diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index df5ac6e81..3b87a49e4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 422b87f52..1fc0268ab 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.3 + placeholder: v3.2.5 validations: required: true - type: dropdown diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d8099923f..57666417a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -8,7 +8,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@v4 + - uses: actions/stale@v5 with: close-issue-message: > This issue has been automatically closed due to lack of activity. In an @@ -27,7 +27,10 @@ jobs: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. NetBox is governed by a small group of core maintainers which means not all opened - issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). + issues may receive direct feedback. **Do not** attempt to circumvent this + process by "bumping" the issue; doing so will result in its immediate closure + and you may be barred from participating in any future discussions. Please see + our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md). stale-pr-label: 'pending closure' stale-pr-message: > This PR has been automatically marked as stale because it has not had diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01adf4c9..1b4733cbe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -160,9 +160,9 @@ to aid in issue management. It is natural that some new issues get more attention than others. The stale bot helps bring renewed attention to potentially valuable issues that may have -been overlooked. **Do not** comment on an issue that has been marked stale in -an effort to circumvent the bot: Doing so will not remove the stale label. -(Stale labels can be removed only by maintainers.) +been overlooked. **Do not** comment on a stale issue merely to "bump" it in an +effort to circumvent the bot: This will result in the immediate closure of the +issue, and you may be barred from participating in future discussions. ## Maintainer Guidance diff --git a/base_requirements.txt b/base_requirements.txt index 6bb537a6a..9bbee6115 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -30,10 +30,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq @@ -44,7 +48,8 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -django-taggit +# Will evaluate v3.0 during NetBox v3.3 beta +django-taggit>=2.1.0,<3.0 # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ @@ -125,3 +130,7 @@ tablib # Timezone data (required by django-timezone-field on Python 3.9+) # https://github.com/python/tzdata tzdata + +# HTML sanitizer +# https://github.com/mozilla/bleach +bleach \ No newline at end of file diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 76fd0a12c..3b1c848a7 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -66,6 +66,14 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. + +--- + ## CSRF_TRUSTED_ORIGINS Default: `[]` @@ -247,6 +255,23 @@ HTTP_PROXIES = { --- +## JINJA2_FILTERS + +Default: `{}` + +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: + +```python +def uppercase(x): + return str(x).upper() + +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + ## INTERNAL_IPS Default: `('127.0.0.1', '::1')` diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index 9421f94fb..3aaa4e99f 100644 --- a/docs/models/circuits/circuit.md +++ b/docs/models/circuits/circuit.md @@ -13,7 +13,7 @@ Each circuit is also assigned one of the following operational statuses: * Deprovisioning * Decommissioned -Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants. +Circuits also have optional fields for annotating their installation and termination dates and commit rate, and may be assigned to NetBox tenants. !!! note NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7fa52fa9f..e3237c2ee 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -11,6 +11,10 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +### Power over Ethernet (PoE) + +Physical interfaces can be assigned a PoE mode to indicate PoE capability: power supplying equipment (PSE) or powered device (PD). Additionally, a PoE mode may be specified. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs). + ### Wireless Interfaces Wireless interfaces may additionally track the following attributes: diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 901a68acf..fb72c218d 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -2,5 +2,4 @@ Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -Each location must have a name that is unique within its parent site and location, if any. - +Each location must have a name that is unique within its parent site and location, if any, and must be assigned an operational status. (The set of available statuses is configurable.) diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index bb4a22e0d..08b5f4fd5 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -5,9 +5,11 @@ Sometimes it is desirable to associate additional data with a group of devices o * Region * Site group * Site +* Location (devices only) * Device type (devices only) * Role * Platform +* Cluster type (VMs only) * Cluster group (VMs only) * Cluster (VMs only) * Tenant group diff --git a/docs/models/users/token.md b/docs/models/users/token.md index d98b51369..367444477 100644 --- a/docs/models/users/token.md +++ b/docs/models/users/token.md @@ -9,4 +9,4 @@ Each token contains a 160-bit key represented as 40 hexadecimal characters. When By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. Tokens can also be restricted by IP range: If defined, authentication for API clients connecting from an IP address outside these ranges will fail. diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 7fc9bfc06..3e3516cd6 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,5 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. +A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification) and operational status, and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index de9b5f214..4ddffb99a 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,6 +1,6 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. +A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to a site and/or cluster, and may optionally be assigned to a particular host device within a cluster. Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md index 77e258def..6dccb4ee2 100644 --- a/docs/plugins/development/tables.md +++ b/docs/plugins/development/tables.md @@ -85,4 +85,5 @@ The table column classes listed below are supported for use in plugins. These cl ::: netbox.tables.TemplateColumn selection: - members: false + members: + - __init__ diff --git a/docs/reference/markdown.md b/docs/reference/markdown.md new file mode 100644 index 000000000..896d5dcf7 --- /dev/null +++ b/docs/reference/markdown.md @@ -0,0 +1,353 @@ +--- +hide: + - toc +--- + +# Markdown + +NetBox supports markdown rendering for certain text fields. + +## Syntax + +##### Table of Contents +[Headers](#headers) +[Emphasis](#emphasis) +[Lists](#lists) +[Links](#links) +[Images](#images) +[Code Blocks](#code) +[Tables](#tables) +[Blockquotes](#blockquotes) +[Inline HTML](#html) +[Horizontal Rule](#hr) +[Line Breaks](#lines) + + + +## Headers + +```no-highlight +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + +Alternatively, for H1 and H2, an underline-ish style: + +Alt-H1 +====== + +Alt-H2 +------ +``` + +# H1 +## H2 +### H3 +#### H4 +##### H5 +###### H6 + + + +## Emphasis + +```no-highlight +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ +``` + +Emphasis, aka italics, with *asterisks* or _underscores_. + +Strong emphasis, aka bold, with **asterisks** or __underscores__. + +Combined emphasis with **asterisks and _underscores_**. + +Strikethrough uses two tildes. ~~Scratch this.~~ + + + + +## Lists + +(In this example, leading and trailing spaces are shown with with dots: ⋅) + +```no-highlight +1. First ordered list item +2. Another item +⋅⋅* Unordered sub-list. +1. Actual numbers don't matter, just that it's a number +⋅⋅1. Ordered sub-list +4. And another item. + +⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + +⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅ +⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅ +⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses +``` + +1. First ordered list item +2. Another item + * Unordered sub-list. +1. Actual numbers don't matter, just that it's a number + 1. Ordered sub-list +4. And another item. + + You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown). + + To have a line break without a paragraph, you will need to use two trailing spaces. + Note that this line is separate, but within the same paragraph. + (This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.) + +* Unordered list can use asterisks +- Or minuses ++ Or pluses + + + +## Links + +There are two ways to create links. + +```no-highlight +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com +``` + +[I'm an inline-style link](https://www.google.com) + +[I'm an inline-style link with title](https://www.google.com "Google's Homepage") + +[I'm a reference-style link][Arbitrary case-insensitive reference text] + +[You can use numbers for reference-style link definitions][1] + +Or leave it empty and use the [link text itself]. + +URLs and URLs in angle brackets will automatically get turned into links. +http://www.example.com or and sometimes +example.com (but not on Github, for example). + +Some text to show that the reference links can follow later. + +[arbitrary case-insensitive reference text]: https://www.mozilla.org +[1]: http://slashdot.org +[link text itself]: http://www.reddit.com + + + +## Images + +``` +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" +``` + +Here's the Netbox logo (hover to see the title text): + +Inline-style: +![alt text](/static/netbox_logo.png "Logo Title Text 1") + +Reference-style: +![alt text][logo] + +[logo]: /static/netbox_logo.png "Logo Title Text 2" + + + +## Code blocks + +``` +Inline `code` has `back-ticks around` it. +``` + +Inline `code` has `back-ticks around` it. + +Blocks of code are fenced by lines with three back-ticks ``` + +```` +``` +var s = "Code block"; +alert(s); +``` +```` + +``` +var s = "Code block"; +alert(s); +``` + + + +## Tables + +```no-highlight +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. +The outer pipes (|) are optional, and you don't need to make the +raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 +``` + +Colons can be used to align columns. + +| Tables | Are | Cool | +| ------------- |:-------------:| -----:| +| col 3 is | right-aligned | $1600 | +| col 2 is | centered | $12 | +| zebra stripes | are neat | $1 | + +There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown. + +Markdown | Less | Pretty +--- | --- | --- +*Still* | `renders` | **nicely** +1 | 2 | 3 + + + +## Blockquotes + +```no-highlight +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. +``` + +> Blockquotes are very handy in email to emulate reply text. +> This line is part of the same quote. + +Quote break. + +> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote. + + + +## Inline HTML + +You can also use raw HTML in your Markdown, and it'll mostly work pretty well. + +```no-highlight +
+
Definition list
+
Is something people use sometimes.
+ +
Markdown in HTML
+
Does *not* work **very** well. Use HTML tags.
+
+``` + +
+
Definition list
+
Is something people use sometimes.
+ +
Markdown in HTML
+
Does *not* work **very** well. Use HTML tags.
+
+ + + +## Horizontal Rule + +``` +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores +``` + +Three or more... + +--- + +Hyphens + +*** + +Asterisks + +___ + +Underscores + + + +## Line Breaks + + +``` +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. +``` + +Here's a line for us to start with. + +This line is separated from the one above by two newlines, so it will be a *separate paragraph*. + +This line is also begins a separate paragraph, but... +This line is only separated by a single newline, so it's a separate line in the *same paragraph*. + +Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/) \ No newline at end of file diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index 0c56c92f7..059fc8924 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -1,5 +1,69 @@ # NetBox v3.2 +## v3.2.6 (FUTURE) + +--- + +## v3.2.5 (2022-06-20) + +### Enhancements + +* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list +* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes +* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view +* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module +* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters +* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view +* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables +* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation +* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms +* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields + +### Bug Fixes + +* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons +* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links +* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data +* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure +* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers +* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view +* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view +* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column +* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs +* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN +* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form +* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI +* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view + +--- + +## v3.2.4 (2022-05-31) + +### Enhancements + +* [#8374](https://github.com/netbox-community/netbox/issues/8374) - Display device type and asset tag if name is blank but asset tag is populated +* [#8922](https://github.com/netbox-community/netbox/issues/8922) - Add service list to IP address view +* [#9098](https://github.com/netbox-community/netbox/issues/9098) - Add "other" types for power ports/outlets, pass-through ports +* [#9239](https://github.com/netbox-community/netbox/issues/9239) - Enable filtering by contact group for all models which support contact assignment +* [#9277](https://github.com/netbox-community/netbox/issues/9277) - Introduce `CSRF_COOKIE_NAME` configuration parameter +* [#9347](https://github.com/netbox-community/netbox/issues/9347) - Include services in global search +* [#9379](https://github.com/netbox-community/netbox/issues/9379) - Redirect to virtual chassis view after adding a member device +* [#9451](https://github.com/netbox-community/netbox/issues/9451) - Add `export_raw` argument for TemplateColumn + +### Bug Fixes + +* [#9094](https://github.com/netbox-community/netbox/issues/9094) - Fix partial address search within Prefix and Aggregate filters +* [#9291](https://github.com/netbox-community/netbox/issues/9291) - Improve data validation for MultiObjectVar script fields +* [#9358](https://github.com/netbox-community/netbox/issues/9358) - Annotate circuit count for providers list under ASN view +* [#9387](https://github.com/netbox-community/netbox/issues/9387) - Ensure ActionsColumn `extra_buttons` are always displayed +* [#9402](https://github.com/netbox-community/netbox/issues/9402) - Fix custom field population when creating a virtual chassis +* [#9407](https://github.com/netbox-community/netbox/issues/9407) - Clean up display of prefixes values when exporting prefixes list +* [#9420](https://github.com/netbox-community/netbox/issues/9420) - Fix custom script class inheritance +* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields +* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields + +--- + ## v3.2.3 (2022-05-12) ### Enhancements diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md new file mode 100644 index 000000000..81125451e --- /dev/null +++ b/docs/release-notes/version-3.3.md @@ -0,0 +1,63 @@ +# NetBox v3.3 + +## v3.3.0 (FUTURE) + +### Breaking Changes + +* Device position and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. +* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). + +### New Features + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + +#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) + +#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) + +### Enhancements + +* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations +* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit +* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location +* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster +* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster +* [#8495](https://github.com/netbox-community/netbox/issues/8495) - Enable custom field grouping +* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#9166](https://github.com/netbox-community/netbox/issues/9166) - Add UI visibility toggle for custom fields +* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location + +### Other Changes + +* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output + +### REST API Changes + +* circuits.Circuit + * Added optional `termination_date` field +* dcim.Device + * The `position` field has been changed from an integer to a decimal +* dcim.DeviceType + * The `u_height` field has been changed from an integer to a decimal +* dcim.Interface + * Added the optional `poe_mode` and `poe_type` fields +* dcim.Location + * Added required `status` field (default value: `active`) +* dcim.Rack + * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit +* extras.ConfigContext + * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations +* extras.CustomField + * Added `group_name` and `ui_visibility` fields +* ipam.IPAddress + * The `nat_inside` field no longer requires a unique value + * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses +* virtualization.Cluster + * Added required `status` field (default value: `active`) +* virtualization.VirtualMachine + * Added `device` field + * The `site` field is now directly writable (rather than being inferred from the assigned cluster) + * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index 45dfcfa36..7ddda6f3c 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it ```no-highlight GET /api/ipam/vlans/?group_id__n=3203 ``` + +## Ordering Objects + +To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values: + +```no-highlight +GET /api/dcim/sites/?ordering=facility +``` + +To invert the ordering, prepend a hyphen to the field name: + +```no-highlight +GET /api/dcim/sites/?ordering=-facility +``` + +Multiple fields can be specified by separating the field names with a comma. For example: + +```no-highlight +GET /api/dcim/sites/?ordering=facility,-name +``` diff --git a/mkdocs.yml b/mkdocs.yml index 5c973e0d6..507b25627 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -136,6 +136,7 @@ nav: - Overview: 'graphql-api/overview.md' - Reference: - Conditions: 'reference/conditions.md' + - Markdown: 'reference/markdown.md' - Development: - Introduction: 'development/index.md' - Getting Started: 'development/getting-started.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 19570f067..2bb3cd266 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -92,9 +92,9 @@ class CircuitSerializer(NetBoxModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', - 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', + 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index b7fa100a8..67a0d1b02 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'commit_rate'] + fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 6e283219a..b6ba42afb 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect, ) @@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) + install_date = forms.DateField( + required=False, + widget=DatePicker() + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker() + ) commit_rate = forms.IntegerField( required=False, label='Commit rate (Kbps)' @@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ('Circuit', ('provider', 'type', 'status', 'description')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), + ('Tenancy', ('tenant',)), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 6da79f75c..cc2d0409a 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm): class Meta: model = Circuit fields = [ - 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', + 'description', 'comments', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ca3b003b9..29410ffdf 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField +from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -23,7 +23,7 @@ class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('ASN', ('asn',)), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -84,10 +84,10 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( (None, ('q', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), - ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), @@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + install_date = forms.DateField( + required=False, + widget=DatePicker + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker + ) commit_rate = forms.IntegerField( required=False, min_value=0, diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 8fd5fb92d..907c39586 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Circuit fields = [ - 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', 'tags', + 'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description', + 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", @@ -110,6 +111,7 @@ class CircuitForm(TenancyForm, NetBoxModelForm): widgets = { 'status': StaticSelect(), 'install_date': DatePicker(), + 'termination_date': DatePicker(), 'commit_rate': SelectSpeedWidget(), } diff --git a/netbox/circuits/migrations/0036_circuit_termination_date.py b/netbox/circuits/migrations/0036_circuit_termination_date.py new file mode 100644 index 000000000..0a8adfbe6 --- /dev/null +++ b/netbox/circuits/migrations/0036_circuit_termination_date.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 18:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0035_provider_asns'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 02ba5209d..5df6f1b85 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -78,7 +78,12 @@ class Circuit(NetBoxModel): install_date = models.DateField( blank=True, null=True, - verbose_name='Date installed' + verbose_name='Installed' + ) + termination_date = models.DateField( + blank=True, + null=True, + verbose_name='Terminates' ) commit_rate = models.PositiveIntegerField( blank=True, @@ -119,7 +124,7 @@ class Circuit(NetBoxModel): ) clone_fields = [ - 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', + 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', ] class Meta: diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index 40f8918ae..8b59700ee 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -70,7 +70,7 @@ class CircuitTable(NetBoxTable): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 205236712..28e0a3fe3 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), - Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), + Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), ) Circuit.objects.bulk_create(circuits) @@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'install_date': ['2020-01-01', '2020-01-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_termination_date(self): + params = {'termination_date': ['2021-01-01', '2021-01-02']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_commit_rate(self): params = {'commit_rate': ['1000', '2000']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 17c846c86..f60275ff3 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, 'tenant': None, 'install_date': datetime.date(2020, 1, 1), + 'termination_date': datetime.date(2021, 1, 1), 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7fcab6ba3..8ac2aa738 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,5 @@ +import decimal + from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -149,6 +151,7 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) @@ -156,8 +159,8 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] @@ -201,7 +204,11 @@ class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. """ - id = serializers.IntegerField(read_only=True) + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -246,7 +253,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( - default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH ) exclude = serializers.IntegerField( required=False, @@ -283,6 +293,13 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=1.0 + ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -589,7 +606,14 @@ class DeviceSerializer(NetBoxModelSerializer): location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') - position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=None + ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) @@ -789,6 +813,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -813,10 +839,10 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', - 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', + 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', + 'vrf', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e99ef333a..c4c25f654 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata +from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model @@ -392,6 +393,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filtersets.DeviceFilterSet + pagination_class = StripCountAnnotationsPaginator def get_serializer_class(self): """ diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index a89960457..94c8b255f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet): ] +# +# Locations +# + +class LocationStatusChoices(ChoiceSet): + key = 'Location.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_RETIRED = 'retired' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] + + # # Racks # @@ -354,6 +376,7 @@ class PowerPortTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -471,6 +494,7 @@ class PowerPortTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -580,6 +604,7 @@ class PowerOutletTypeChoices(ChoiceSet): TYPE_UBIQUITI_SMARTPOWER = 'ubiquiti-smartpower' # Other TYPE_HARDWIRED = 'hardwired' + TYPE_OTHER = 'other' CHOICES = ( ('IEC 60320', ( @@ -690,6 +715,7 @@ class PowerOutletTypeChoices(ChoiceSet): )), ('Other', ( (TYPE_HARDWIRED, 'Hardwired'), + (TYPE_OTHER, 'Other'), )), ) @@ -999,6 +1025,51 @@ class InterfaceModeChoices(ChoiceSet): ) +class InterfacePoEModeChoices(ChoiceSet): + + MODE_PD = 'pd' + MODE_PSE = 'pse' + + CHOICES = ( + (MODE_PD, 'Powered device (PD)'), + (MODE_PSE, 'Power sourcing equipment (PSE)'), + ) + + +class InterfacePoETypeChoices(ChoiceSet): + + TYPE_1_8023AF = 'type1-ieee802.3af' + TYPE_2_8023AT = 'type2-ieee802.3at' + TYPE_3_8023BT = 'type3-ieee802.3bt' + TYPE_4_8023BT = 'type4-ieee802.3bt' + + PASSIVE_24V_2PAIR = 'passive-24v-2pair' + PASSIVE_24V_4PAIR = 'passive-24v-4pair' + PASSIVE_48V_2PAIR = 'passive-48v-2pair' + PASSIVE_48V_4PAIR = 'passive-48v-4pair' + + CHOICES = ( + ( + 'IEEE Standard', + ( + (TYPE_1_8023AF, '802.3af (Type 1)'), + (TYPE_2_8023AT, '802.3at (Type 2)'), + (TYPE_3_8023BT, '802.3bt (Type 3)'), + (TYPE_4_8023BT, '802.3bt (Type 4)'), + ) + ), + ( + 'Passive', + ( + (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), + (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + ) + ), + ) + + # # FrontPorts/RearPorts # @@ -1047,6 +1118,7 @@ class PortTypeChoices(ChoiceSet): TYPE_URM_P2 = 'urm-p2' TYPE_URM_P4 = 'urm-p4' TYPE_URM_P8 = 'urm-p8' + TYPE_OTHER = 'other' CHOICES = ( ( @@ -1099,6 +1171,12 @@ class PortTypeChoices(ChoiceSet): (TYPE_URM_P4, 'URM-P4'), (TYPE_URM_P8, 'URM-P8'), (TYPE_SPLICE, 'Splice'), + ), + ), + ( + 'Other', + ( + (TYPE_OTHER, 'Other'), ) ) ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..68bbd1dbe 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 -RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 +RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 +RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d57d0a59b..628bd58f6 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet): @@ -216,10 +216,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=LocationStatusChoices, + null_value=None + ) class Meta: model = Location - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'status', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -1238,6 +1242,12 @@ class InterfaceFilterSet( ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1271,8 +1281,8 @@ class InterfaceFilterSet( class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', ] def filter_device(self, queryset, name, value): diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 314a7a75f..43b852928 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, [ + 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', + ]), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'poe_type', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9e4f5e400..b4ab226ae 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, VLAN, VRF +from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + status = forms.ChoiceField( + choices=add_blank_choice(LocationStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'tenant', 'description')), + (None, ('site', 'parent', 'status', 'tenant', 'description')), ) nullable_fields = ('parent', 'tenant', 'description') @@ -1063,17 +1069,48 @@ class InterfaceBulkEditForm( widget=BulkEditNullBooleanSelect, label='Management only' ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect() + ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect ) + mode = forms.ChoiceField( + choices=add_blank_choice(InterfaceModeChoices), + required=False, + initial='', + widget=StaticSelect() + ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -1086,14 +1123,15 @@ class InterfaceBulkEditForm( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - 'vrf', + 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index b28c16fad..d6ec0f6f4 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm): 'invalid_choice': 'Location not found.', } ) + status = CSVChoiceField( + choices=LocationStatusChoices, + help_text='Operational status' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm): choices=InterfaceDuplexChoices, required=False ) + poe_mode = CSVChoiceField( + choices=InterfacePoEModeChoices, + required=False, + help_text='PoE mode' + ) + poe_type = CSVChoiceField( + choices=InterfacePoETypeChoices, + required=False, + help_text='PoE type' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', + 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0c7d02f9d..d9bc79fb5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -108,7 +108,7 @@ class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -122,7 +122,7 @@ class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup fieldsets = ( (None, ('q', 'tag', 'parent_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), @@ -138,7 +138,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (None, ('q', 'tag')), ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) status = MultipleChoiceField( choices=SiteStatusChoices, @@ -166,9 +166,9 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF model = Location fieldsets = ( (None, ('q', 'tag')), - ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) + status = MultipleChoiceField( + choices=LocationStatusChoices, + required=False + ) tag = TagFilterField(model) @@ -214,7 +218,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte ('Function', ('status', 'role_id')), ('Hardware', ('type', 'width', 'serial', 'asset_tag')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -329,7 +333,7 @@ class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer fieldsets = ( (None, ('q', 'tag')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) tag = TagFilterField(model) @@ -518,7 +522,7 @@ class DeviceFilterForm( ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', )), @@ -788,7 +792,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -969,6 +973,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) @@ -1009,6 +1014,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + poe_mode = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) + poe_type = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False + ) rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, @@ -1102,7 +1115,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('name', 'label', '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', 'virtual_chassis_id', 'device_id')), ) role_id = DynamicModelMultipleChoiceField( diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 179893219..7aa2a8584 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'tags', ) + widgets = { + 'status': StaticSelect(), + } class RackRoleForm(NetBoxModelForm): @@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'location_id': '$location', } ) - position = forms.IntegerField( + position = forms.DecimalField( required=False, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( @@ -1314,6 +1318,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('PoE', ('poe_mode', 'poe_type')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1324,14 +1329,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = Interface fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', - 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', - 'vrf', 'tags', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index e3e9c1179..8c9ddab19 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -256,6 +256,8 @@ class VirtualChassisCreateForm(NetBoxModelForm): ] def clean(self): + super().clean() + if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None: raise forms.ValidationError({ 'initial_position': "A position must be specified for the first VC member." diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d25a6bba6..17d6bc646 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -226,6 +226,12 @@ class InterfaceType(IPAddressesMixin, ComponentObjectType): exclude = ('_path',) filterset_class = filtersets.InterfaceFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + def resolve_mode(self, info): return self.mode or None diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + 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)]), + ), + ] diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py new file mode 100644 index 000000000..0615d5d7e --- /dev/null +++ b/netbox/dcim/migrations/0155_interface_poe_mode_type.py @@ -0,0 +1,23 @@ +# Generated by Django 4.0.5 on 2022-06-22 00:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0154_half_height_rack_units'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0156_location_status.py new file mode 100644 index 000000000..b20273755 --- /dev/null +++ b/netbox/dcim/migrations/0156_location_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_interface_poe_mode_type'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9a0609c12..f49db08ab 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -590,6 +590,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo validators=(MaxValueValidator(127),), verbose_name='Transmit power (dBm)' ) + poe_mode = models.CharField( + max_length=50, + choices=InterfacePoEModeChoices, + blank=True, + verbose_name='PoE mode' + ) + poe_type = models.CharField( + max_length=50, + choices=InterfacePoETypeChoices, + blank=True, + verbose_name='PoE type' + ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', on_delete=models.SET_NULL, @@ -638,7 +650,7 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo related_query_name='+' ) - clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] + clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'poe_mode', 'poe_type'] class Meta: ordering = ('device', CollateAsChar('_name')) @@ -726,6 +738,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo f"of virtual chassis {self.device.virtual_chassis}." }) + # PoE validation + + # Only physical interfaces may have a PoE mode/type assigned + if self.poe_mode and self.is_virtual: + raise ValidationError({ + 'poe_mode': "Virtual interfaces cannot have a PoE mode." + }) + if self.poe_type and self.is_virtual: + raise ValidationError({ + 'poe_type': "Virtual interfaces cannot have a PoE type." + }) + + # An interface with a PoE type set must also specify a mode + if self.poe_type and not self.poe_mode: + raise ValidationError({ + 'poe_type': "Must specify PoE mode when designating a PoE type." + }) + # Wireless validation # RF role & channel may only be set for wireless interfaces diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d50db958..43b84974b 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -99,8 +99,10 @@ class DeviceType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - u_height = models.PositiveSmallIntegerField( - default=1, + u_height = models.DecimalField( + max_digits=4, + decimal_places=1, + default=1.0, verbose_name='Height (U)' ) is_full_depth = models.BooleanField( @@ -166,7 +168,7 @@ class DeviceType(NetBoxModel): ('model', self.model), ('slug', self.slug), ('part_number', self.part_number), - ('u_height', self.u_height), + ('u_height', float(self.u_height)), ('is_full_depth', self.is_full_depth), ('subdevice_role', self.subdevice_role), ('airflow', self.airflow), @@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel): blank=True, null=True ) - position = models.PositiveSmallIntegerField( + position = models.DecimalField( + max_digits=4, + decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) @@ -748,8 +752,12 @@ class Device(NetBoxModel, ConfigContextModel): return f'{self.name} ({self.asset_tag})' elif self.name: return self.name + elif self.virtual_chassis and self.asset_tag: + return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})' elif self.virtual_chassis: return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})' + elif self.device_type and self.asset_tag: + return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})' elif self.device_type: return f'{self.device_type.manufacturer} {self.device_type.model} ({self.pk})' return super().__str__() diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 81d699b11..39e01cae3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +import decimal from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericRelation @@ -13,11 +13,10 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.config import get_config from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string +from utilities.utils import array_to_string, drange from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -242,10 +241,13 @@ class Rack(NetBoxModel): @property def units(self): + """ + Return a list of unit numbers, top to bottom. + """ + max_position = self.u_height + decimal.Decimal(0.5) if self.desc_units: - return range(1, self.u_height + 1) - else: - return reversed(range(1, self.u_height + 1)) + drange(0.5, max_position, 0.5) + return drange(max_position, 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -263,12 +265,12 @@ class Rack(NetBoxModel): reference to the device. When False, only the bottom most unit for a device is included and that unit contains a height attribute for the device """ - - elevation = OrderedDict() + elevation = {} for u in self.units: + u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}' elevation[u] = { 'id': u, - 'name': f'U{u}', + 'name': u_name, 'face': face, 'device': None, 'occupied': False @@ -278,7 +280,7 @@ class Rack(NetBoxModel): if self.pk: # Retrieve all devices installed within the rack - queryset = Device.objects.prefetch_related( + devices = Device.objects.prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -299,9 +301,9 @@ class Rack(NetBoxModel): if user is not None: permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True) - for device in queryset: + for device in devices: if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): + for u in drange(device.position, device.position + device.device_type.u_height, 0.5): if user is None or device.pk in permitted_device_ids: elevation[u]['device'] = device elevation[u]['occupied'] = True @@ -310,8 +312,6 @@ class Rack(NetBoxModel): elevation[device.position]['device'] = device elevation[device.position]['occupied'] = True elevation[device.position]['height'] = device.device_type.u_height - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) return [u for u in elevation.values()] @@ -331,12 +331,12 @@ class Rack(NetBoxModel): devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) + units = list(self.units) # Remove units consumed by installed devices for d in devices: if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: - for u in range(d.position, d.position + d.device_type.u_height): + for u in drange(d.position, d.position + d.device_type.u_height, 0.5): try: units.remove(u) except ValueError: @@ -346,7 +346,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(range(u, u + u_height)).issubset(units): + if set(drange(u, u + u_height, 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -356,9 +356,9 @@ class Rack(NetBoxModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): - for u in r.units: - reserved_units[u] = r + for reservation in self.reservations.all(): + for u in reservation.units: + reserved_units[u] = reservation return reserved_units def get_elevation_svg( @@ -367,7 +367,8 @@ class Rack(NetBoxModel): user=None, unit_width=None, unit_height=None, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, + margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, base_url=None ): @@ -381,16 +382,22 @@ class Rack(NetBoxModel): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels + :param margin_width: Width of the rigth-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ - elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) - if unit_width is None or unit_height is None: - config = get_config() - unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + elevation = RackElevationSVG( + self, + unit_width=unit_width, + unit_height=unit_height, + legend_width=legend_width, + margin_width=margin_width, + user=user, + include_images=include_images, + base_url=base_url + ) - return elevation.render(face, unit_width, unit_height, legend_width) + return elevation.render(face) def get_0u_devices(self): return self.devices.filter(position=0) @@ -401,6 +408,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -408,8 +416,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index d02bd0932..9b7ffdcf4 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -341,6 +341,11 @@ class Location(NestedGroupModel): null=True, db_index=True ) + status = models.CharField( + max_length=50, + choices=LocationStatusChoices, + default=LocationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -367,7 +372,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'tenant', 'description'] + clone_fields = ['site', 'parent', 'status', 'tenant', 'description'] class Meta: ordering = ['site', 'name'] @@ -409,6 +414,9 @@ class Location(NestedGroupModel): def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) + def get_status_color(self): + return LocationStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/dcim/svg/__init__.py b/netbox/dcim/svg/__init__.py new file mode 100644 index 000000000..21e27d495 --- /dev/null +++ b/netbox/dcim/svg/__init__.py @@ -0,0 +1,2 @@ +from .cables import * +from .racks import * diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg/cables.py similarity index 53% rename from netbox/dcim/svg.py rename to netbox/dcim/svg/cables.py index 7cd0fa417..eb0d2aca1 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg/cables.py @@ -1,272 +1,17 @@ import svgwrite + +from django.conf import settings from svgwrite.container import Group, Hyperlink from svgwrite.shapes import Line, Rect from svgwrite.text import Text -from django.conf import settings -from django.urls import reverse -from django.utils.http import urlencode - from utilities.utils import foreground_color -from .choices import DeviceFaceChoices -from .constants import RACK_ELEVATION_BORDER_WIDTH - __all__ = ( 'CableTraceSVG', - 'RackElevationSVG', ) -def get_device_name(device): - if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' - elif device.name: - return device.name - else: - return str(device.device_type) - - -class RackElevationSVG: - """ - Use this class to render a rack elevation as an SVG image. - - :param rack: A NetBox Rack instance - :param user: User instance. If specified, only devices viewable by this user will be fully displayed. - :param include_images: If true, the SVG document will embed front/rear device face images, where available - :param base_url: Base URL for links within the SVG document. If none, links will be relative. - """ - def __init__(self, rack, user=None, include_images=True, base_url=None): - self.rack = rack - self.include_images = include_images - if base_url is not None: - self.base_url = base_url.rstrip('/') - else: - self.base_url = '' - - # Determine the subset of devices within this rack that are viewable by the user, if any - permitted_devices = self.rack.devices - if user is not None: - permitted_devices = permitted_devices.restrict(user, 'view') - self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - - @staticmethod - def _get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - - @staticmethod - def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( - start=(0, 0), - end=(0, 25), - spreadMethod='repeat', - id_=id_, - gradientTransform='rotate(45, 0, 0)', - gradientUnits='userSpaceOnUse' - ) - gradient.add_stop_color(offset='0%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color=color) - gradient.add_stop_color(offset='100%', color=color) - drawing.defs.add(gradient) - - @staticmethod - def _setup_drawing(width, height): - drawing = svgwrite.Drawing(size=(width, height)) - - # add the stylesheet - with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: - drawing.defs.add(drawing.style(css_file.read())) - - # add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') - - return drawing - - def _draw_device_front(self, drawing, device, start, end, text): - name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - - color = device.device_role.color - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) - - # Embed front device type image if one exists - if self.include_images and device.device_type.front_image: - image = drawing.image( - href=device.device_type.front_image.url, - insert=start, - size=end, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) - - def _draw_device_rear(self, drawing, device, start, end, text): - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text)) - - # Embed rear device type image if one exists - if self.include_images and device.device_type.rear_image: - image = drawing.image( - href=device.device_type.rear_image.url, - insert=start, - size=end, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - - @staticmethod - def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): - link_url = '{}?{}'.format( - reverse('dcim:device_add'), - urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': id_ - }) - ) - link = drawing.add( - drawing.a(href=link_url, target='_top') - ) - if reservation: - link.set_desc('{} — {} · {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) - - def merge_elevations(self, face): - elevation = self.rack.get_rack_units(face=face, expand_devices=False) - if face == DeviceFaceChoices.FACE_REAR: - other_face = DeviceFaceChoices.FACE_FRONT - else: - other_face = DeviceFaceChoices.FACE_REAR - other = self.rack.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def render(self, face, unit_width, unit_height, legend_width): - """ - Return an SVG document representing a rack elevation. - """ - drawing = self._setup_drawing( - unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, - unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - ) - reserved_units = self.rack.get_reserved_units() - - unit_cursor = 0 - for ru in range(0, self.rack.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) - unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") - ) - - for unit in self.merge_elevations(face): - - # Loop through all units in the elevation - device = unit['device'] - height = unit.get('height', 1) - - # Setup drawing coordinates - x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH - y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH - end_y = unit_height * height - start_cordinates = (x_offset, y_offset) - end_cordinates = (unit_width, end_y) - text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) - - # Draw the device - if device and device.face == face and device.pk in self.permitted_device_ids: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device: - # Devices which the user does not have permission to view are rendered only as unavailable space - drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, - self.rack, - start_cordinates, - end_cordinates, - text_cordinates, - unit["id"], - face, - class_, - reservation - ) - - unit_cursor += height - - # Wrap the drawing with a border - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = drawing.rect( - insert=(legend_width + border_offset, border_offset), - size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), - class_='rack' - ) - drawing.add(frame) - - return drawing - - OFFSET = 0.5 PADDING = 10 LINE_HEIGHT = 20 diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py new file mode 100644 index 000000000..b344aad0a --- /dev/null +++ b/netbox/dcim/svg/racks.py @@ -0,0 +1,300 @@ +import decimal +import svgwrite +from svgwrite.container import Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient +from svgwrite.shapes import Rect +from svgwrite.text import Text + +from django.conf import settings +from django.urls import reverse +from django.utils.http import urlencode + +from netbox.config import get_config +from utilities.utils import foreground_color, array_to_ranges +from dcim.choices import DeviceFaceChoices +from dcim.constants import RACK_ELEVATION_BORDER_WIDTH + + +__all__ = ( + 'RackElevationSVG', +) + + +def get_device_name(device): + if device.virtual_chassis: + name = f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + name = device.name + else: + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) + + +class RackElevationSVG: + """ + Use this class to render a rack elevation as an SVG image. + + :param rack: A NetBox Rack instance + :param user: User instance. If specified, only devices viewable by this user will be fully displayed. + :param include_images: If true, the SVG document will embed front/rear device face images, where available + :param base_url: Base URL for links within the SVG document. If none, links will be relative. + """ + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, + include_images=True, base_url=None): + self.rack = rack + self.include_images = include_images + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH + + # Determine the subset of devices within this rack that are viewable by the user, if any + permitted_devices = self.rack.devices + if user is not None: + permitted_devices = permitted_devices.restrict(user, 'view') + self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = LinearGradient( + start=(0, 0), + end=(0, 25), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(45, 0, 0)', + gradientUnits='userSpaceOnUse' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color=color) + gradient.add_stop_color(offset='100%', color=color) + + drawing.defs.add(gradient) + + def _setup_drawing(self): + width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 + drawing = svgwrite.Drawing(size=(width, height)) + + # Add the stylesheet + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: + drawing.defs.add(drawing.style(css_file.read())) + + # Add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') + RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + + return drawing + + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device(self, device, coords, size, color=None, image=None): + name = get_device_name(device) + description = get_device_description(device) + text_coords = ( + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 + ) + text_color = f'#{foreground_color(color)}' if color else '#000000' + + # Create hyperlink element + link = Hyperlink( + href='{}{}'.format( + self.base_url, + reverse('dcim:device', kwargs={'pk': device.pk}) + ), + target='_blank', + ) + link.set_desc(description) + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + else: + link.add(Rect(coords, size, class_='slot blocked')) + link.add(Text(name, insert=text_coords, fill=text_color)) + + # Embed device type image if provided + if self.include_images and image: + image = Image( + href='{}{}'.format(self.base_url, image.url), + insert=coords, + size=size, + class_='device-image' + ) + image.fit(scale='slice') + link.add(image) + link.add(Text(name, insert=text_coords, stroke='black', + stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) + link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) + + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' + ) + self.drawing.add(frame) + + def draw_legend(self): + """ + Draw the rack unit labels along the lefthand side of the elevation. + """ + for ru in range(0, self.rack.u_height): + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + self.drawing.add( + Text(str(unit), position_coordinates, class_='unit') + ) + + def draw_margin(self): + """ + Draw any rack reservations in the right-hand margin alongside the rack elevation. + """ + for reservation in self.rack.reservations.all(): + for segment in array_to_ranges(reservation.units): + u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0] + coords = self._get_device_coords(segment[0], u_height) + coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) + size = ( + self.margin_width, + u_height * self.unit_height + ) + link = Hyperlink( + href='{}{}'.format(self.base_url, reservation.get_absolute_url()), + target='_blank' + ) + link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') + link.add( + Rect(coords, size, class_='reservation') + ) + self.drawing.add(link) + + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 + ) + + link = Hyperlink(href=url_string.format(ru), target='_blank') + link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(Text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): + + # Loop through all units in the elevation + device = unit['device'] + height = unit.get('height', decimal.Decimal(1.0)) + + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) + + # Draw the device + if device and device.pk in self.permitted_device_ids: + if device.face == face and not opposite: + self.draw_device_front(device, device_coords, device_size) + else: + self.draw_device_rear(device, device_coords, device_size) + + elif device: + # Devices which the user does not have permission to view are rendered only as unavailable space + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # Draw the empty rack, legend, and margin + self.draw_legend() + self.draw_background(face) + self.draw_margin() + + # Draw the rack face + self.draw_face(face) + + # Draw the rack border last + self.draw_border() + + return self.drawing diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0f015b7f3..b3dd700cb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -520,10 +520,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', - 'tagged_vlans', 'created', 'last_updated', + 'speed', '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', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index fa3c73e12..83db99aec 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -126,6 +126,7 @@ class LocationTable(NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = TenantColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', @@ -150,7 +151,7 @@ class LocationTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description', 'slug', + 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') + default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 92739c6ed..7124c2b1f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22537abe0..436f43b6f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -197,13 +197,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) parent_locations = ( - Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), - Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE), ) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) cls.create_data = [ { @@ -211,18 +211,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'slug': 'test-location-4', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 5', 'slug': 'test-location-5', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, ] @@ -327,15 +330,15 @@ class RackTest(APIViewTestCases.APIViewTestCase): # Retrieve all units response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 42) + self.assertEqual(response.data['count'], 84) # Search for specific units response = self.client.get(f'{url}?q=3', **self.header) - self.assertEqual(response.data['count'], 13) + self.assertEqual(response.data['count'], 26) response = self.client.get(f'{url}?q=U3', **self.header) - self.assertEqual(response.data['count'], 11) + self.assertEqual(response.data['count'], 22) response = self.client.get(f'{url}?q=U10', **self.header) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['count'], 2) def test_get_rack_elevation_svg(self): """ @@ -1507,6 +1510,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'speed': 1000000, 'duplex': 'full', 'vrf': vrfs[0].pk, + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 273ee6570..9df75f4c0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'), ) for location in locations: location.save() @@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2540,14 +2544,109 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), - Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), - Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), - Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), - Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), - Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), - Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), + Interface( + device=devices[0], + module=modules[0], + name='Interface 1', + label='A', + type=InterfaceTypeChoices.TYPE_1GE_SFP, + enabled=True, + mgmt_only=True, + mtu=100, + mode=InterfaceModeChoices.MODE_ACCESS, + mac_address='00-00-00-00-00-01', + description='First', + vrf=vrfs[0], + speed=1000000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[1], + module=modules[1], + name='Interface 2', + label='B', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + enabled=True, + mgmt_only=True, + mtu=200, + mode=InterfaceModeChoices.MODE_TAGGED, + mac_address='00-00-00-00-00-02', + description='Second', + vrf=vrfs[1], + speed=1000000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[2], + module=modules[2], + name='Interface 3', + label='C', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + enabled=False, + mgmt_only=False, + mtu=300, + mode=InterfaceModeChoices.MODE_TAGGED_ALL, + mac_address='00-00-00-00-00-03', + description='Third', + vrf=vrfs[2], + speed=100000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 4', + label='D', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40, + speed=100000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 5', + label='E', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 6', + label='F', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=False, + mgmt_only=False, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 7', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_AP, + rf_channel=WirelessChannelChoices.CHANNEL_24G_1, + rf_channel_frequency=2412, + rf_channel_width=22 + ), + Interface( + device=devices[3], + name='Interface 8', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_STATION, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ), ) Interface.objects.bulk_create(interfaces) @@ -2594,6 +2693,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_poe_type(self): + params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 8566f969b..98d57801d 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -5,6 +5,7 @@ from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant +from utilities.utils import drange class LocationTestCase(TestCase): @@ -74,148 +75,142 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( - name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=rack1, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=self.rack, - position=10, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, + position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) - self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del(rack1_inventory_front[-10]) - for u in rack1_inventory_front: + rack1_inventory_front = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + } + self.assertEqual(rack1_inventory_front[10.0]['device'], device1) + self.assertEqual(rack1_inventory_front[10.5]['device'], device1) + del(rack1_inventory_front[10.0]) + del(rack1_inventory_front[10.5]) + for u in rack1_inventory_front.values(): self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) - self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del(rack1_inventory_rear[-10]) - for u in rack1_inventory_rear: + rack1_inventory_rear = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + } + self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) + self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) + del(rack1_inventory_rear[10.0]) + del(rack1_inventory_rear[10.5]) + for u in rack1_inventory_rear.values(): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( - name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', - ) - self.assertTrue(pdu) + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ + site = Site.objects.first() + rack = Rack.objects.first() + + Device( + name='Device 1', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack + ).save() + + def test_mount_half_u_devices(self): + """ + Check that two 0.5U devices can be mounted in the same rack unit. + """ + rack = Rack.objects.first() + attrs = { + 'device_type': DeviceType.objects.get(u_height=0.5), + 'device_role': DeviceRole.objects.first(), + 'site': Site.objects.first(), + 'rack': rack, + 'face': DeviceFaceChoices.FACE_FRONT, + } + + Device(name='Device 1', position=1, **attrs).save() + Device(name='Device 2', position=1.5, **attrs).save() + + self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3) def test_change_rack_site(self): """ @@ -224,19 +219,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - 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_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e17f94682..748bf24c8 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -175,9 +175,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site, tenant=tenant), - Location(name='Location 2', slug='location-2', site=site, tenant=tenant), - Location(name='Location 3', slug='location-3', site=site, tenant=tenant), + Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), ) for location in locations: location.save() @@ -188,16 +188,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'status': LocationStatusChoices.STATUS_PLANNED, 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,tenant,name,slug,description", - "Site 1,Tenant 1,Location 4,location-4,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,status,description", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) cls.bulk_edit_data = { @@ -2204,6 +2205,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -2225,6 +2228,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], @@ -2244,6 +2249,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT, 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, 'untagged_vlan': vlans[0].pk, @@ -2252,10 +2259,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - f"device,name,type,vrf.pk", - f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", + f"device,name,type,vrf.pk,poe_mode,poe_type", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 57e8b1c79..35a1056b2 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView): queryset = RackRole.objects.all() def get_extra_context(self, request, instance): - racks = Rack.objects.restrict(request.user, 'view').filter( - role=instance + racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate( + device_count=count_related(Device, 'rack') ) racks_table = tables.RackTable(racks, user=request.user, exclude=( diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index e05d4083c..2060e3e86 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer, - NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -84,13 +84,14 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description', - 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'choices', 'created', 'last_updated', + 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', + 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): @@ -271,6 +272,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=NestedLocationSerializer, + required=False, + many=True + ) device_types = SerializedPKRelatedField( queryset=DeviceType.objects.all(), serializer=NestedDeviceTypeSerializer, @@ -330,8 +337,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', 'data', 'created', 'last_updated', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 688f3c7ab..82c68c86d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet): class ConfigContextViewSet(NetBoxModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index f14368d3d..123fd2cd4 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) +class CustomFieldVisibilityChoices(ChoiceSet): + + VISIBILITY_READ_WRITE = 'read-write' + VISIBILITY_READ_ONLY = 'read-only' + VISIBILITY_HIDDEN = 'hidden' + + CHOICES = ( + (VISIBILITY_READ_WRITE, 'Read/Write'), + (VISIBILITY_READ_ONLY, 'Read-only'), + (VISIBILITY_HIDDEN, 'Hidden'), + ) + + # # CustomLinks # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 25477fbda..cca197c73 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter @@ -62,7 +62,10 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description'] + fields = [ + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', + 'description', + ] def search(self, queryset, name, value): if not value.strip(): @@ -70,6 +73,7 @@ class CustomFieldFilterSet(BaseFilterSet): return queryset.filter( Q(name__icontains=value) | Q(label__icontains=value) | + Q(group_name__icontains=value) | Q(description__icontains=value) ) @@ -251,6 +255,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label='Site (slug)', ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='locations', + queryset=Location.objects.all(), + label='Location', + ) + location = django_filters.ModelMultipleChoiceFilter( + field_name='locations__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label='Location (slug)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index e16f8aeac..b1d8a6c21 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput ) + group_name = forms.CharField( + required=False + ) description = forms.CharField( required=False ) @@ -34,8 +37,15 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + ui_visibility = forms.ChoiceField( + label="UI visibility", + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + initial='', + widget=StaticSelect() + ) - nullable_fields = ('description',) + nullable_fields = ('group_name', 'description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index fa6d8af55..d9148a5c3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -27,6 +27,12 @@ class CustomFieldCSVForm(CSVModelForm): choices=CustomFieldTypeChoices, help_text='Field data type (e.g. text, integer, etc.)' ) + object_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('custom_fields'), + required=False, + help_text="Object type (for object or multi-object fields)" + ) choices = SimpleArrayField( base_field=forms.CharField(), required=False, @@ -36,8 +42,9 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default', - 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', + 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bb8028eec..4cf8b5e0a 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from extras.models import * +from extras.choices import CustomFieldVisibilityChoices __all__ = ( 'CustomFieldsMixin', @@ -42,8 +43,18 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + self.fields[field_name].disabled = True + if self.fields[field_name].help_text: + self.fields[field_name].help_text += '
' + self.fields[field_name].help_text += ' ' \ + 'Field is set to read-only.' + # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 5d66c8be8..56f48f96b 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('type', 'content_types', 'weight', 'required')), + ('Attributes', ('content_types', 'type', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_types = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -44,6 +44,9 @@ class CustomFieldFilterForm(FilterForm): required=False, label=_('Field type') ) + group_name = forms.CharField( + required=False + ) weight = forms.IntegerField( required=False ) @@ -53,6 +56,12 @@ class CustomFieldFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + ui_visibility = forms.ChoiceField( + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + label=_('UI visibility'), + widget=StaticSelect() + ) class CustomLinkFilterForm(FilterForm): @@ -161,7 +170,7 @@ class TagFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm): fieldsets = ( (None, ('q', 'tag_id')), - ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Tenant', ('tenant_group_id', 'tenant_id')) @@ -181,6 +190,11 @@ class ConfigContextFilterForm(FilterForm): required=False, label=_('Sites') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Locations') + ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 112911f42..1ef723e93 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -40,8 +40,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ) fieldsets = ( - ('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), - ('Behavior', ('filter_logic',)), + ('Custom Field', ( + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + )), + ('Behavior', ('filter_logic', 'ui_visibility')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -56,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), + 'ui_visibility': StaticSelect(), } @@ -163,6 +166,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + locations = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False @@ -199,15 +206,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Tag.objects.all(), required=False ) - data = JSONField( - label='' + data = JSONField() + + fieldsets = ( + ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), + ('Assignment', ( + 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + )), ) class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', + 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', ) diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 12188619f..2296ce1ff 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices from extras.context_managers import change_logging from extras.models import JobResult from extras.scripts import get_script +from extras.signals import clear_webhooks from utilities.exceptions import AbortTransaction from utilities.utils import NetBoxFakeRequest @@ -49,7 +50,7 @@ class Command(BaseCommand): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -58,7 +59,7 @@ class Command(BaseCommand): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/extras/migrations/0074_customfield_group_name.py b/netbox/extras/migrations/0074_customfield_group_name.py new file mode 100644 index 000000000..e1be76b1f --- /dev/null +++ b/netbox/extras/migrations/0074_customfield_group_name.py @@ -0,0 +1,22 @@ +# Generated by Django 4.0.4 on 2022-04-15 17:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0073_journalentry_tags_custom_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customfield', + options={'ordering': ['group_name', 'weight', 'name']}, + ), + migrations.AddField( + model_name='customfield', + name='group_name', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/extras/migrations/0075_customfield_ui_visibility.py b/netbox/extras/migrations/0075_customfield_ui_visibility.py new file mode 100644 index 000000000..29ee65516 --- /dev/null +++ b/netbox/extras/migrations/0075_customfield_ui_visibility.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-23 20:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0074_customfield_group_name'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='ui_visibility', + field=models.CharField(default='read-write', max_length=50), + ), + ] diff --git a/netbox/extras/migrations/0076_configcontext_locations.py b/netbox/extras/migrations/0076_configcontext_locations.py new file mode 100644 index 000000000..f9b3a664b --- /dev/null +++ b/netbox/extras/migrations/0076_configcontext_locations.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.5 on 2022-06-22 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0156_location_status'), + ('extras', '0075_customfield_ui_visibility'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='locations', + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 0dc5d57db..30fb07069 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): related_name='+', blank=True ) + locations = models.ManyToManyField( + to='dcim.Location', + related_name='+', + blank=True + ) device_types = models.ManyToManyField( to='dcim.DeviceType', related_name='+', @@ -138,11 +141,10 @@ class ConfigContextModel(models.Model): def get_config_context(self): """ + Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs. Return the rendered configuration context for a device or VM. """ - - # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs - data = OrderedDict() + data = {} if not hasattr(self, 'config_context_data'): # The annotation is not available, so we fall back to manually querying for the config context objects diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 49afe1bba..c91f96c15 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text='Name of the field as displayed to users (if not provided, ' 'the field\'s name will be used)' ) + group_name = models.CharField( + max_length=50, + blank=True, + help_text="Custom fields within the same group will be displayed together" + ) description = models.CharField( max_length=200, blank=True @@ -131,10 +136,17 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + ui_visibility = models.CharField( + max_length=50, + choices=CustomFieldVisibilityChoices, + default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + verbose_name='UI visibility', + help_text='Specifies the visibility of custom field in the UI' + ) objects = CustomFieldManager() class Meta: - ordering = ['weight', 'name'] + ordering = ['group_name', 'weight', 'name'] def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 21727d3d4..2b97af0fb 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Device type assignment is relevant only for Devices + # Device type and location assignment is relevant only for Devices device_type = getattr(obj, 'device_type', None) + location = getattr(obj, 'location', None) # Get assigned cluster, group, and type (if any) cluster = getattr(obj, 'cluster', None) @@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(regions__in=regions) | Q(regions=None), Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(locations=location) | Q(locations=None), Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), @@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND) base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND) diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 4332d72f7..cee264878 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -17,6 +17,7 @@ from django.utils.functional import classproperty from extras.api.serializers import ScriptOutputSerializer from extras.choices import JobResultStatusChoices, LogLevelChoices +from extras.signals import clear_webhooks from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction @@ -306,9 +307,16 @@ class BaseScript: @classmethod def _get_vars(cls): vars = {} - for name, attr in cls.__dict__.items(): - if name not in vars and issubclass(attr.__class__, ScriptVariable): - vars[name] = attr + + # Iterate all base classes looking for ScriptVariables + for base_class in inspect.getmro(cls): + # When object is reached there's no reason to continue + if base_class is object: + break + + for name, attr in base_class.__dict__.items(): + if name not in vars and issubclass(attr.__class__, ScriptVariable): + vars[name] = attr # Order variables according to field_order field_order = getattr(cls.Meta, 'field_order', None) @@ -458,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs): except AbortTransaction: script.log_info("Database changes have been reverted automatically.") - + clear_webhooks.send(request) except Exception as e: stacktrace = traceback.format_exc() script.log_failure( @@ -467,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs): script.log_info("Database changes have been reverted due to error.") logger.error(f"Exception raised during script execution: {e}") job_result.set_status(JobResultStatusChoices.STATUS_ERRORED) - + clear_webhooks.send(request) finally: job_result.data = ScriptOutputSerializer(script).data job_result.save() diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a13054d56..2fa13f98a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -28,14 +28,15 @@ class CustomFieldTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', + 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', + 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') + default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') # @@ -166,8 +167,9 @@ class ConfigContextTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms', - 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated', + 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index bdb8de9db..a88ed9418 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -6,7 +6,7 @@ from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import Provider -from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Manufacturer, Platform, Rack, Region, Site, SiteGroup from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filtersets import * from extras.models import * @@ -368,9 +368,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): regions = ( - Region(name='Test Region 1', slug='test-region-1'), - Region(name='Test Region 2', slug='test-region-2'), - Region(name='Test Region 3', slug='test-region-3'), + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), ) for r in regions: r.save() @@ -384,12 +384,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), @@ -460,6 +468,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.locations.set([locations[i]]) c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -501,6 +510,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device_type(self): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 17138d42b..4929690e7 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -29,7 +29,8 @@ class ConfigContextTest(TestCase): self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) + self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) + self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -40,7 +41,8 @@ class ConfigContextTest(TestCase): name='Device 1', device_type=self.devicetype, device_role=self.devicerole, - site=self.site + site=self.site, + location=self.location ) def test_higher_weight_wins(self): @@ -144,15 +146,6 @@ class ConfigContextTest(TestCase): self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): - - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={ - "site": 1 - } - ) - site_context.sites.add(self.site) region_context = ConfigContext.objects.create( name="region", weight=100, @@ -169,6 +162,22 @@ class ConfigContextTest(TestCase): } ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={ + "site": 1 + } + ) + site_context.sites.add(self.site) + location_context = ConfigContext.objects.create( + name="location", + weight=100, + data={ + "location": 1 + } + ) + location_context.locations.add(self.location) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -205,6 +214,7 @@ class ConfigContextTest(TestCase): device = Device.objects.create( name="Device 2", site=self.site, + location=self.location, tenant=self.tenant, platform=self.platform, device_role=self.devicerole, @@ -220,13 +230,6 @@ class ConfigContextTest(TestCase): cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={"site": 1} - ) - site_context.sites.add(self.site) - region_context = ConfigContext.objects.create( name="region", weight=100, @@ -241,6 +244,13 @@ class ConfigContextTest(TestCase): ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={"site": 1} + ) + site_context.sites.add(self.site) + platform_context = ConfigContext.objects.create( name="platform", weight=100, diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ea3a952d6..936213cbf 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -36,13 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, + 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, } cls.csv_data = ( - 'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,', + 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9825d10de..bb99536c3 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView): ('Regions', instance.regions.all), ('Site Groups', instance.site_groups.all), ('Sites', instance.sites.all), + ('Locations', instance.locations.all), ('Device Types', instance.device_types.all), ('Roles', instance.roles.all), ('Platforms', instance.platforms.all), @@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView): class ConfigContextEditView(generic.ObjectEditView): queryset = ConfigContext.objects.all() form = forms.ConfigContextForm - template_name = 'extras/configcontext_edit.html' class ConfigContextBulkEditView(generic.BulkEditView): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 3fa1bcc7e..ea5c37f91 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -360,7 +360,7 @@ class IPAddressSerializer(NetBoxModelSerializer): ) assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(required=False, read_only=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = IPAddress @@ -369,7 +369,6 @@ class IPAddressSerializer(NetBoxModelSerializer): 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family', 'nat_outside'] @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, obj): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 7839dc03e..d9cf6eefc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -145,9 +145,11 @@ class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -334,9 +336,11 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): if not value.strip(): return queryset qs_filter = Q(description__icontains=value) + qs_filter |= Q(prefix__contains=value.strip()) try: prefix = str(netaddr.IPNetwork(value.strip()).cidr) qs_filter |= Q(prefix__net_contains_or_equals=prefix) + qs_filter |= Q(prefix__contains=value.strip()) except (AddrFormatError, ValueError): pass return queryset.filter(qs_filter) @@ -460,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): field_name='address', lookup_expr='family' ) - parent = django_filters.CharFilter( + parent = MultiValueCharFilter( method='search_by_parent', label='Parent prefix', ) @@ -567,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): return queryset.filter(qs_filter) def search_by_parent(self, queryset, name, value): - value = value.strip() if not value: return queryset - try: - query = str(netaddr.IPNetwork(value.strip()).cidr) - return queryset.filter(address__net_host_contained=query) - except (AddrFormatError, ValueError): - return queryset.none() + q = Q() + for prefix in value: + try: + query = str(netaddr.IPNetwork(prefix.strip()).cidr) + q |= Q(address__net_host_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + return queryset.filter(q) def filter_address(self, queryset, name, value): try: diff --git a/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py new file mode 100644 index 000000000..63e93d137 --- /dev/null +++ b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a3b8fb2c1..db662f49c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -813,7 +813,7 @@ class IPAddress(NetBoxModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - nat_inside = models.OneToOneField( + nat_inside = models.ForeignKey( to='self', on_delete=models.SET_NULL, related_name='nat_outside', diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 475ad787e..558631585 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -226,8 +226,9 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(NetBoxTable): - prefix = tables.TemplateColumn( + prefix = columns.TemplateColumn( template_code=PREFIX_LINK, + export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) prefix_flat = tables.TemplateColumn( diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index 8c81a28c2..58d0a9aff 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable): linkify=True ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:servicetemplate_list' @@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable): order_by=('device', 'virtual_machine') ) ports = tables.Column( - accessor=tables.A('port_list') + accessor=tables.A('port_list'), + order_by=tables.A('ports'), ) tags = columns.TagColumn( url_name='ipam:service_list' diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 198f9d62d..d98fe889e 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_parent(self): - params = {'parent': '10.0.0.0/24'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - params = {'parent': '2001:db8::/64'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'parent': ['10.0.0.0/30', '2001:db8::/126']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_filter_address(self): # Check IPv4 and IPv6, with and without a mask diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 79804aabd..6682fc920 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -4,15 +4,15 @@ from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from circuits.models import Provider +from circuits.models import Provider, Circuit from circuits.tables import ProviderTable from dcim.filtersets import InterfaceFilterSet -from dcim.models import Interface, Site +from dcim.models import Interface, Site, Device from dcim.tables import SiteTable from netbox.views import generic from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet -from virtualization.models import VMInterface +from virtualization.models import VMInterface, VirtualMachine from . import filtersets, forms, tables from .constants import * from .models import * @@ -225,7 +225,9 @@ class ASNView(generic.ObjectView): sites_table.configure(request) # Gather assigned Providers - providers = instance.providers.restrict(request.user, 'view') + providers = instance.providers.restrict(request.user, 'view').annotate( + count_circuits=count_related(Circuit, 'provider') + ) providers_table = ProviderTable(providers, user=request.user) providers_table.configure(request) @@ -585,7 +587,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', + 'vrf', 'tenant', ) def get_extra_context(self, request, instance): @@ -674,11 +676,26 @@ class IPAddressView(generic.ObjectView): related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table.configure(request) + # Find services belonging to the IP + service_filter = Q(ipaddresses=instance) + + # Find services listening on all IPs on the assigned device/vm + if instance.assigned_object and instance.assigned_object.parent_object: + parent_object = instance.assigned_object.parent_object + + if isinstance(parent_object, VirtualMachine): + service_filter |= (Q(virtual_machine=parent_object) & Q(ipaddresses=None)) + elif isinstance(parent_object, Device): + service_filter |= (Q(device=parent_object) & Q(ipaddresses=None)) + + services = Service.objects.restrict(request.user, 'view').filter(service_filter) + return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, 'more_duplicate_ips': duplicate_ips.count() > 10, 'related_ips_table': related_ips_table, + 'services': services, } diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 1eaa7d1c4..231ab55e6 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField +from .fields import * from .routers import NetBoxRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -7,6 +7,7 @@ __all__ = ( 'BulkOperationSerializer', 'ChoiceField', 'ContentTypeField', + 'IPNetworkSerializer', 'NetBoxRouter', 'SerializedPKRelatedField', 'ValidatedModelSerializer', diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 847bcbfd9..1b7def3a3 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -7,14 +7,36 @@ from rest_framework.permissions import BasePermission, DjangoObjectPermissions, from netbox.config import get_config from users.models import Token +from utilities.request import get_client_ip class TokenAuthentication(authentication.TokenAuthentication): """ - A custom authentication scheme which enforces Token expiration times. + A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token + def authenticate(self, request): + result = super().authenticate(request) + + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token + if token.allowed_ips: + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) + + return result + def authenticate_credentials(self, key): model = self.get_model() try: diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index d73cbcac2..1f3c40dc2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,12 +1,18 @@ from collections import OrderedDict -import pytz -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField +__all__ = ( + 'ChoiceField', + 'ContentTypeField', + 'IPNetworkSerializer', + 'SerializedPKRelatedField', +) + class ChoiceField(serializers.Field): """ @@ -104,6 +110,17 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" +class IPNetworkSerializer(serializers.Serializer): + """ + Representation of an IP network value (e.g. 192.0.2.0/24). + """ + def to_representation(self, instance): + return str(instance) + + def to_internal_value(self, value): + return IPNetwork(value) + + class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d89e32124..5ecade264 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): if isinstance(queryset, QuerySet): - self.count = queryset.count() + self.count = self.get_queryset_count(queryset) else: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) @@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return self.default_limit + def get_queryset_count(self, queryset): + return queryset.count() + def get_next_link(self): # Pagination has been disabled @@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return None return super().get_previous_link() + + +class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination): + """ + Strips the annotations on the queryset before getting the count + to optimize pagination of complex queries. + """ + def get_queryset_count(self, queryset): + # Clone the queryset to avoid messing up the actual query + cloned_queryset = queryset.all() + cloned_queryset.query.annotations.clear() + + return cloned_queryset.count() diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index c82749e3f..ad0dcc7c3 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -202,6 +202,9 @@ RQ_DEFAULT_TIMEOUT = 300 # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' +# The name to use for the csrf token cookie. +CSRF_COOKIE_NAME = 'csrftoken' + # The name to use for the session cookie. SESSION_COOKIE_NAME = 'sessionid' diff --git a/netbox/netbox/configuration_testing.py b/netbox/netbox/configuration_testing.py index 59529b80c..621671f04 100644 --- a/netbox/netbox/configuration_testing.py +++ b/netbox/netbox/configuration_testing.py @@ -36,3 +36,8 @@ REDIS = { } SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': True +} diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index e054dc9da..8ca0d98c1 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,32 +1,24 @@ from collections import OrderedDict from typing import Dict -from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNetworkFilterSet +import circuits.filtersets +import circuits.tables +import dcim.filtersets +import dcim.tables +import ipam.filtersets +import ipam.tables +import tenancy.filtersets +import tenancy.tables +import virtualization.filtersets +import virtualization.tables from circuits.models import Circuit, ProviderNetwork, Provider -from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable -from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, LocationFilterSet, ModuleFilterSet, ModuleTypeFilterSet, - PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, SiteFilterSet, VirtualChassisFilterSet, -) from dcim.models import ( Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis, ) -from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, LocationTable, ModuleTable, ModuleTypeTable, PowerFeedTable, RackTable, - RackReservationTable, SiteTable, VirtualChassisTable, -) -from ipam.filtersets import ( - AggregateFilterSet, ASNFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet, -) -from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF -from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable -from tenancy.filtersets import ContactFilterSet, TenantFilterSet +from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF from tenancy.models import Contact, Tenant, ContactAssignment -from tenancy.tables import ContactTable, TenantTable from utilities.utils import count_related -from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine -from virtualization.tables import ClusterTable, VirtualMachineTable SEARCH_MAX_RESULTS = 15 @@ -36,22 +28,22 @@ CIRCUIT_TYPES = OrderedDict( 'queryset': Provider.objects.annotate( count_circuits=count_related(Circuit, 'provider') ), - 'filterset': ProviderFilterSet, - 'table': ProviderTable, + 'filterset': circuits.filtersets.ProviderFilterSet, + 'table': circuits.tables.ProviderTable, 'url': 'circuits:provider_list', }), ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' ), - 'filterset': CircuitFilterSet, - 'table': CircuitTable, + 'filterset': circuits.filtersets.CircuitFilterSet, + 'table': circuits.tables.CircuitTable, 'url': 'circuits:circuit_list', }), ('providernetwork', { 'queryset': ProviderNetwork.objects.prefetch_related('provider'), - 'filterset': ProviderNetworkFilterSet, - 'table': ProviderNetworkTable, + 'filterset': circuits.filtersets.ProviderNetworkFilterSet, + 'table': circuits.tables.ProviderNetworkTable, 'url': 'circuits:providernetwork_list', }), ) @@ -62,22 +54,22 @@ DCIM_TYPES = OrderedDict( ( ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), - 'filterset': SiteFilterSet, - 'table': SiteTable, + 'filterset': dcim.filtersets.SiteFilterSet, + 'table': dcim.tables.SiteTable, 'url': 'dcim:site_list', }), ('rack', { 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate( device_count=count_related(Device, 'rack') ), - 'filterset': RackFilterSet, - 'table': RackTable, + 'filterset': dcim.filtersets.RackFilterSet, + 'table': dcim.tables.RackTable, 'url': 'dcim:rack_list', }), ('rackreservation', { 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), - 'filterset': RackReservationFilterSet, - 'table': RackReservationTable, + 'filterset': dcim.filtersets.RackReservationFilterSet, + 'table': dcim.tables.RackReservationTable, 'url': 'dcim:rackreservation_list', }), ('location', { @@ -94,60 +86,60 @@ DCIM_TYPES = OrderedDict( 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': LocationFilterSet, - 'table': LocationTable, + 'filterset': dcim.filtersets.LocationFilterSet, + 'table': dcim.tables.LocationTable, 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Device, 'device_type') ), - 'filterset': DeviceTypeFilterSet, - 'table': DeviceTypeTable, + 'filterset': dcim.filtersets.DeviceTypeFilterSet, + 'table': dcim.tables.DeviceTypeTable, 'url': 'dcim:devicetype_list', }), ('device', { 'queryset': Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6', ), - 'filterset': DeviceFilterSet, - 'table': DeviceTable, + 'filterset': dcim.filtersets.DeviceFilterSet, + 'table': dcim.tables.DeviceTable, 'url': 'dcim:device_list', }), ('moduletype', { 'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate( instance_count=count_related(Module, 'module_type') ), - 'filterset': ModuleTypeFilterSet, - 'table': ModuleTypeTable, + 'filterset': dcim.filtersets.ModuleTypeFilterSet, + 'table': dcim.tables.ModuleTypeTable, 'url': 'dcim:moduletype_list', }), ('module', { 'queryset': Module.objects.prefetch_related( 'module_type__manufacturer', 'device', 'module_bay', ), - 'filterset': ModuleFilterSet, - 'table': ModuleTable, + 'filterset': dcim.filtersets.ModuleFilterSet, + 'table': dcim.tables.ModuleTable, 'url': 'dcim:module_list', }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( member_count=count_related(Device, 'virtual_chassis') ), - 'filterset': VirtualChassisFilterSet, - 'table': VirtualChassisTable, + 'filterset': dcim.filtersets.VirtualChassisFilterSet, + 'table': dcim.tables.VirtualChassisTable, 'url': 'dcim:virtualchassis_list', }), ('cable', { 'queryset': Cable.objects.all(), - 'filterset': CableFilterSet, - 'table': CableTable, + 'filterset': dcim.filtersets.CableFilterSet, + 'table': dcim.tables.CableTable, 'url': 'dcim:cable_list', }), ('powerfeed', { 'queryset': PowerFeed.objects.all(), - 'filterset': PowerFeedFilterSet, - 'table': PowerFeedTable, + 'filterset': dcim.filtersets.PowerFeedFilterSet, + 'table': dcim.tables.PowerFeedTable, 'url': 'dcim:powerfeed_list', }), ) @@ -157,40 +149,46 @@ IPAM_TYPES = OrderedDict( ( ('vrf', { 'queryset': VRF.objects.prefetch_related('tenant'), - 'filterset': VRFFilterSet, - 'table': VRFTable, + 'filterset': ipam.filtersets.VRFFilterSet, + 'table': ipam.tables.VRFTable, 'url': 'ipam:vrf_list', }), ('aggregate', { 'queryset': Aggregate.objects.prefetch_related('rir'), - 'filterset': AggregateFilterSet, - 'table': AggregateTable, + 'filterset': ipam.filtersets.AggregateFilterSet, + 'table': ipam.tables.AggregateTable, 'url': 'ipam:aggregate_list', }), ('prefix', { 'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'), - 'filterset': PrefixFilterSet, - 'table': PrefixTable, + 'filterset': ipam.filtersets.PrefixFilterSet, + 'table': ipam.tables.PrefixTable, 'url': 'ipam:prefix_list', }), ('ipaddress', { 'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'), - 'filterset': IPAddressFilterSet, - 'table': IPAddressTable, + 'filterset': ipam.filtersets.IPAddressFilterSet, + 'table': ipam.tables.IPAddressTable, 'url': 'ipam:ipaddress_list', }), ('vlan', { 'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'), - 'filterset': VLANFilterSet, - 'table': VLANTable, + 'filterset': ipam.filtersets.VLANFilterSet, + 'table': ipam.tables.VLANTable, 'url': 'ipam:vlan_list', }), ('asn', { 'queryset': ASN.objects.prefetch_related('rir', 'tenant'), - 'filterset': ASNFilterSet, - 'table': ASNTable, + 'filterset': ipam.filtersets.ASNFilterSet, + 'table': ipam.tables.ASNTable, 'url': 'ipam:asn_list', }), + ('service', { + 'queryset': Service.objects.prefetch_related('device', 'virtual_machine'), + 'filterset': ipam.filtersets.ServiceFilterSet, + 'table': ipam.tables.ServiceTable, + 'url': 'ipam:service_list', + }), ) ) @@ -198,15 +196,15 @@ TENANCY_TYPES = OrderedDict( ( ('tenant', { 'queryset': Tenant.objects.prefetch_related('group'), - 'filterset': TenantFilterSet, - 'table': TenantTable, + 'filterset': tenancy.filtersets.TenantFilterSet, + 'table': tenancy.tables.TenantTable, 'url': 'tenancy:tenant_list', }), ('contact', { 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate( assignment_count=count_related(ContactAssignment, 'contact')), - 'filterset': ContactFilterSet, - 'table': ContactTable, + 'filterset': tenancy.filtersets.ContactFilterSet, + 'table': tenancy.tables.ContactTable, 'url': 'tenancy:contact_list', }), ) @@ -219,16 +217,16 @@ VIRTUALIZATION_TYPES = OrderedDict( device_count=count_related(Device, 'cluster'), vm_count=count_related(VirtualMachine, 'cluster') ), - 'filterset': ClusterFilterSet, - 'table': ClusterTable, + 'filterset': virtualization.filtersets.ClusterFilterSet, + 'table': virtualization.tables.ClusterTable, 'url': 'virtualization:cluster_list', }), ('virtualmachine', { 'queryset': VirtualMachine.objects.prefetch_related( 'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', ), - 'filterset': VirtualMachineFilterSet, - 'table': VirtualMachineTable, + 'filterset': virtualization.filtersets.VirtualMachineFilterSet, + 'table': virtualization.tables.VirtualMachineTable, 'url': 'virtualization:virtualmachine_list', }), ) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e443dde5f..817da526b 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -7,7 +9,7 @@ from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager -from extras.choices import ObjectChangeActionChoices +from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import register_features from netbox.signals import post_clean from utilities.utils import serialize_object @@ -98,7 +100,7 @@ class CustomFieldsMixin(models.Model): """ return self.custom_field_data - def get_custom_fields(self): + def get_custom_fields(self, omit_hidden=False): """ Return a dictionary of custom fields for a single object in the form `{field: value}`. @@ -112,11 +114,25 @@ class CustomFieldsMixin(models.Model): data = {} 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) data[field] = field.deserialize(value) return data + def get_custom_fields_by_group(self): + """ + Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. + """ + grouped_custom_fields = defaultdict(dict) + for cf, value in self.get_custom_fields(omit_hidden=True).items(): + grouped_custom_fields[cf.group_name][cf] = value + + return dict(grouped_custom_fields) + def clean(self): super().clean() from extras.models import CustomField diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 999e39479..446198c61 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.3' +VERSION = '3.3.0-dev' # Hostname HOSTNAME = platform.node() @@ -84,6 +84,7 @@ if BASE_PATH: CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) +CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken') CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') @@ -95,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) +JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {}) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) @@ -422,6 +424,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( @@ -511,6 +515,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', ), 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 801b97766..7da241566 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -90,6 +90,15 @@ class TemplateColumn(tables.TemplateColumn): """ PLACEHOLDER = mark_safe('—') + def __init__(self, export_raw=False, **kwargs): + """ + Args: + export_raw: If true, data export returns the raw field value rather than the rendered template. (Default: + False) + """ + super().__init__(**kwargs) + self.export_raw = export_raw + def render(self, *args, **kwargs): ret = super().render(*args, **kwargs) if not ret.strip(): @@ -97,6 +106,10 @@ class TemplateColumn(tables.TemplateColumn): return ret def value(self, **kwargs): + if self.export_raw: + # Skip template rendering and export raw value + return kwargs.get('value') + ret = super().value(**kwargs) if ret == self.PLACEHOLDER: return '' @@ -153,6 +166,7 @@ class ActionsItem: title: str icon: str permission: Optional[str] = None + css_class: Optional[str] = 'secondary' class ActionsColumn(tables.Column): @@ -162,19 +176,22 @@ class ActionsColumn(tables.Column): :param actions: The ordered list of dropdown menu items to include :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + :param split_actions: When True, converts the actions dropdown menu into a split button with first action as the + direct button link and icon (default: True) """ attrs = {'td': {'class': 'text-end text-nowrap noprint'}} empty_values = () actions = { - 'edit': ActionsItem('Edit', 'pencil', 'change'), - 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'), 'changelog': ActionsItem('Changelog', 'history'), } - def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs): super().__init__(*args, **kwargs) self.extra_buttons = extra_buttons + self.split_actions = split_actions # Determine which actions to enable self.actions = { @@ -192,32 +209,62 @@ class ActionsColumn(tables.Column): model = table.Meta.model request = getattr(table, 'context', {}).get('request') url_appendix = f'?return_url={request.path}' if request else '' + html = '' - links = [] + # Compile actions menu + button = None + dropdown_class = 'secondary' + dropdown_links = [] user = getattr(request, 'user', AnonymousUser()) - for action, attrs in self.actions.items(): + for idx, (action, attrs) in enumerate(self.actions.items()): permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' if attrs.permission is None or user.has_perm(permission): url = reverse(get_viewname(model, action), kwargs={'pk': record.pk}) - links.append(f'
  • ' - f' {attrs.title}
  • ') - if not links: - return '' + # Render a separate button if a) only one action exists, or b) if split_actions is True + if len(self.actions) == 1 or (self.split_actions and idx == 0): + dropdown_class = attrs.css_class + button = ( + f'' + f'' + ) - menu = f'' \ - f'' \ - f'' \ - f'' + # Add dropdown menu items + else: + dropdown_links.append( + f'
  • ' + f' {attrs.title}
  • ' + ) + + # Create the actions dropdown menu + if button and dropdown_links: + html += ( + f'' + f' {button}' + f' ' + f' Toggle Dropdown' + f' ' + f'' + ) + elif button: + html += button + elif dropdown_links: + html += ( + f'' + f' ' + f' Toggle Dropdown' + f' ' + f'' + ) # Render any extra buttons from template code if self.extra_buttons: template = Template(self.extra_buttons) context = getattr(table, "context", Context()) context.update({'record': record}) - menu = template.render(context) + menu + html = template.render(context) + html - return mark_safe(menu) + return mark_safe(html) class ChoiceFieldColumn(tables.Column): diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..38399b5fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink +from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -97,7 +98,7 @@ class BaseTable(tables.Table): break if prefetch_path: prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + self.data.data = self.data.data.prefetch_related(*prefetch_fields) def _get_columns(self, visible=True): columns = [] @@ -178,7 +179,10 @@ class NetBoxTable(BaseTable): # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) + custom_fields = CustomField.objects.filter( + content_types=content_type + ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..6597684fb 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,3 +1,5 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType @@ -8,10 +10,73 @@ from netaddr import IPNetwork from rest_framework.test import APIClient from dcim.models import Site -from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token from utilities.testing import TestCase +from utilities.testing.api import APITestCase + + +class TokenAuthenticationTestCase(APITestCase): + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_authentication(self): + url = reverse('dcim-api:site-list') + + # Request without a token should return a 403 + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Valid token should return a 200 + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_expiration(self): + url = reverse('dcim-api:site-list') + + # Request without a non-expired token should succeed + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Request with an expired token should fail + token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_write_enabled(self): + url = reverse('dcim-api:site-list') + data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + # Request with a write-disabled token should fail + token = Token.objects.create(user=self.user, write_enabled=False) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + # Request with a write-enabled token should succeed + token.write_enabled = True + token.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_allowed_ips(self): + url = reverse('dcim-api:site-list') + + # Request from a non-allowed client IP should fail + token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + self.assertEqual(response.status_code, 403) + + # Request with an expired token should fail + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + self.assertEqual(response.status_code, 200) class ExternalAuthenticationTestCase(TestCase): diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index acd1abbf2..bc0cabef0 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index ebf3e0a39..26bb1c514 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489..bfeed4150 100644 Binary files a/netbox/project-static/dist/rack_elevation.css and b/netbox/project-static/dist/rack_elevation.css differ diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6a9001cd1..e677ff599 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initSelectMultiple } from './selectMultiple'; export function initButtons(): void { for (const func of [ @@ -10,6 +11,7 @@ export function initButtons(): void { initConnectionToggle, initReslug, initSelectAll, + initSelectMultiple, initMoveButtons, ]) { func(); diff --git a/netbox/project-static/src/buttons/selectMultiple.ts b/netbox/project-static/src/buttons/selectMultiple.ts new file mode 100644 index 000000000..d8bad3105 --- /dev/null +++ b/netbox/project-static/src/buttons/selectMultiple.ts @@ -0,0 +1,105 @@ +import { getElements } from '../util'; +import { StateManager } from 'src/state'; +import { previousPkCheckState } from '../stores'; + +type PreviousPkCheckState = { element: Nullable }; + +/** + * If there is a text selection, removes it. + */ +function removeTextSelection(): void { + window.getSelection()?.removeAllRanges(); +} + +/** + * Sets the state object passed in to the eventTargetElement object passed in. + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ +function updatePreviousPkCheckState( + eventTargetElement: HTMLInputElement, + state: StateManager, +): void { + state.set('element', eventTargetElement); +} + +/** + * For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle + * "checked" value to eventTargetElement.checked + * + * @param eventTargetElement HTML Input Element, retrieved from getting the target of the + * event passed in from handlePkCheck() + * @param state PreviousPkCheckState object. + */ +function toggleCheckboxRange( + eventTargetElement: HTMLInputElement, + previousStateElement: HTMLInputElement, + elementList: Generator, +): void { + let changePkCheckboxState = false; + for (const element of elementList) { + const typedElement = element as HTMLInputElement; + //Change loop's current checkbox state to eventTargetElement checkbox state + if (changePkCheckboxState === true) { + typedElement.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was above the shift clicked checkbox + if (element === previousStateElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; + } + changePkCheckboxState = true; + typedElement.checked = eventTargetElement.checked; + } + //The previously clicked checkbox was below the shift clicked checkbox + if (element === eventTargetElement) { + if (changePkCheckboxState === true) { + changePkCheckboxState = false; + return; + } + changePkCheckboxState = true; + } + } +} + +/** + * IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the + * event target element and the state element. + * + * @param event Mouse event. + * @param state PreviousPkCheckState object. + */ +function handlePkCheck(event: MouseEvent, state: StateManager): void { + const eventTargetElement = event.target as HTMLInputElement; + const previousStateElement = state.get('element'); + updatePreviousPkCheckState(eventTargetElement, state); + //Stop if user is not holding shift key + if (!event.shiftKey) { + return; + } + removeTextSelection(); + //If no previous state, store event target element as previous state and return + if (previousStateElement === null) { + return updatePreviousPkCheckState(eventTargetElement, state); + } + const checkboxList = getElements('input[type="checkbox"][name="pk"]'); + toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList); +} + +/** + * Initialize table select all elements. + */ +export function initSelectMultiple(): void { + const checkboxElements = getElements('input[type="checkbox"][name="pk"]'); + for (const element of checkboxElements) { + element.addEventListener('click', event => { + removeTextSelection(); + //Stop propogation to avoid event firing multiple times + event.stopPropagation(); + handlePkCheck(event, previousPkCheckState); + }); + } +} diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 88b35a0e9..f5b605d58 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -205,6 +205,11 @@ export class APISelect { onChange: () => this.handleSlimChange(), }); + // Don't close on select if multiple select + if (this.base.multiple) { + this.slim.config.closeOnSelect = false; + } + // Initialize API query properties. this.getStaticParams(); this.getDynamicParams(); diff --git a/netbox/project-static/src/stores/index.ts b/netbox/project-static/src/stores/index.ts index 42d4aa0b5..d4644e619 100644 --- a/netbox/project-static/src/stores/index.ts +++ b/netbox/project-static/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './objectDepth'; export * from './rackImages'; +export * from './previousPkCheck'; diff --git a/netbox/project-static/src/stores/previousPkCheck.ts b/netbox/project-static/src/stores/previousPkCheck.ts new file mode 100644 index 000000000..19b244ec7 --- /dev/null +++ b/netbox/project-static/src/stores/previousPkCheck.ts @@ -0,0 +1,6 @@ +import { createState } from '../state'; + +export const previousPkCheckState = createState<{ element: Nullable }>( + { element: null }, + { persist: false }, +); diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bf8063110..bc02995dd 100644 --- a/netbox/project-static/styles/rack-elevation.scss +++ b/netbox/project-static/styles/rack-elevation.scss @@ -81,17 +81,6 @@ svg { opacity: 1; } - // When a reserved slot is hovered, use a more readable color for the 'Add Device' text. - &.reserved:hover[class] + .add-device { - fill: $black; - } - - // Reserved rack unit background color. - &.reserved[class], - &.reserved:hover[class] { - fill: url(#reserved); - } - // Occupied rack unit background color. &.occupied[class], &.occupied:hover[class] { @@ -108,4 +97,9 @@ svg { opacity: 0; } } + + // Reservation background color. + .reservation[class] { + fill: url(#reserved); + } } diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 881b6cca6..a4c41f871 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -45,6 +45,10 @@ Install Date {{ object.install_date|annotated_date|placeholder }} + + Termination Date + {{ object.termination_date|annotated_date|placeholder }} + Commit Rate {{ object.commit_rate|humanize_speed|placeholder }} diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html index 27eebb3d8..b2b30d635 100644 --- a/netbox/templates/circuits/circuit_terminations_swap.html +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -10,7 +10,7 @@ {% if termination_a %} {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • @@ -18,7 +18,7 @@ {% if termination_z %} {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %}
  • diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index fdb01e803..b673cd4a3 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -94,7 +94,7 @@ {% elif termination.port_speed %} {{ termination.port_speed|humanize_speed }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 1bf63f2d5..60bf8cfbc 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -50,7 +50,7 @@ {% if object.portal_url %} {{ object.portal_url }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index f1cf986e6..cd171cbb3 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -40,7 +40,7 @@ {% if object.color %}   {% else %} - + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.length %} {{ object.length|floatformat }} {{ object.get_length_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d075a801d..d3d6f03dc 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -23,7 +23,7 @@ {% endfor %} {{ object.site.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -40,7 +40,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -50,7 +50,7 @@ {% if object.rack %} {{ object.rack }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -69,7 +69,7 @@ {% elif object.rack and object.device_type.u_height %} Not racked {% else %} - + {{ ''|placeholder }} {% endif %} @@ -180,7 +180,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -195,7 +195,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html index 288101c08..610c53071 100644 --- a/netbox/templates/dcim/devicerole.html +++ b/netbox/templates/dcim/devicerole.html @@ -54,7 +54,7 @@ {% if object.vm_role %} {{ virtualmachine_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index e717a48aa..bb3ec9d2e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -55,7 +55,7 @@ {{ object.front_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -67,7 +67,7 @@ {{ object.rear_image.name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 358922730..e98750518 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,14 @@ Description {{ object.description|placeholder }} + + PoE Mode + {{ object.get_poe_mode_display|placeholder }} + + + PoE Mode + {{ object.get_poe_type_display|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} @@ -321,7 +329,7 @@ {% if object.rf_channel_frequency %} {{ object.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -329,7 +337,7 @@ {% if peer.rf_channel_frequency %} {{ peer.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} @@ -340,7 +348,7 @@ {% if object.rf_channel_width %} {{ object.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% if peer %} @@ -348,7 +356,7 @@ {% if peer.rf_channel_width %} {{ peer.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} {% endif %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b2b2bc4cd..f0335036f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -43,6 +43,10 @@ Parent {{ object.parent|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Tenant diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 130cd046f..f2dac38f2 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -18,25 +18,25 @@ diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 777af5563..ed1f9a1cd 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -44,7 +44,7 @@ {% if object.connected_endpoint %} {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 6408bc759..c312bee03 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -44,7 +44,7 @@ Power Port - {{ object.power_port }} + {{ object.power_port|linkify|placeholder }} Feed Leg diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 6574e9b74..42f6a8e99 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -53,7 +53,7 @@ {% endfor %} {{ object.location|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -115,7 +115,7 @@ {% if object.type %} {{ object.get_type_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -133,7 +133,7 @@ {% if object.outer_width %} {{ object.outer_width }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -143,7 +143,7 @@ {% if object.outer_depth %} {{ object.outer_depth }} {{ object.get_outer_unit_display }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index c15cab468..ab04ea018 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -34,7 +34,7 @@ {% endfor %} {{ object.region|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -47,7 +47,7 @@ {% endfor %} {{ object.group|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -79,7 +79,7 @@ {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})
    Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -94,7 +94,7 @@ {{ object.physical_address|linebreaksbr }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -113,7 +113,7 @@ {{ object.latitude }}, {{ object.longitude }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 4683b775b..1ff9f2e9a 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -15,74 +15,70 @@ {% block content %}
    -
    -
    - Virtual Chassis -
    -
    - - - - - - - - - -
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Virtual Chassis
    +
    + + + + + + + + + +
    Domain{{ object.domain|placeholder }}
    Master{{ object.master|linkify }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    - Members -
    -
    - - - - - - - - {% for vc_member in members %} - - - - - - - {% endfor %} -
    DevicePositionMasterPriority
    - {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.master == vc_member %} - {% checkmark True %} - {% endif %} - - {{ vc_member.vc_priority|placeholder }} -
    -
    - {% if perms.dcim.change_virtualchassis %} - - {% endif %} +
    +
    Members
    +
    + + + + + + + + {% for vc_member in members %} + + + + + + + {% endfor %} +
    DevicePositionMasterPriority
    + {{ vc_member|linkify }} + + {% badge vc_member.vc_position show_empty=True %} + + {% if object.master == vc_member %} + {% checkmark True %} + {% endif %} + + {{ vc_member.vc_priority|placeholder }} +
    - {% plugin_right_page object %} + {% if perms.dcim.change_virtualchassis %} + + {% endif %} +
    + {% plugin_right_page object %}
    -
    - {% plugin_full_width_page object %} -
    +
    + {% plugin_full_width_page object %} +
    {% endblock %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 327f20531..275391c61 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -57,7 +57,7 @@ {% if device.rack %} {{ device.rack }} / {{ device.position }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ device.serial|placeholder }} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html deleted file mode 100644 index 7b37a69c6..000000000 --- a/netbox/templates/extras/configcontext_edit.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Config Context
    -
    - {% render_field form.name %} - {% render_field form.weight %} - {% render_field form.description %} - {% render_field form.is_active %} -
    -
    -
    -
    Assignment
    -
    - {% render_field form.regions %} - {% render_field form.site_groups %} - {% render_field form.sites %} - {% render_field form.device_types %} - {% render_field form.roles %} - {% render_field form.platforms %} - {% render_field form.cluster_types %} - {% render_field form.cluster_groups %} - {% render_field form.clusters %} - {% render_field form.tenant_groups %} - {% render_field form.tenants %} - {% render_field form.tags %} -
    -
    -
    -
    Data
    -
    - {% render_field form.data %} -
    -
    -{% endblock %} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index e8c3df460..ff4e6e08c 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -19,6 +19,10 @@ Label {{ object.label|placeholder }} + + Group Name + {{ object.group_name|placeholder }} + Type @@ -38,6 +42,14 @@ Weight {{ object.weight }} + + Filter Logic + {{ object.get_filter_logic_display }} + + + UI Visibility + {{ object.get_ui_visibility_display }} +
    @@ -57,14 +69,10 @@ {% if object.choices %} {{ object.choices|join:", " }} {% else %} - + {{ ''|placeholder }} {% endif %} - - Filter Logic - {{ object.get_filter_logic_display }} - @@ -105,7 +113,7 @@ {% if object.validation_regex %} {{ object.validation_regex }} {% else %} - — + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index 9b3e9db5f..c20bf5fe2 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -57,7 +57,7 @@ {% elif obj %} {{ obj }} {% else %} - + {{ ''|placeholder }} {% endif %} {{ message|markdown }} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 43e078826..1a85c3a21 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -76,14 +76,14 @@ Context: {% if field.required %} {% checkmark True true="Required" %} {% else %} - + {{ ''|placeholder }} {% endif %} {% if field.to_field_name %} {{ field.to_field_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 32e586d3a..b18d44030 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -1,49 +1,54 @@ {% load helpers %} -{% with custom_fields=object.get_custom_fields %} - {% if custom_fields %} -
    -
    Custom Fields
    -
    - - {% for field, value in custom_fields.items %} - - - - +{% with custom_fields=object.get_custom_fields_by_group %} + {% if custom_fields %} +
    +
    Custom Fields
    +
    + {% for group_name, fields in custom_fields.items %} + {% if group_name %} +
    {{ group_name }}
    + {% endif %} +
    - {{ field }} - - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
    {{ value|json }}
    - {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
    {% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - - {% endif %} -
    + {% for field, value in fields.items %} + + +
    + {{ field }} + + {% if field.type == 'integer' and value is not None %} + {{ value }} + {% elif field.type == 'longtext' and value %} + {{ value|markdown }} + {% elif field.type == 'boolean' and value == True %} + {% checkmark value true="True" %} + {% elif field.type == 'boolean' and value == False %} + {% checkmark value false="False" %} + {% elif field.type == 'url' and value %} + {{ value|truncatechars:70 }} + {% elif field.type == 'json' and value %} +
    {{ value|json }}
    + {% elif field.type == 'multiselect' and value %} + {{ value|join:", " }} + {% elif field.type == 'object' and value %} + {{ value|linkify }} + {% elif field.type == 'multiobject' and value %} + {% for obj in value %} + {{ obj|linkify }}{% if not forloop.last %}
    {% endif %} {% endfor %} -
    -
    -
    - {% endif %} + {% elif value %} + {{ value }} + {% elif field.required %} + Not defined + {% else %} + {{ ''|placeholder }} + {% endif %} + + + {% endfor %} + + {% endfor %} + + + {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 7867e829b..8b628c2f7 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -52,7 +52,7 @@ {% if object.role %} {{ object.get_role_display }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -73,7 +73,7 @@ {% endif %} {{ object.assigned_object|linkify }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -86,13 +86,19 @@ ({{ object.nat_inside.assigned_object.parent_object|linkify }}) {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} - NAT (outside) - {{ object.nat_outside|linkify|placeholder }} + Outside NAT IPs + + {% for ip in object.nat_outside.all %} + {{ ip|linkify }}
    + {% empty %} + {{ ''|placeholder }} + {% endfor %} + @@ -128,6 +134,24 @@
    {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
    +
    +
    + Services +
    +
    + {% if services %} + + {% for service in services %} + {% include 'ipam/inc/service.html' %} + {% endfor %} +
    + {% else %} +
    + None +
    + {% endif %} +
    +
    {% plugin_right_page object %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index e2ba76a82..a47566ff7 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -39,7 +39,7 @@ {% if aggregate %} {{ aggregate.prefix }} ({{ aggregate.rir }}) {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -52,7 +52,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -65,7 +65,7 @@ {% endif %} {{ object.vlan|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -138,7 +138,7 @@ {{ first_available_ip }} {% endif %} {% else %} - None + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/role.html b/netbox/templates/ipam/role.html index 49570099d..a6ef2c6d4 100644 --- a/netbox/templates/ipam/role.html +++ b/netbox/templates/ipam/role.html @@ -45,7 +45,7 @@ {% if ipranges_count %} {{ ipranges_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} @@ -57,7 +57,7 @@ {% if vlans_count %} {{ vlans_count }} {% else %} - — + {{ ''|placeholder }} {% endif %} {% endwith %} diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html index 71ea20fa5..47ae70dc9 100644 --- a/netbox/templates/ipam/service.html +++ b/netbox/templates/ipam/service.html @@ -44,7 +44,7 @@ {% for ipaddress in object.ipaddresses.all %} {{ ipaddress|linkify }}
    {% empty %} - None + {{ ''|placeholder }} {% endfor %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index f74149ad6..fd0ba36a3 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -21,7 +21,7 @@ {% endif %} {{ object.site|linkify }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -56,7 +56,7 @@ {% if object.role %} {{ object.role }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/contact.html b/netbox/templates/tenancy/contact.html index f55e87895..8e71628e9 100644 --- a/netbox/templates/tenancy/contact.html +++ b/netbox/templates/tenancy/contact.html @@ -35,7 +35,7 @@ {% if object.phone %} {{ object.phone }} {% else %} - None + {{ ''|placeholder }} {% endif %} @@ -45,7 +45,7 @@ {% if object.email %} {{ object.email }} {% else %} - None + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index e4c1db006..52c13e1aa 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -77,6 +77,10 @@

    {{ stats.prefix_count }}

    Prefixes

    +
    +

    {{ stats.iprange_count }}

    +

    IP Ranges

    +

    {{ stats.ipaddress_count }}

    IP addresses

    diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index a019cbd1f..24b32cc9b 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -50,7 +50,14 @@ Disabled {% endif %}
    - +
    + Allowed Source IPs
    + {% if token.allowed_ips %} + {{ token.allowed_ips|join:', ' }} + {% else %} + Any + {% endif %} +
    {% if token.description %}
    {{ token.description }} {% endif %} diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html index 112603126..913784c94 100644 --- a/netbox/templates/users/profile.html +++ b/netbox/templates/users/profile.html @@ -21,7 +21,7 @@ {% if request.user.first_name or request.user.last_name %} {{ request.user.first_name }} {{ request.user.last_name }} {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 0dec4968c..f62da6fed 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -49,7 +49,7 @@ (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -64,7 +64,7 @@ (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) {% endif %} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -78,31 +78,39 @@
    -
    - Cluster -
    +
    Cluster
    + + + + + + + +
    Site + {{ object.site|linkify|placeholder }} +
    Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}
    Cluster Type {{ object.cluster.type }}
    Device + {{ object.device|linkify|placeholder }} +
    -
    - Resources -
    +
    Resources
    @@ -115,7 +123,7 @@ {% if object.memory %} {{ object.memory|humanize_megabytes }} {% else %} - + {{ ''|placeholder }} {% endif %} @@ -125,7 +133,7 @@ {% if object.disk %} {{ object.disk }} GB {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index db4f84f0a..7732816a7 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -33,7 +33,7 @@ {% if interface.rf_channel_frequency %} {{ interface.rf_channel_frequency|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} @@ -43,7 +43,7 @@ {% if interface.rf_channel_width %} {{ interface.rf_channel_width|simplify_decimal }} MHz {% else %} - + {{ ''|placeholder }} {% endif %} diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 8ca4ae29c..dd14a412b 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -112,6 +112,12 @@ class ContactModelFilterSet(django_filters.FilterSet): queryset=ContactRole.objects.all(), label='Contact Role' ) + contact_group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contacts__contact__group', + lookup_expr='in', + label='Contact group', + ) # diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index 15d7773b7..02589d733 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -32,7 +32,7 @@ class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant fieldsets = ( (None, ('q', 'tag', 'group_id')), - ('Contacts', ('contact', 'contact_role')) + ('Contacts', ('contact', 'contact_role', 'contact_group')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), diff --git a/netbox/tenancy/forms/forms.py b/netbox/tenancy/forms/forms.py index 5dcad1d43..5e78bc540 100644 --- a/netbox/tenancy/forms/forms.py +++ b/netbox/tenancy/forms/forms.py @@ -58,3 +58,8 @@ class ContactModelFilterForm(forms.Form): required=False, label=_('Contact Role') ) + contact_group = DynamicModelMultipleChoiceField( + queryset=ContactGroup.objects.all(), + required=False, + label=_('Contact Group') + ) diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py index 17abc5a5b..234dc2ad7 100644 --- a/netbox/tenancy/tables/contacts.py +++ b/netbox/tenancy/tables/contacts.py @@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable): ) contact_count = columns.LinkedCountColumn( viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, + url_params={'group_id': 'pk'}, verbose_name='Contacts' ) tags = columns.TagColumn( diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 58ad98e8f..f6f95b123 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404 from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site -from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster @@ -104,8 +104,9 @@ class TenantView(generic.ObjectView): 'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(), - 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(), diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 320c28df2..2db822cfe 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips or 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 7d0212441..bc3d44862 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm): class Meta: fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' + 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 059bb0bd7..177cce39c 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField, ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * @@ -64,10 +64,19 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() + allowed_ips = serializers.ListField( + child=IPNetworkSerializer(), + required=False, + allow_empty=True, + default=[] + ) class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + fields = ( + 'id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description', + 'allowed_ips', + ) def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d5e6218e5..8692eb050 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -99,11 +101,18 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, help_text="If no key is provided, one will be generated automatically." ) + allowed_ips = SimpleArrayField( + base_field=IPNetworkFormField(), + required=False, + label='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + ) class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', + 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0003_token_allowed_ips.py b/netbox/users/migrations/0003_token_allowed_ips.py new file mode 100644 index 000000000..f4eaa9f96 --- /dev/null +++ b/netbox/users/migrations/0003_token_allowed_ips.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-04-19 12:37 + +import django.contrib.postgres.fields +from django.db import migrations +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index a0055914b..4ee4dce6b 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -9,13 +9,14 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netaddr import IPNetwork +from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * - __all__ = ( 'ObjectPermission', 'Token', @@ -220,6 +221,14 @@ class Token(models.Model): max_length=200, blank=True ) + allowed_ips = ArrayField( + base_field=IPNetworkField(), + blank=True, + null=True, + verbose_name='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + ) class Meta: pass @@ -244,6 +253,19 @@ class Token(models.Model): return False return True + def validate_client_ip(self, client_ip): + """ + Validate the API client IP address against the source IP restrictions (if any) set on the token. + """ + if not self.allowed_ips: + return True + + for ip_network in self.allowed_ips: + if client_ip in IPNetwork(ip_network): + return True + + return False + # # Permissions diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py index f83fc6a7c..68e71610c 100644 --- a/netbox/utilities/forms/fields/dynamic.py +++ b/netbox/utilities/forms/fields/dynamic.py @@ -88,7 +88,12 @@ class DynamicModelChoiceMixin: # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options # will be populated on-demand via the APISelect widget. data = bound_field.value() + if data: + # When the field is multiple choice pass the data as a list if it's not already + if isinstance(bound_field.field, DynamicModelMultipleChoiceField) and not type(data) is list: + data = [data] + field_name = getattr(self, 'to_field_name') or 'pk' filter = self.filter(field_name=field_name) try: @@ -130,11 +135,12 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip widget = widgets.APISelectMultiple def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ + value = value or [] + + # When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + # string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] return [None, *value] + return super().clean(value) diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py index 0d09d2ac7..9168189a1 100644 --- a/netbox/utilities/forms/fields/fields.py +++ b/netbox/utilities/forms/fields/fields.py @@ -3,6 +3,7 @@ import json from django import forms from django.db.models import Count from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from django.templatetags.static import static from netaddr import AddrFormatError, EUI from utilities.forms import widgets @@ -26,10 +27,9 @@ class CommentField(forms.CharField): A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. """ widget = forms.Textarea - # TODO: Port Markdown cheat sheet to internal documentation - help_text = """ + help_text = f""" - + Markdown syntax is supported """ diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,7 +1,6 @@ import re from django import forms -from django.conf import settings from django.forms.models import fields_for_model from utilities.choices import unpack_grouped_choices diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py new file mode 100644 index 000000000..3b8e1edde --- /dev/null +++ b/netbox/utilities/request.py @@ -0,0 +1,27 @@ +from netaddr import IPAddress + +__all__ = ( + 'get_client_ip', +) + + +def get_client_ip(request, additional_headers=()): + """ + Return the client (source) IP address of the given request. + """ + HTTP_HEADERS = ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', + *additional_headers + ) + for header in HTTP_HEADERS: + if header in request.META: + client_ip = request.META[header].split(',')[0] + try: + return IPAddress(client_ip) + except ValueError: + raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + + # Could not determine the client IP address from request headers + return None diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py index 44ad5ac47..738dc0e00 100644 --- a/netbox/utilities/templatetags/builtins/filters.py +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -11,7 +11,7 @@ from markdown import markdown from netbox.config import get_config from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import clean_html, foreground_color register = template.Library() @@ -144,18 +144,6 @@ def render_markdown(value): {{ md_source_text|markdown }} """ - schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) - - # Strip HTML tags - value = strip_tags(value) - - # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)' - value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) - - # Sanitize Markdown reference links - pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)' - value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) # Render Markdown html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()]) @@ -164,6 +152,11 @@ def render_markdown(value): if html: html = f'
    {html}
    ' + schemes = get_config().ALLOWED_URL_SCHEMES + + # Sanitize HTML + html = clean_html(html, schemes) + return mark_safe(html) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 466b5e22b..52ccd002d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,15 +34,16 @@ def post_data(data): return ret -def create_test_device(name): +def create_test_device(name, site=None, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + if site is None: + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs) return device diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 1171bd496..e341442be 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -176,6 +176,64 @@ class APIPaginationTestCase(APITestCase): self.assertEqual(len(response.data['results']), 100) +class APIOrderingTestCase(APITestCase): + user_permissions = ('dcim.view_site',) + + @classmethod + def setUpTestData(cls): + cls.url = reverse('dcim-api:site-list') + + sites = ( + Site(name='Site 1', slug='site-1', facility='C', description='Z'), + Site(name='Site 2', slug='site-2', facility='C', description='Y'), + Site(name='Site 3', slug='site-3', facility='B', description='X'), + Site(name='Site 4', slug='site-4', facility='B', description='W'), + Site(name='Site 5', slug='site-5', facility='A', description='V'), + Site(name='Site 6', slug='site-6', facility='A', description='U'), + ) + Site.objects.bulk_create(sites) + + def test_default_order(self): + response = self.client.get(self.url, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 1', 'Site 2', 'Site 3', 'Site 4', 'Site 5', 'Site 6'] + ) + + def test_order_single_field(self): + response = self.client.get(f'{self.url}?ordering=description', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_reversed(self): + response = self.client.get(f'{self.url}?ordering=-name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_multiple_fields(self): + response = self.client.get(f'{self.url}?ordering=facility,name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 5', 'Site 6', 'Site 3', 'Site 4', 'Site 1', 'Site 2'] + ) + + class APIDocsTestCase(TestCase): def setUp(self): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 7b37c0b70..97ab165fe 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,9 +1,11 @@ import datetime +import decimal import json from collections import OrderedDict from decimal import Decimal from itertools import count, groupby +import bleach from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce @@ -14,6 +16,7 @@ from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.plugins import PluginConfig from extras.utils import is_taggable +from netbox.config import get_config from utilities.constants import HTTP_REQUEST_META_SAFE_COPY @@ -224,6 +227,21 @@ def deepmerge(original, new): return merged +def drange(start, end, step=decimal.Decimal(1)): + """ + Decimal-compatible implementation of Python's range() + """ + start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step) + if start < end: + while start < end: + yield start + start += step + else: + while start > end: + yield start + start += step + + def to_meters(length, unit): """ Convert the given length to meters. @@ -257,7 +275,9 @@ def render_jinja2(template_code, context): """ Render a Jinja2 template with the provided context. Return the rendered content. """ - return SandboxedEnvironment().from_string(source=template_code).render(**context) + environment = SandboxedEnvironment() + environment.filters.update(get_config().JINJA2_FILTERS) + return environment.from_string(source=template_code).render(**context) def prepare_cloned_fields(instance): @@ -321,14 +341,34 @@ def flatten_dict(d, prefix='', separator='.'): return ret +def array_to_ranges(array): + """ + Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as + single-item tuples. For example: + [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]" + """ + group = ( + list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) + ) + return [ + (g[0], g[-1])[:len(g)] for g in group + ] + + def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. For example: [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" """ - group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + ret = [] + ranges = array_to_ranges(array) + for value in ranges: + if len(value) == 1: + ret.append(str(value[0])) + else: + ret.append(f'{value[0]}-{value[1]}') + return ', '.join(ret) def content_type_name(ct): @@ -382,3 +422,33 @@ def copy_safe_request(request): 'path': request.path, 'id': getattr(request, 'id', None), # UUID assigned by middleware }) + + +def clean_html(html, schemes): + """ + Sanitizes HTML based on a whitelist of allowed tags and attributes. + Also takes a list of allowed URI schemes. + """ + + ALLOWED_TAGS = [ + "div", "pre", "code", "blockquote", "del", + "hr", "h1", "h2", "h3", "h4", "h5", "h6", + "ul", "ol", "li", "p", "br", + "strong", "em", "a", "b", "i", "img", + "table", "thead", "tbody", "tr", "th", "td", + "dl", "dt", "dd", + ] + + ALLOWED_ATTRIBUTES = { + "div": ['class'], + "h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"], + "a": ["href", "title"], + "img": ["src", "title", "alt"], + } + + return bleach.clean( + html, + tags=ALLOWED_TAGS, + attributes=ALLOWED_ATTRIBUTES, + protocols=schemes + ) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index afdf50b96..bd01b5533 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,7 +1,9 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, +) from dcim.choices import InterfaceModeChoices from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN @@ -45,6 +47,7 @@ class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) @@ -53,8 +56,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -65,8 +68,9 @@ class ClusterSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) - site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer(required=False, allow_null=True) + device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) @@ -77,9 +81,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -89,9 +93,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 665114881..d2a90ae34 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 693e53df6..2cf6357e1 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,6 +1,28 @@ from utilities.choices import ChoiceSet +# +# Clusters +# + +class ClusterStatusChoices(ChoiceSet): + key = 'Cluster.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] + + # # VirtualMachines # diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5a2aa8b42..00d3e2313 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label='Cluster type (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=ClusterStatusChoices, + null_value=None + ) class Meta: model = Cluster @@ -146,39 +150,48 @@ class VirtualMachineFilterSet( to_field_name='name', label='Cluster', ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', label='Site group (ID)', ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', to_field_name='slug', label='Site group (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index d5d33df2a..b2429744b 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -2,8 +2,8 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import VLAN, VRF +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup +from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( @@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=ClusterGroup.objects.all(), required=False ) + status = forms.ChoiceField( + choices=add_blank_choice(ClusterStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'tenant',)), + (None, ('type', 'group', 'status', 'tenant',)), ('Site', ('region', 'site_group', 'site',)), ) nullable_fields = ( @@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect(), ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -182,13 +202,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): required=False, widget=StaticSelect() ) + vlan_group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + label='VLAN group' + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Untagged VLAN' ) tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), - required=False + required=False, + query_params={ + 'group_id': '$vlan_group', + }, + label='Tagged VLANs' ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), @@ -200,7 +233,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): fieldsets = ( (None, ('mtu', 'enabled', 'vrf', 'description')), ('Related Interfaces', ('parent', 'bridge')), - ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ) nullable_fields = ( 'parent', 'bridge', 'mtu', 'vrf', 'description', @@ -223,8 +256,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # See 5643 if 'pk' in self.initial: site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' + interfaces = VMInterface.objects.filter( + pk__in=self.initial['pk'] + ).prefetch_related( + 'virtual_machine__site' ) # Check interface sites. First interface should set site, further interfaces will either continue the diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index eab6fc9e7..2d7ee52e2 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,5 @@ from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Platform, Site +from dcim.models import Device, DeviceRole, Platform, Site from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm): required=False, help_text='Assigned cluster group' ) + status = CSVChoiceField( + choices=ClusterStatusChoices, + help_text='Operational status' + ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): choices=VirtualMachineStatusChoices, help_text='Operational status' ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Assigned cluster' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned device within cluster' + ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f386e889..e15a76a43 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm @@ -29,16 +29,20 @@ class ClusterTypeFilterForm(NetBoxModelFilterSetForm): class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ) class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('group_id', 'type_id')), + ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), @@ -50,6 +54,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) + status = MultipleChoiceField( + choices=ClusterStatusChoices, + required=False + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -83,11 +91,11 @@ class VirtualMachineFilterForm( model = VirtualMachine fieldsets = ( (None, ('q', 'tag')), - ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), - ('Contacts', ('contact', 'contact_role')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), @@ -106,6 +114,11 @@ class VirtualMachineFilterForm( required=False, label=_('Cluster') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 314b0bddf..018b50c99 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) + widgets = { + 'status': StaticSelect(), + } class ClusterAddDevicesForm(BootstrapMixin, forms.Form): @@ -161,6 +165,9 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -172,7 +179,15 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), query_params={ - 'group_id': '$cluster_group' + 'site_id': '$site', + 'group_id': '$cluster_group', + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' } ) role = DynamicModelChoiceField( @@ -193,7 +208,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), + ('Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -203,8 +218,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', 'role', 'tenant_group', 'tenant', + 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', + 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " @@ -307,7 +323,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py new file mode 100644 index 000000000..e836bb914 --- /dev/null +++ b/netbox/virtualization/migrations/0030_cluster_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-19 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0029_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py new file mode 100644 index 000000000..85ea24455 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'), + ), + ] diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py new file mode 100644 index 000000000..e9c52bfde --- /dev/null +++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def update_virtualmachines_site(apps, schema_editor): + """ + Automatically set the site for all virtual machines. + """ + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + + virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False) + for vm in virtual_machines: + vm.site = vm.cluster.site + VirtualMachine.objects.bulk_update(virtual_machines, ['site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0031_virtualmachine_site_device'), + ] + + operations = [ + migrations.RunPython( + code=update_virtualmachines_site, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 586bb8a9e..02560a962 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -119,6 +119,11 @@ class Cluster(NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -165,6 +170,9 @@ class Cluster(NetBoxModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + def clean(self): super().clean() @@ -187,10 +195,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.PROTECT, - related_name='virtual_machines' + related_name='virtual_machines', + blank=True, + null=True + ) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -276,7 +300,7 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() clone_fields = [ - 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', ] class Meta: @@ -308,6 +332,28 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Must be assigned to a site and/or cluster + if not self.site and not self.cluster: + raise ValidationError({ + 'cluster': f'A virtual machine must be assigned to a site and/or cluster.' + }) + + # Validate site for cluster & device + if self.cluster and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' + }) + if self.device and self.device.site != self.site: + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + }) + + # Validate assigned cluster device + if self.device and self.device not in self.cluster.devices.all(): + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' + }) + # Validate primary IP addresses interfaces = self.interfaces.all() for field in ['primary_ip4', 'primary_ip6']: @@ -336,10 +382,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): else: return None - @property - def site(self): - return self.cluster.site - # # Interfaces diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index a0c98425a..dfcae052a 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -66,6 +66,7 @@ class ClusterTable(NetBoxTable): group = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() tenant = tables.Column( linkify=True ) @@ -93,7 +94,7 @@ class ClusterTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'device_count', 'vm_count', + 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 89dbdf901..0fe2571b1 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -30,9 +30,15 @@ class VirtualMachineTable(NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() + site = tables.Column( + linkify=True + ) cluster = tables.Column( linkify=True ) + device = tables.Column( + linkify=True + ) role = columns.ColoredLabelColumn() tenant = TenantColumn() comments = columns.MarkdownColumn() @@ -56,11 +62,11 @@ class VirtualMachineTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', + 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f6c07fa54..b2ae68860 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,8 +2,10 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from dcim.models import Site from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { + 'status': 'offline', 'comments': 'New comment', } @@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_groups) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) Cluster.objects.bulk_create(clusters) @@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): 'name': 'Cluster 4', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 5', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 6', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, ] @@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + clusters = ( - Cluster(name='Cluster 1', type=clustertype, group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 3', type=clustertype), ) Cluster.objects.bulk_create(clusters) + device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) + device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) + virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}), ) VirtualMachine.objects.bulk_create(virtual_machines) cls.create_data = [ { 'name': 'Virtual Machine 4', + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': device2.pk, }, { 'name': 'Virtual Machine 5', + 'site': sites[1].pk, 'cluster': clusters[1].pk, }, { 'name': 'Virtual Machine 6', - 'cluster': clusters[1].pk, + 'site': sites[1].pk, + }, + { + 'name': 'Virtual Machine 7', + 'cluster': clusters[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 9e264ac5c..d3ff12887 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,9 +1,9 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]), ) Cluster.objects.bulk_create(clusters) @@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): types = ClusterType.objects.all()[:2] params = {'type_id': [types[0].pk, types[1].pk]} @@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ) DeviceRole.objects.bulk_create(roles) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + create_test_device('device3', cluster=clusters[2]), + ) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) @@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster': [clusters[0].name, clusters[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 3b4d73a30..df5816efa 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,21 +1,19 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from dcim.models import Site from virtualization.models import * from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def setUp(self): - - cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1') - self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type) - def test_vm_duplicate_name_per_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) vm1 = VirtualMachine( - cluster=self.cluster, + cluster=cluster, name='Test VM 1' ) vm1.save() @@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase): # Two VMs assigned to the same Cluster and different Tenants should pass validation vm2.full_clean() vm2.save() + + def test_vm_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + # VM with site only should pass + VirtualMachine(name='vm1', site=sites[0]).full_clean() + + # VM with non-site cluster only should pass + VirtualMachine(name='vm1', cluster=clusters[2]).full_clean() + + # VM with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() + + # VM with cluster site but no direct site should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 8edc14f00..01d4394f3 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_tags, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Cluster X', 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', @@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,type", - "Cluster 4,Cluster Type 1", - "Cluster 5,Cluster Type 1", - "Cluster 6,Cluster Type 1", + "name,type,status", + "Cluster 4,Cluster Type 1,active", + "Cluster 5,Cluster Type 1,active", + "Cluster 6,Cluster Type 1,active", ) cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'New comments', @@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype), - Cluster(name='Cluster 2', type=clustertype), + Cluster(name='Cluster 1', type=clustertype, site=sites[0]), + Cluster(name='Cluster 2', type=clustertype, site=sites[1]), ) Cluster.objects.bulk_create(clusters) + devices = ( + create_test_device('device1', site=sites[0], cluster=clusters[0]), + create_test_device('device2', site=sites[1], cluster=clusters[1]), + ) + VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, + 'site': sites[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster", - "Virtual Machine 4,active,Cluster 1", - "Virtual Machine 5,active,Cluster 1", - "Virtual Machine 6,active,Cluster 1", + "name,status,site,cluster,device", + "Virtual Machine 4,active,Site 1,Cluster 1,device1", + "Virtual Machine 5,active,Site 1,Cluster 1,device1", + "Virtual Machine 6,active,Site 1,Cluster 1,", ) cls.bulk_edit_data = { + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, @@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) virtualmachines = ( - VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole), - VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole), ) VirtualMachine.objects.bulk_create(virtualmachines) diff --git a/requirements.txt b/requirements.txt index 0a15fcf20..d5b86eafa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,14 @@ -Django==4.0.4 -django-cors-headers==3.12.0 -django-debug-toolbar==3.2.4 -django-filter==21.1 +bleach==5.0.0 +Django==4.0.5 +django-cors-headers==3.13.0 +django-debug-toolbar==3.4.0 +django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 @@ -18,15 +20,15 @@ gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 markdown-include==0.6.0 -mkdocs-material==8.2.14 -mkdocstrings[python-legacy]==0.18.1 +mkdocs-material==8.3.6 +mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 -Pillow==9.1.0 +Pillow==9.1.1 psycopg2-binary==2.9.3 PyYAML==6.0 sentry-sdk==1.5.12 social-auth-app-django==5.0.0 -social-auth-core==4.2.0 +social-auth-core==4.3.0 svgwrite==1.4.2 tablib==3.2.1 tzdata==2022.1