mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-18 13:06:30 -06:00
Merge branch 'feature' into issue_9536
This commit is contained in:
commit
cfb9605e9b
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
7
.github/workflows/stale.yml
vendored
7
.github/workflows/stale.yml
vendored
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
@ -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')`
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
@ -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.)
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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:
|
||||
|
||||
|
@ -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__
|
||||
|
353
docs/reference/markdown.md
Normal file
353
docs/reference/markdown.md
Normal file
@ -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)
|
||||
|
||||
<a name="headers"></a>
|
||||
|
||||
## 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
|
||||
|
||||
<a name="emphasis"></a>
|
||||
|
||||
## 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.~~
|
||||
|
||||
|
||||
<a name="lists"></a>
|
||||
|
||||
## 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
|
||||
|
||||
<a name="links"></a>
|
||||
|
||||
## 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 <http://www.example.com> 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 <http://www.example.com> 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
|
||||
|
||||
<a name="images"></a>
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
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:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
|
||||
<a name="code"></a>
|
||||
|
||||
## 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 <code>```</code>
|
||||
|
||||
````
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
````
|
||||
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
|
||||
<a name="tables"></a>
|
||||
|
||||
## 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
|
||||
|
||||
<a name="blockquotes"></a>
|
||||
|
||||
## 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.
|
||||
|
||||
<a name="html"></a>
|
||||
|
||||
## Inline HTML
|
||||
|
||||
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
|
||||
|
||||
```no-highlight
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
|
||||
<a name="hr"></a>
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
```
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
```
|
||||
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
|
||||
<a name="lines"></a>
|
||||
|
||||
## 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/)
|
@ -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
|
||||
|
63
docs/release-notes/version-3.3.md
Normal file
63
docs/release-notes/version-3.3.md
Normal file
@ -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.
|
@ -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
|
||||
```
|
||||
|
@ -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'
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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():
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
]
|
||||
|
@ -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,
|
||||
|
@ -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(),
|
||||
}
|
||||
|
||||
|
18
netbox/circuits/migrations/0036_circuit_termination_date.py
Normal file
18
netbox/circuits/migrations/0036_circuit_termination_date.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
@ -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:
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -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
|
||||
|
||||
|
||||
#
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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(
|
||||
|
@ -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(),
|
||||
|
@ -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."
|
||||
|
@ -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
|
||||
|
||||
|
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
23
netbox/dcim/migrations/0154_half_height_rack_units.py
Normal file
@ -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)]),
|
||||
),
|
||||
]
|
23
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
23
netbox/dcim/migrations/0155_interface_poe_mode_type.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
18
netbox/dcim/migrations/0156_location_status.py
Normal file
18
netbox/dcim/migrations/0156_location_status.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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__()
|
||||
|
@ -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
|
||||
|
||||
|
@ -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()
|
||||
|
||||
|
2
netbox/dcim/svg/__init__.py
Normal file
2
netbox/dcim/svg/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .cables import *
|
||||
from .racks import *
|
@ -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
|
300
netbox/dcim/svg/racks.py
Normal file
300
netbox/dcim/svg/racks.py
Normal file
@ -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
|
@ -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')
|
||||
|
||||
|
@ -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')
|
||||
|
@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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=['*'])
|
||||
|
@ -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=(
|
||||
|
@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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(),
|
||||
|
@ -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):
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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 += '<br />'
|
||||
self.fields[field_name].help_text += '<i class="mdi mdi-alert-circle-outline"></i> ' \
|
||||
'Field is set to read-only.'
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[field_name] = customfield
|
||||
|
@ -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,
|
||||
|
@ -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',
|
||||
)
|
||||
|
||||
|
||||
|
@ -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()
|
||||
|
22
netbox/extras/migrations/0074_customfield_group_name.py
Normal file
22
netbox/extras/migrations/0074_customfield_group_name.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
18
netbox/extras/migrations/0075_customfield_ui_visibility.py
Normal file
18
netbox/extras/migrations/0075_customfield_ui_visibility.py
Normal file
@ -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),
|
||||
),
|
||||
]
|
19
netbox/extras/migrations/0076_configcontext_locations.py
Normal file
19
netbox/extras/migrations/0076_configcontext_locations.py
Normal file
@ -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'),
|
||||
),
|
||||
]
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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]}
|
||||
|
@ -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,
|
||||
|
@ -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 = {
|
||||
|
@ -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):
|
||||
|
@ -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):
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
),
|
||||
]
|
@ -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',
|
||||
|
@ -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(
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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()
|
||||
|
@ -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'
|
||||
|
||||
|
@ -36,3 +36,8 @@ REDIS = {
|
||||
}
|
||||
|
||||
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True
|
||||
}
|
||||
|
@ -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',
|
||||
}),
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>')
|
||||
|
||||
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'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
|
||||
)
|
||||
|
||||
menu = f'<span class="dropdown">' \
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">' \
|
||||
f'<i class="mdi mdi-wrench"></i></a>' \
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
# Add dropdown menu items
|
||||
else:
|
||||
dropdown_links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
|
||||
# Create the actions dropdown menu
|
||||
if button and dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' {button}'
|
||||
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
elif button:
|
||||
html += button
|
||||
elif dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
|
||||
# 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):
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user