mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Merge branch 'feature' into 9102-cabling
This commit is contained in:
commit
440dfabefe
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.2.4
|
placeholder: v3.2.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.2.4
|
placeholder: v3.2.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
5
.github/workflows/stale.yml
vendored
5
.github/workflows/stale.yml
vendored
@ -27,7 +27,10 @@ jobs:
|
|||||||
This issue has been automatically marked as stale because it has not had
|
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
|
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
|
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-label: 'pending closure'
|
||||||
stale-pr-message: >
|
stale-pr-message: >
|
||||||
This PR has been automatically marked as stale because it has not had
|
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
|
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
|
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
|
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
||||||
an effort to circumvent the bot: Doing so will not remove the stale label.
|
effort to circumvent the bot: This will result in the immediate closure of the
|
||||||
(Stale labels can be removed only by maintainers.)
|
issue, and you may be barred from participating in future discussions.
|
||||||
|
|
||||||
## Maintainer Guidance
|
## Maintainer Guidance
|
||||||
|
|
||||||
|
@ -30,10 +30,14 @@ django-pglocks
|
|||||||
# https://github.com/korfuri/django-prometheus
|
# https://github.com/korfuri/django-prometheus
|
||||||
django-prometheus
|
django-prometheus
|
||||||
|
|
||||||
# Django chaching backend using Redis
|
# Django caching backend using Redis
|
||||||
# https://github.com/jazzband/django-redis
|
# https://github.com/jazzband/django-redis
|
||||||
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)
|
# Django integration for RQ (Reqis queuing)
|
||||||
# https://github.com/rq/django-rq
|
# https://github.com/rq/django-rq
|
||||||
django-rq
|
django-rq
|
||||||
@ -44,7 +48,8 @@ django-tables2
|
|||||||
|
|
||||||
# User-defined tags for objects
|
# User-defined tags for objects
|
||||||
# https://github.com/alex/django-taggit
|
# 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
|
# A Django field for representing time zones
|
||||||
# https://github.com/mfogel/django-timezone-field/
|
# https://github.com/mfogel/django-timezone-field/
|
||||||
@ -125,3 +130,7 @@ tablib
|
|||||||
# Timezone data (required by django-timezone-field on Python 3.9+)
|
# Timezone data (required by django-timezone-field on Python 3.9+)
|
||||||
# https://github.com/python/tzdata
|
# https://github.com/python/tzdata
|
||||||
tzdata
|
tzdata
|
||||||
|
|
||||||
|
# HTML sanitizer
|
||||||
|
# https://github.com/mozilla/bleach
|
||||||
|
bleach
|
@ -255,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
|
## INTERNAL_IPS
|
||||||
|
|
||||||
Default: `('127.0.0.1', '::1')`
|
Default: `('127.0.0.1', '::1')`
|
||||||
|
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,6 +1,39 @@
|
|||||||
# NetBox v3.2
|
# NetBox v3.2
|
||||||
|
|
||||||
## v3.2.5 (FUTURE)
|
## 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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -4,8 +4,13 @@
|
|||||||
|
|
||||||
### Breaking Changes
|
### 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).
|
* 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))
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses
|
||||||
@ -19,9 +24,16 @@
|
|||||||
### Other Changes
|
### 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
|
* [#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
|
### REST API Changes
|
||||||
|
|
||||||
|
* 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.Rack
|
||||||
|
* The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit
|
||||||
* extras.CustomField
|
* extras.CustomField
|
||||||
* Added `group_name` and `ui_visibility` fields
|
* Added `group_name` and `ui_visibility` fields
|
||||||
* ipam.IPAddress
|
* ipam.IPAddress
|
||||||
|
@ -136,6 +136,7 @@ nav:
|
|||||||
- Overview: 'graphql-api/overview.md'
|
- Overview: 'graphql-api/overview.md'
|
||||||
- Reference:
|
- Reference:
|
||||||
- Conditions: 'reference/conditions.md'
|
- Conditions: 'reference/conditions.md'
|
||||||
|
- Markdown: 'reference/markdown.md'
|
||||||
- Development:
|
- Development:
|
||||||
- Introduction: 'development/index.md'
|
- Introduction: 'development/index.md'
|
||||||
- Getting Started: 'development/getting-started.md'
|
- Getting Started: 'development/getting-started.md'
|
||||||
|
@ -5,7 +5,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0036_new_cabling_models'),
|
('circuits', '0036_new_cabling_models'),
|
||||||
('dcim', '0157_populate_cable_ends'),
|
('dcim', '0158_populate_cable_ends'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import decimal
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from drf_yasg.utils import swagger_serializer_method
|
from drf_yasg.utils import swagger_serializer_method
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
@ -246,7 +248,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.
|
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)
|
name = serializers.CharField(read_only=True)
|
||||||
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
|
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
|
||||||
device = NestedDeviceSerializer(read_only=True)
|
device = NestedDeviceSerializer(read_only=True)
|
||||||
@ -328,6 +334,13 @@ class ManufacturerSerializer(NetBoxModelSerializer):
|
|||||||
class DeviceTypeSerializer(NetBoxModelSerializer):
|
class DeviceTypeSerializer(NetBoxModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||||
manufacturer = NestedManufacturerSerializer()
|
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)
|
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
|
||||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||||
device_count = serializers.IntegerField(read_only=True)
|
device_count = serializers.IntegerField(read_only=True)
|
||||||
@ -634,7 +647,14 @@ class DeviceSerializer(NetBoxModelSerializer):
|
|||||||
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
location = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||||
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
|
rack = NestedRackSerializer(required=False, allow_null=True, default=None)
|
||||||
face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='')
|
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)
|
status = ChoiceField(choices=DeviceStatusChoices, required=False)
|
||||||
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False)
|
||||||
primary_ip = NestedIPAddressSerializer(read_only=True)
|
primary_ip = NestedIPAddressSerializer(read_only=True)
|
||||||
|
@ -19,6 +19,7 @@ from ipam.models import Prefix, VLAN
|
|||||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||||
from netbox.api.exceptions import ServiceUnavailable
|
from netbox.api.exceptions import ServiceUnavailable
|
||||||
from netbox.api.metadata import ContentTypeMetadata
|
from netbox.api.metadata import ContentTypeMetadata
|
||||||
|
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||||
from netbox.api.viewsets import NetBoxModelViewSet
|
from netbox.api.viewsets import NetBoxModelViewSet
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.api import get_serializer_for_model
|
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',
|
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||||
)
|
)
|
||||||
filterset_class = filtersets.DeviceFilterSet
|
filterset_class = filtersets.DeviceFilterSet
|
||||||
|
pagination_class = StripCountAnnotationsPaginator
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""
|
"""
|
||||||
|
@ -164,7 +164,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
|||||||
qs_filter |= Q(asns__asn=int(value.strip()))
|
qs_filter |= Q(asns__asn=int(value.strip()))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
pass
|
pass
|
||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter).distinct()
|
||||||
|
|
||||||
|
|
||||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||||
|
@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.models 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 netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm(
|
|||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect
|
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(
|
untagged_vlan = DynamicModelChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'group_id': '$vlan_group',
|
||||||
|
},
|
||||||
|
label='Untagged VLAN'
|
||||||
)
|
)
|
||||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'group_id': '$vlan_group',
|
||||||
|
},
|
||||||
|
label='Tagged VLANs'
|
||||||
)
|
)
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm(
|
|||||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
('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')),
|
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
'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',
|
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
||||||
'vrf',
|
'tagged_vlans', 'vrf',
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
|
@ -467,7 +467,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
'location_id': '$location',
|
'location_id': '$location',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
position = forms.IntegerField(
|
position = forms.DecimalField(
|
||||||
required=False,
|
required=False,
|
||||||
help_text="The lowest-numbered unit occupied by the device",
|
help_text="The lowest-numbered unit occupied by the device",
|
||||||
widget=APISelect(
|
widget=APISelect(
|
||||||
|
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)]),
|
||||||
|
),
|
||||||
|
]
|
@ -6,7 +6,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('contenttypes', '0002_remove_content_type_name'),
|
('contenttypes', '0002_remove_content_type_name'),
|
||||||
('dcim', '0153_created_datetimefield'),
|
('dcim', '0154_half_height_rack_units'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -40,7 +40,7 @@ def populate_cable_terminations(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0154_new_cabling_models'),
|
('dcim', '0155_new_cabling_models'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -39,7 +39,7 @@ def populate_cable_paths(apps, schema_editor):
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0155_populate_cable_terminations'),
|
('dcim', '0156_populate_cable_terminations'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -31,7 +31,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('circuits', '0036_new_cabling_models'),
|
('circuits', '0036_new_cabling_models'),
|
||||||
('dcim', '0156_populate_cable_paths'),
|
('dcim', '0157_populate_cable_paths'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -4,7 +4,7 @@ from django.db import migrations
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('dcim', '0157_populate_cable_ends'),
|
('dcim', '0158_populate_cable_ends'),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
@ -99,8 +99,10 @@ class DeviceType(NetBoxModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
help_text='Discrete part number (optional)'
|
help_text='Discrete part number (optional)'
|
||||||
)
|
)
|
||||||
u_height = models.PositiveSmallIntegerField(
|
u_height = models.DecimalField(
|
||||||
default=1,
|
max_digits=4,
|
||||||
|
decimal_places=1,
|
||||||
|
default=1.0,
|
||||||
verbose_name='Height (U)'
|
verbose_name='Height (U)'
|
||||||
)
|
)
|
||||||
is_full_depth = models.BooleanField(
|
is_full_depth = models.BooleanField(
|
||||||
@ -166,7 +168,7 @@ class DeviceType(NetBoxModel):
|
|||||||
('model', self.model),
|
('model', self.model),
|
||||||
('slug', self.slug),
|
('slug', self.slug),
|
||||||
('part_number', self.part_number),
|
('part_number', self.part_number),
|
||||||
('u_height', self.u_height),
|
('u_height', float(self.u_height)),
|
||||||
('is_full_depth', self.is_full_depth),
|
('is_full_depth', self.is_full_depth),
|
||||||
('subdevice_role', self.subdevice_role),
|
('subdevice_role', self.subdevice_role),
|
||||||
('airflow', self.airflow),
|
('airflow', self.airflow),
|
||||||
@ -654,10 +656,12 @@ class Device(NetBoxModel, ConfigContextModel):
|
|||||||
blank=True,
|
blank=True,
|
||||||
null=True
|
null=True
|
||||||
)
|
)
|
||||||
position = models.PositiveSmallIntegerField(
|
position = models.DecimalField(
|
||||||
|
max_digits=4,
|
||||||
|
decimal_places=1,
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
validators=[MinValueValidator(1)],
|
validators=[MinValueValidator(1), MaxValueValidator(99.5)],
|
||||||
verbose_name='Position (U)',
|
verbose_name='Position (U)',
|
||||||
help_text='The lowest-numbered unit occupied by the device'
|
help_text='The lowest-numbered unit occupied by the device'
|
||||||
)
|
)
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
from collections import OrderedDict
|
import decimal
|
||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.contenttypes.fields import GenericRelation
|
from django.contrib.contenttypes.fields import GenericRelation
|
||||||
@ -13,11 +13,10 @@ from django.urls import reverse
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.svg import RackElevationSVG
|
from dcim.svg import RackElevationSVG
|
||||||
from netbox.config import get_config
|
|
||||||
from netbox.models import OrganizationalModel, NetBoxModel
|
from netbox.models import OrganizationalModel, NetBoxModel
|
||||||
from utilities.choices import ColorChoices
|
from utilities.choices import ColorChoices
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
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 .device_components import PowerOutlet, PowerPort
|
||||||
from .devices import Device
|
from .devices import Device
|
||||||
from .power import PowerFeed
|
from .power import PowerFeed
|
||||||
@ -242,10 +241,13 @@ class Rack(NetBoxModel):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def units(self):
|
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:
|
if self.desc_units:
|
||||||
return range(1, self.u_height + 1)
|
drange(0.5, max_position, 0.5)
|
||||||
else:
|
return drange(max_position, 0.5, -0.5)
|
||||||
return reversed(range(1, self.u_height + 1))
|
|
||||||
|
|
||||||
def get_status_color(self):
|
def get_status_color(self):
|
||||||
return RackStatusChoices.colors.get(self.status)
|
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
|
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
|
contains a height attribute for the device
|
||||||
"""
|
"""
|
||||||
|
elevation = {}
|
||||||
elevation = OrderedDict()
|
|
||||||
for u in self.units:
|
for u in self.units:
|
||||||
|
u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}'
|
||||||
elevation[u] = {
|
elevation[u] = {
|
||||||
'id': u,
|
'id': u,
|
||||||
'name': f'U{u}',
|
'name': u_name,
|
||||||
'face': face,
|
'face': face,
|
||||||
'device': None,
|
'device': None,
|
||||||
'occupied': False
|
'occupied': False
|
||||||
@ -278,7 +280,7 @@ class Rack(NetBoxModel):
|
|||||||
if self.pk:
|
if self.pk:
|
||||||
|
|
||||||
# Retrieve all devices installed within the rack
|
# Retrieve all devices installed within the rack
|
||||||
queryset = Device.objects.prefetch_related(
|
devices = Device.objects.prefetch_related(
|
||||||
'device_type',
|
'device_type',
|
||||||
'device_type__manufacturer',
|
'device_type__manufacturer',
|
||||||
'device_role'
|
'device_role'
|
||||||
@ -299,9 +301,9 @@ class Rack(NetBoxModel):
|
|||||||
if user is not None:
|
if user is not None:
|
||||||
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
|
permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True)
|
||||||
|
|
||||||
for device in queryset:
|
for device in devices:
|
||||||
if expand_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:
|
if user is None or device.pk in permitted_device_ids:
|
||||||
elevation[u]['device'] = device
|
elevation[u]['device'] = device
|
||||||
elevation[u]['occupied'] = True
|
elevation[u]['occupied'] = True
|
||||||
@ -310,8 +312,6 @@ class Rack(NetBoxModel):
|
|||||||
elevation[device.position]['device'] = device
|
elevation[device.position]['device'] = device
|
||||||
elevation[device.position]['occupied'] = True
|
elevation[device.position]['occupied'] = True
|
||||||
elevation[device.position]['height'] = device.device_type.u_height
|
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()]
|
return [u for u in elevation.values()]
|
||||||
|
|
||||||
@ -331,12 +331,12 @@ class Rack(NetBoxModel):
|
|||||||
devices = devices.exclude(pk__in=exclude)
|
devices = devices.exclude(pk__in=exclude)
|
||||||
|
|
||||||
# Initialize the rack unit skeleton
|
# Initialize the rack unit skeleton
|
||||||
units = list(range(1, self.u_height + 1))
|
units = list(self.units)
|
||||||
|
|
||||||
# Remove units consumed by installed devices
|
# Remove units consumed by installed devices
|
||||||
for d in devices:
|
for d in devices:
|
||||||
if rack_face is None or d.face == rack_face or d.device_type.is_full_depth:
|
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:
|
try:
|
||||||
units.remove(u)
|
units.remove(u)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@ -346,7 +346,7 @@ class Rack(NetBoxModel):
|
|||||||
# Remove units without enough space above them to accommodate a device of the specified height
|
# Remove units without enough space above them to accommodate a device of the specified height
|
||||||
available_units = []
|
available_units = []
|
||||||
for u in 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)
|
available_units.append(u)
|
||||||
|
|
||||||
return list(reversed(available_units))
|
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.
|
Return a dictionary mapping all reserved units within the rack to their reservation.
|
||||||
"""
|
"""
|
||||||
reserved_units = {}
|
reserved_units = {}
|
||||||
for r in self.reservations.all():
|
for reservation in self.reservations.all():
|
||||||
for u in r.units:
|
for u in reservation.units:
|
||||||
reserved_units[u] = r
|
reserved_units[u] = reservation
|
||||||
return reserved_units
|
return reserved_units
|
||||||
|
|
||||||
def get_elevation_svg(
|
def get_elevation_svg(
|
||||||
@ -384,13 +384,17 @@ class Rack(NetBoxModel):
|
|||||||
:param include_images: Embed front/rear device images where available
|
: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.
|
: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)
|
elevation = RackElevationSVG(
|
||||||
if unit_width is None or unit_height is None:
|
self,
|
||||||
config = get_config()
|
unit_width=unit_width,
|
||||||
unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH
|
unit_height=unit_height,
|
||||||
unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT
|
legend_width=legend_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):
|
def get_0u_devices(self):
|
||||||
return self.devices.filter(position=0)
|
return self.devices.filter(position=0)
|
||||||
@ -401,6 +405,7 @@ class Rack(NetBoxModel):
|
|||||||
as utilized.
|
as utilized.
|
||||||
"""
|
"""
|
||||||
# Determine unoccupied units
|
# Determine unoccupied units
|
||||||
|
total_units = len(list(self.units))
|
||||||
available_units = self.get_available_units()
|
available_units = self.get_available_units()
|
||||||
|
|
||||||
# Remove reserved units
|
# Remove reserved units
|
||||||
@ -408,8 +413,8 @@ class Rack(NetBoxModel):
|
|||||||
if u in available_units:
|
if u in available_units:
|
||||||
available_units.remove(u)
|
available_units.remove(u)
|
||||||
|
|
||||||
occupied_unit_count = self.u_height - len(available_units)
|
occupied_unit_count = total_units - len(available_units)
|
||||||
percentage = float(occupied_unit_count) / self.u_height * 100
|
percentage = float(occupied_unit_count) / total_units * 100
|
||||||
|
|
||||||
return percentage
|
return percentage
|
||||||
|
|
||||||
|
@ -1,9 +1,16 @@
|
|||||||
|
import decimal
|
||||||
import svgwrite
|
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.conf import settings
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.http import urlencode
|
from django.utils.http import urlencode
|
||||||
|
|
||||||
|
from netbox.config import get_config
|
||||||
from utilities.utils import foreground_color
|
from utilities.utils import foreground_color
|
||||||
from dcim.choices import DeviceFaceChoices
|
from dcim.choices import DeviceFaceChoices
|
||||||
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
|
from dcim.constants import RACK_ELEVATION_BORDER_WIDTH
|
||||||
@ -16,11 +23,27 @@ __all__ = (
|
|||||||
|
|
||||||
def get_device_name(device):
|
def get_device_name(device):
|
||||||
if device.virtual_chassis:
|
if device.virtual_chassis:
|
||||||
return f'{device.virtual_chassis.name}:{device.vc_position}'
|
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||||
elif device.name:
|
elif device.name:
|
||||||
return device.name
|
name = device.name
|
||||||
else:
|
else:
|
||||||
return str(device.device_type)
|
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:
|
class RackElevationSVG:
|
||||||
@ -32,13 +55,17 @@ class RackElevationSVG:
|
|||||||
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
: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.
|
: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):
|
def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True,
|
||||||
|
base_url=None):
|
||||||
self.rack = rack
|
self.rack = rack
|
||||||
self.include_images = include_images
|
self.include_images = include_images
|
||||||
if base_url is not None:
|
self.base_url = base_url.rstrip('/') if base_url is not None else ''
|
||||||
self.base_url = base_url.rstrip('/')
|
|
||||||
else:
|
# Set drawing dimensions
|
||||||
self.base_url = ''
|
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_LEGEND_WIDTH_DEFAULT
|
||||||
|
|
||||||
# Determine the subset of devices within this rack that are viewable by the user, if any
|
# Determine the subset of devices within this rack that are viewable by the user, if any
|
||||||
permitted_devices = self.rack.devices
|
permitted_devices = self.rack.devices
|
||||||
@ -46,21 +73,9 @@ class RackElevationSVG:
|
|||||||
permitted_devices = permitted_devices.restrict(user, 'view')
|
permitted_devices = permitted_devices.restrict(user, 'view')
|
||||||
self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
|
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
|
@staticmethod
|
||||||
def _add_gradient(drawing, id_, color):
|
def _add_gradient(drawing, id_, color):
|
||||||
gradient = drawing.linearGradient(
|
gradient = LinearGradient(
|
||||||
start=(0, 0),
|
start=(0, 0),
|
||||||
end=(0, 25),
|
end=(0, 25),
|
||||||
spreadMethod='repeat',
|
spreadMethod='repeat',
|
||||||
@ -72,192 +87,193 @@ class RackElevationSVG:
|
|||||||
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
||||||
gradient.add_stop_color(offset='50%', color=color)
|
gradient.add_stop_color(offset='50%', color=color)
|
||||||
gradient.add_stop_color(offset='100%', color=color)
|
gradient.add_stop_color(offset='100%', color=color)
|
||||||
|
|
||||||
drawing.defs.add(gradient)
|
drawing.defs.add(gradient)
|
||||||
|
|
||||||
@staticmethod
|
def _setup_drawing(self):
|
||||||
def _setup_drawing(width, height):
|
width = self.unit_width + self.legend_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))
|
drawing = svgwrite.Drawing(size=(width, height))
|
||||||
|
|
||||||
# add the stylesheet
|
# Add the stylesheet
|
||||||
with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
|
with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file:
|
||||||
drawing.defs.add(drawing.style(css_file.read()))
|
drawing.defs.add(drawing.style(css_file.read()))
|
||||||
|
|
||||||
# add gradients
|
# Add gradients
|
||||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
|
|
||||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||||
|
|
||||||
return drawing
|
return drawing
|
||||||
|
|
||||||
def _draw_device_front(self, drawing, device, start, end, text):
|
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)
|
name = get_device_name(device)
|
||||||
if device.devicebay_count:
|
description = get_device_description(device)
|
||||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
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
|
color = device.device_role.color
|
||||||
link = drawing.add(
|
image = device.device_type.front_image
|
||||||
drawing.a(
|
self._draw_device(device, coords, size, color=color, image=image)
|
||||||
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
|
||||||
target='_top',
|
def draw_device_rear(self, device, coords, size):
|
||||||
fill='black'
|
"""
|
||||||
)
|
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'
|
||||||
)
|
)
|
||||||
link.set_desc(self._get_device_description(device))
|
self.drawing.add(frame)
|
||||||
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
|
def draw_legend(self):
|
||||||
if self.include_images and device.device_type.front_image:
|
"""
|
||||||
image = drawing.image(
|
Draw the rack unit labels along the lefthand side of the elevation.
|
||||||
href=device.device_type.front_image.url,
|
"""
|
||||||
insert=start,
|
for ru in range(0, self.rack.u_height):
|
||||||
size=end,
|
start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||||
class_='device-image'
|
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')
|
||||||
)
|
)
|
||||||
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):
|
def draw_background(self, face):
|
||||||
link = drawing.add(
|
"""
|
||||||
drawing.a(
|
Draw the rack unit placeholders which form the "background" of the rack elevation.
|
||||||
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
"""
|
||||||
target='_top',
|
x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width
|
||||||
fill='black'
|
url_string = '{}?{}&position={{}}'.format(
|
||||||
)
|
|
||||||
)
|
|
||||||
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'),
|
reverse('dcim:device_add'),
|
||||||
urlencode({
|
urlencode({
|
||||||
'site': rack.site.pk,
|
'site': self.rack.site.pk,
|
||||||
'location': rack.location.pk if rack.location else '',
|
'location': self.rack.location.pk if self.rack.location else '',
|
||||||
'rack': rack.pk,
|
'rack': self.rack.pk,
|
||||||
'face': face_id,
|
'face': face,
|
||||||
'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):
|
for ru in range(0, self.rack.u_height):
|
||||||
start_y = ru * unit_height
|
y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height
|
||||||
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
text_coords = (
|
||||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
x_offset + self.unit_width / 2,
|
||||||
drawing.add(
|
y_offset + self.unit_height / 2
|
||||||
drawing.text(str(unit), position_coordinates, class_="unit")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
for unit in self.merge_elevations(face):
|
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
|
# Loop through all units in the elevation
|
||||||
device = unit['device']
|
device = unit['device']
|
||||||
height = unit.get('height', 1)
|
height = unit.get('height', decimal.Decimal(1.0))
|
||||||
|
|
||||||
# Setup drawing coordinates
|
device_coords = self._get_device_coords(unit['id'], height)
|
||||||
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
|
device_size = (
|
||||||
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
|
self.unit_width,
|
||||||
end_y = unit_height * height
|
int(self.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
|
# Draw the device
|
||||||
if device and device.face == face and device.pk in self.permitted_device_ids:
|
if device and device.pk in self.permitted_device_ids:
|
||||||
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
if device.face == face and not opposite:
|
||||||
elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
|
self.draw_device_front(device, device_coords, device_size)
|
||||||
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
else:
|
||||||
|
self.draw_device_rear(device, device_coords, device_size)
|
||||||
|
|
||||||
elif device:
|
elif device:
|
||||||
# Devices which the user does not have permission to view are rendered only as unavailable space
|
# 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'))
|
self.drawing.add(Rect(device_coords, device_size, 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
|
def render(self, face):
|
||||||
|
"""
|
||||||
|
Return an SVG document representing a rack elevation.
|
||||||
|
"""
|
||||||
|
|
||||||
# Wrap the drawing with a border
|
# Initialize the drawing
|
||||||
border_width = RACK_ELEVATION_BORDER_WIDTH
|
self.drawing = self._setup_drawing()
|
||||||
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
|
# Draw the empty rack & legend
|
||||||
|
self.draw_legend()
|
||||||
|
self.draw_background(face)
|
||||||
|
|
||||||
|
# Draw the opposite rack face first, then the near face
|
||||||
|
if face == DeviceFaceChoices.FACE_REAR:
|
||||||
|
opposite_face = DeviceFaceChoices.FACE_FRONT
|
||||||
|
else:
|
||||||
|
opposite_face = DeviceFaceChoices.FACE_REAR
|
||||||
|
# self.draw_face(opposite_face, opposite=True)
|
||||||
|
self.draw_face(face)
|
||||||
|
|
||||||
|
# Draw the rack border last
|
||||||
|
self.draw_border()
|
||||||
|
|
||||||
|
return self.drawing
|
||||||
|
@ -391,7 +391,7 @@ MODULEBAY_BUTTONS = """
|
|||||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% 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>
|
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -327,15 +327,15 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
|||||||
|
|
||||||
# Retrieve all units
|
# Retrieve all units
|
||||||
response = self.client.get(url, **self.header)
|
response = self.client.get(url, **self.header)
|
||||||
self.assertEqual(response.data['count'], 42)
|
self.assertEqual(response.data['count'], 84)
|
||||||
|
|
||||||
# Search for specific units
|
# Search for specific units
|
||||||
response = self.client.get(f'{url}?q=3', **self.header)
|
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)
|
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)
|
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):
|
def test_get_rack_elevation_svg(self):
|
||||||
"""
|
"""
|
||||||
|
@ -5,6 +5,7 @@ from circuits.models import *
|
|||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
|
from utilities.utils import drange
|
||||||
|
|
||||||
|
|
||||||
class LocationTestCase(TestCase):
|
class LocationTestCase(TestCase):
|
||||||
@ -74,148 +75,142 @@ class RackTestCase(TestCase):
|
|||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|
||||||
self.site1 = Site.objects.create(
|
sites = (
|
||||||
name='TestSite1',
|
Site(name='Site 1', slug='site-1'),
|
||||||
slug='test-site-1'
|
Site(name='Site 2', slug='site-2'),
|
||||||
)
|
)
|
||||||
self.site2 = Site.objects.create(
|
Site.objects.bulk_create(sites)
|
||||||
name='TestSite2',
|
|
||||||
slug='test-site-2'
|
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(
|
for location in locations:
|
||||||
name='TestGroup1',
|
location.save()
|
||||||
slug='test-group-1',
|
|
||||||
site=self.site1
|
Rack.objects.create(
|
||||||
)
|
name='Rack 1',
|
||||||
self.location2 = Location.objects.create(
|
|
||||||
name='TestGroup2',
|
|
||||||
slug='test-group-2',
|
|
||||||
site=self.site2
|
|
||||||
)
|
|
||||||
self.rack = Rack.objects.create(
|
|
||||||
name='TestRack1',
|
|
||||||
facility_id='A101',
|
facility_id='A101',
|
||||||
site=self.site1,
|
site=sites[0],
|
||||||
location=self.location1,
|
location=locations[0],
|
||||||
u_height=42
|
u_height=42
|
||||||
)
|
)
|
||||||
self.manufacturer = Manufacturer.objects.create(
|
|
||||||
name='Acme',
|
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||||
slug='acme'
|
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 = {
|
DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||||
'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',
|
|
||||||
),
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
def test_rack_device_outside_height(self):
|
def test_rack_device_outside_height(self):
|
||||||
|
site = Site.objects.first()
|
||||||
rack1 = Rack(
|
rack = Rack.objects.first()
|
||||||
name='TestRack2',
|
|
||||||
facility_id='A102',
|
|
||||||
site=self.site1,
|
|
||||||
u_height=42
|
|
||||||
)
|
|
||||||
rack1.save()
|
|
||||||
|
|
||||||
device1 = Device(
|
device1 = Device(
|
||||||
name='TestSwitch1',
|
name='Device 1',
|
||||||
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
device_type=DeviceType.objects.first(),
|
||||||
device_role=DeviceRole.objects.get(slug='switch'),
|
device_role=DeviceRole.objects.first(),
|
||||||
site=self.site1,
|
site=site,
|
||||||
rack=rack1,
|
rack=rack,
|
||||||
position=43,
|
position=43,
|
||||||
face=DeviceFaceChoices.FACE_FRONT,
|
face=DeviceFaceChoices.FACE_FRONT,
|
||||||
)
|
)
|
||||||
device1.save()
|
device1.save()
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
rack1.clean()
|
rack.clean()
|
||||||
|
|
||||||
def test_location_site(self):
|
def test_location_site(self):
|
||||||
|
site1 = Site.objects.get(name='Site 1')
|
||||||
|
location2 = Location.objects.get(name='Location 2')
|
||||||
|
|
||||||
rack_invalid_location = Rack(
|
rack2 = Rack(
|
||||||
name='TestRack2',
|
name='Rack 2',
|
||||||
facility_id='A102',
|
site=site1,
|
||||||
site=self.site1,
|
location=location2,
|
||||||
u_height=42,
|
u_height=42
|
||||||
location=self.location2
|
|
||||||
)
|
)
|
||||||
rack_invalid_location.save()
|
rack2.save()
|
||||||
|
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
rack_invalid_location.clean()
|
rack2.clean()
|
||||||
|
|
||||||
def test_mount_single_device(self):
|
def test_mount_single_device(self):
|
||||||
|
site = Site.objects.first()
|
||||||
|
rack = Rack.objects.first()
|
||||||
|
|
||||||
device1 = Device(
|
device1 = Device(
|
||||||
name='TestSwitch1',
|
name='TestSwitch1',
|
||||||
device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'),
|
device_type=DeviceType.objects.first(),
|
||||||
device_role=DeviceRole.objects.get(slug='switch'),
|
device_role=DeviceRole.objects.first(),
|
||||||
site=self.site1,
|
site=site,
|
||||||
rack=self.rack,
|
rack=rack,
|
||||||
position=10,
|
position=10.0,
|
||||||
face=DeviceFaceChoices.FACE_REAR,
|
face=DeviceFaceChoices.FACE_REAR,
|
||||||
)
|
)
|
||||||
device1.save()
|
device1.save()
|
||||||
|
|
||||||
# Validate rack height
|
# 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)
|
# Validate inventory (front face)
|
||||||
rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
|
rack1_inventory_front = {
|
||||||
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
|
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
|
||||||
del(rack1_inventory_front[-10])
|
}
|
||||||
for u in rack1_inventory_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'])
|
self.assertIsNone(u['device'])
|
||||||
|
|
||||||
# Validate inventory (rear face)
|
# Validate inventory (rear face)
|
||||||
rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
|
rack1_inventory_rear = {
|
||||||
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
|
u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
|
||||||
del(rack1_inventory_rear[-10])
|
}
|
||||||
for u in rack1_inventory_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'])
|
self.assertIsNone(u['device'])
|
||||||
|
|
||||||
def test_mount_zero_ru(self):
|
def test_mount_zero_ru(self):
|
||||||
pdu = Device.objects.create(
|
"""
|
||||||
name='TestPDU',
|
Check that a 0RU device can be mounted in a rack with no face/position.
|
||||||
device_role=self.role.get('PDU'),
|
"""
|
||||||
device_type=self.device_type.get('cc5000'),
|
site = Site.objects.first()
|
||||||
site=self.site1,
|
rack = Rack.objects.first()
|
||||||
rack=self.rack,
|
|
||||||
position=None,
|
Device(
|
||||||
face='',
|
name='Device 1',
|
||||||
)
|
device_role=DeviceRole.objects.first(),
|
||||||
self.assertTrue(pdu)
|
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):
|
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_a = Site.objects.create(name='Site A', slug='site-a')
|
||||||
site_b = Site.objects.create(name='Site B', slug='site-b')
|
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
|
# Create Rack1 in Site A
|
||||||
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
|
rack1 = Rack.objects.create(site=site_a, name='Rack 1')
|
||||||
|
|
||||||
# Create Device1 in Rack1
|
# 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
|
# Move Rack1 to Site B
|
||||||
rack1.site = site_b
|
rack1.site = site_b
|
||||||
|
@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView):
|
|||||||
queryset = RackRole.objects.all()
|
queryset = RackRole.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
racks = Rack.objects.restrict(request.user, 'view').filter(
|
racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
|
||||||
role=instance
|
device_count=count_related(Device, 'rack')
|
||||||
)
|
)
|
||||||
|
|
||||||
racks_table = tables.RackTable(racks, user=request.user, exclude=(
|
racks_table = tables.RackTable(racks, user=request.user, exclude=(
|
||||||
|
@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
|
|||||||
from extras.context_managers import change_logging
|
from extras.context_managers import change_logging
|
||||||
from extras.models import JobResult
|
from extras.models import JobResult
|
||||||
from extras.scripts import get_script
|
from extras.scripts import get_script
|
||||||
|
from extras.signals import clear_webhooks
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
from utilities.utils import NetBoxFakeRequest
|
from utilities.utils import NetBoxFakeRequest
|
||||||
|
|
||||||
@ -49,7 +50,7 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
script.log_info("Database changes have been reverted automatically.")
|
script.log_info("Database changes have been reverted automatically.")
|
||||||
|
clear_webhooks.send(request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
script.log_failure(
|
script.log_failure(
|
||||||
@ -58,7 +59,7 @@ class Command(BaseCommand):
|
|||||||
script.log_info("Database changes have been reverted due to error.")
|
script.log_info("Database changes have been reverted due to error.")
|
||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
clear_webhooks.send(request)
|
||||||
finally:
|
finally:
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
job_result.save()
|
job_result.save()
|
||||||
|
@ -17,6 +17,7 @@ from django.utils.functional import classproperty
|
|||||||
|
|
||||||
from extras.api.serializers import ScriptOutputSerializer
|
from extras.api.serializers import ScriptOutputSerializer
|
||||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||||
|
from extras.signals import clear_webhooks
|
||||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||||
from utilities.exceptions import AbortTransaction
|
from utilities.exceptions import AbortTransaction
|
||||||
@ -465,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
|
|
||||||
except AbortTransaction:
|
except AbortTransaction:
|
||||||
script.log_info("Database changes have been reverted automatically.")
|
script.log_info("Database changes have been reverted automatically.")
|
||||||
|
clear_webhooks.send(request)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
stacktrace = traceback.format_exc()
|
stacktrace = traceback.format_exc()
|
||||||
script.log_failure(
|
script.log_failure(
|
||||||
@ -474,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
|||||||
script.log_info("Database changes have been reverted due to error.")
|
script.log_info("Database changes have been reverted due to error.")
|
||||||
logger.error(f"Exception raised during script execution: {e}")
|
logger.error(f"Exception raised during script execution: {e}")
|
||||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||||
|
clear_webhooks.send(request)
|
||||||
finally:
|
finally:
|
||||||
job_result.data = ScriptOutputSerializer(script).data
|
job_result.data = ScriptOutputSerializer(script).data
|
||||||
job_result.save()
|
job_result.save()
|
||||||
|
@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
field_name='address',
|
field_name='address',
|
||||||
lookup_expr='family'
|
lookup_expr='family'
|
||||||
)
|
)
|
||||||
parent = django_filters.CharFilter(
|
parent = MultiValueCharFilter(
|
||||||
method='search_by_parent',
|
method='search_by_parent',
|
||||||
label='Parent prefix',
|
label='Parent prefix',
|
||||||
)
|
)
|
||||||
@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
|||||||
return queryset.filter(qs_filter)
|
return queryset.filter(qs_filter)
|
||||||
|
|
||||||
def search_by_parent(self, queryset, name, value):
|
def search_by_parent(self, queryset, name, value):
|
||||||
value = value.strip()
|
|
||||||
if not value:
|
if not value:
|
||||||
return queryset
|
return queryset
|
||||||
try:
|
q = Q()
|
||||||
query = str(netaddr.IPNetwork(value.strip()).cidr)
|
for prefix in value:
|
||||||
return queryset.filter(address__net_host_contained=query)
|
try:
|
||||||
except (AddrFormatError, ValueError):
|
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
|
||||||
return queryset.none()
|
q |= Q(address__net_host_contained=query)
|
||||||
|
except (AddrFormatError, ValueError):
|
||||||
|
return queryset.none()
|
||||||
|
return queryset.filter(q)
|
||||||
|
|
||||||
def filter_address(self, queryset, name, value):
|
def filter_address(self, queryset, name, value):
|
||||||
try:
|
try:
|
||||||
|
@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
|
|||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
ports = tables.Column(
|
ports = tables.Column(
|
||||||
accessor=tables.A('port_list')
|
accessor=tables.A('port_list'),
|
||||||
|
order_by=tables.A('ports'),
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:servicetemplate_list'
|
url_name='ipam:servicetemplate_list'
|
||||||
@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
|
|||||||
order_by=('device', 'virtual_machine')
|
order_by=('device', 'virtual_machine')
|
||||||
)
|
)
|
||||||
ports = tables.Column(
|
ports = tables.Column(
|
||||||
accessor=tables.A('port_list')
|
accessor=tables.A('port_list'),
|
||||||
|
order_by=tables.A('ports'),
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='ipam:service_list'
|
url_name='ipam:service_list'
|
||||||
|
@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_parent(self):
|
def test_parent(self):
|
||||||
params = {'parent': '10.0.0.0/24'}
|
params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||||
params = {'parent': '2001:db8::/64'}
|
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
|
||||||
|
|
||||||
def test_filter_address(self):
|
def test_filter_address(self):
|
||||||
# Check IPv4 and IPv6, with and without a mask
|
# Check IPv4 and IPv6, with and without a mask
|
||||||
|
@ -7,12 +7,12 @@ from django.urls import reverse
|
|||||||
from circuits.models import Provider, Circuit
|
from circuits.models import Provider, Circuit
|
||||||
from circuits.tables import ProviderTable
|
from circuits.tables import ProviderTable
|
||||||
from dcim.filtersets import InterfaceFilterSet
|
from dcim.filtersets import InterfaceFilterSet
|
||||||
from dcim.models import Interface, Site
|
from dcim.models import Interface, Site, Device
|
||||||
from dcim.tables import SiteTable
|
from dcim.tables import SiteTable
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from virtualization.filtersets import VMInterfaceFilterSet
|
from virtualization.filtersets import VMInterfaceFilterSet
|
||||||
from virtualization.models import VMInterface
|
from virtualization.models import VMInterface, VirtualMachine
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .constants import *
|
from .constants import *
|
||||||
from .models import *
|
from .models import *
|
||||||
@ -676,7 +676,19 @@ class IPAddressView(generic.ObjectView):
|
|||||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||||
related_ips_table.configure(request)
|
related_ips_table.configure(request)
|
||||||
|
|
||||||
services = Service.objects.restrict(request.user, 'view').filter(ipaddresses=instance)
|
# 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 {
|
return {
|
||||||
'parent_prefixes_table': parent_prefixes_table,
|
'parent_prefixes_table': parent_prefixes_table,
|
||||||
|
@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
def paginate_queryset(self, queryset, request, view=None):
|
def paginate_queryset(self, queryset, request, view=None):
|
||||||
|
|
||||||
if isinstance(queryset, QuerySet):
|
if isinstance(queryset, QuerySet):
|
||||||
self.count = queryset.count()
|
self.count = self.get_queryset_count(queryset)
|
||||||
else:
|
else:
|
||||||
# We're dealing with an iterable, not a QuerySet
|
# We're dealing with an iterable, not a QuerySet
|
||||||
self.count = len(queryset)
|
self.count = len(queryset)
|
||||||
@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
|
|
||||||
return self.default_limit
|
return self.default_limit
|
||||||
|
|
||||||
|
def get_queryset_count(self, queryset):
|
||||||
|
return queryset.count()
|
||||||
|
|
||||||
def get_next_link(self):
|
def get_next_link(self):
|
||||||
|
|
||||||
# Pagination has been disabled
|
# Pagination has been disabled
|
||||||
@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
return super().get_previous_link()
|
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()
|
||||||
|
@ -36,3 +36,8 @@ REDIS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||||
|
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': True
|
||||||
|
}
|
||||||
|
@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
|||||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||||
|
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||||
@ -423,6 +424,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
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
|
# 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.
|
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
|
||||||
EXEMPT_EXCLUDE_MODELS = (
|
EXEMPT_EXCLUDE_MODELS = (
|
||||||
|
@ -166,6 +166,7 @@ class ActionsItem:
|
|||||||
title: str
|
title: str
|
||||||
icon: str
|
icon: str
|
||||||
permission: Optional[str] = None
|
permission: Optional[str] = None
|
||||||
|
css_class: Optional[str] = 'secondary'
|
||||||
|
|
||||||
|
|
||||||
class ActionsColumn(tables.Column):
|
class ActionsColumn(tables.Column):
|
||||||
@ -175,19 +176,22 @@ class ActionsColumn(tables.Column):
|
|||||||
|
|
||||||
:param actions: The ordered list of dropdown menu items to include
|
: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 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'}}
|
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||||
empty_values = ()
|
empty_values = ()
|
||||||
actions = {
|
actions = {
|
||||||
'edit': ActionsItem('Edit', 'pencil', 'change'),
|
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
||||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
|
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
|
||||||
'changelog': ActionsItem('Changelog', 'history'),
|
'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)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
self.extra_buttons = extra_buttons
|
self.extra_buttons = extra_buttons
|
||||||
|
self.split_actions = split_actions
|
||||||
|
|
||||||
# Determine which actions to enable
|
# Determine which actions to enable
|
||||||
self.actions = {
|
self.actions = {
|
||||||
@ -208,22 +212,49 @@ class ActionsColumn(tables.Column):
|
|||||||
html = ''
|
html = ''
|
||||||
|
|
||||||
# Compile actions menu
|
# Compile actions menu
|
||||||
links = []
|
button = None
|
||||||
|
dropdown_class = 'secondary'
|
||||||
|
dropdown_links = []
|
||||||
user = getattr(request, 'user', AnonymousUser())
|
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}'
|
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||||
if attrs.permission is None or user.has_perm(permission):
|
if attrs.permission is None or user.has_perm(permission):
|
||||||
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
||||||
links.append(
|
|
||||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
# Render a separate button if a) only one action exists, or b) if split_actions is True
|
||||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
if len(self.actions) == 1 or (self.split_actions and idx == 0):
|
||||||
)
|
dropdown_class = attrs.css_class
|
||||||
if links:
|
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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 += (
|
html += (
|
||||||
f'<span class="dropdown">'
|
f'<span class="btn-group dropdown">'
|
||||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
|
f' {button}'
|
||||||
f'<i class="mdi mdi-wrench"></i></a>'
|
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
||||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
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
|
# Render any extra buttons from template code
|
||||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
|
|||||||
import { initMoveButtons } from './moveOptions';
|
import { initMoveButtons } from './moveOptions';
|
||||||
import { initReslug } from './reslug';
|
import { initReslug } from './reslug';
|
||||||
import { initSelectAll } from './selectAll';
|
import { initSelectAll } from './selectAll';
|
||||||
|
import { initSelectMultiple } from './selectMultiple';
|
||||||
|
|
||||||
export function initButtons(): void {
|
export function initButtons(): void {
|
||||||
for (const func of [
|
for (const func of [
|
||||||
@ -10,6 +11,7 @@ export function initButtons(): void {
|
|||||||
initConnectionToggle,
|
initConnectionToggle,
|
||||||
initReslug,
|
initReslug,
|
||||||
initSelectAll,
|
initSelectAll,
|
||||||
|
initSelectMultiple,
|
||||||
initMoveButtons,
|
initMoveButtons,
|
||||||
]) {
|
]) {
|
||||||
func();
|
func();
|
||||||
|
105
netbox/project-static/src/buttons/selectMultiple.ts
Normal file
105
netbox/project-static/src/buttons/selectMultiple.ts
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { getElements } from '../util';
|
||||||
|
import { StateManager } from 'src/state';
|
||||||
|
import { previousPkCheckState } from '../stores';
|
||||||
|
|
||||||
|
type PreviousPkCheckState = { element: Nullable<HTMLInputElement> };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If there is a text selection, removes it.
|
||||||
|
*/
|
||||||
|
function removeTextSelection(): void {
|
||||||
|
window.getSelection()?.removeAllRanges();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the state object passed in to the eventTargetElement object passed in.
|
||||||
|
*
|
||||||
|
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||||
|
* event passed in from handlePkCheck()
|
||||||
|
* @param state PreviousPkCheckState object.
|
||||||
|
*/
|
||||||
|
function updatePreviousPkCheckState(
|
||||||
|
eventTargetElement: HTMLInputElement,
|
||||||
|
state: StateManager<PreviousPkCheckState>,
|
||||||
|
): void {
|
||||||
|
state.set('element', eventTargetElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For all checkboxes between eventTargetElement and previousStateElement in elementList, toggle
|
||||||
|
* "checked" value to eventTargetElement.checked
|
||||||
|
*
|
||||||
|
* @param eventTargetElement HTML Input Element, retrieved from getting the target of the
|
||||||
|
* event passed in from handlePkCheck()
|
||||||
|
* @param state PreviousPkCheckState object.
|
||||||
|
*/
|
||||||
|
function toggleCheckboxRange(
|
||||||
|
eventTargetElement: HTMLInputElement,
|
||||||
|
previousStateElement: HTMLInputElement,
|
||||||
|
elementList: Generator,
|
||||||
|
): void {
|
||||||
|
let changePkCheckboxState = false;
|
||||||
|
for (const element of elementList) {
|
||||||
|
const typedElement = element as HTMLInputElement;
|
||||||
|
//Change loop's current checkbox state to eventTargetElement checkbox state
|
||||||
|
if (changePkCheckboxState === true) {
|
||||||
|
typedElement.checked = eventTargetElement.checked;
|
||||||
|
}
|
||||||
|
//The previously clicked checkbox was above the shift clicked checkbox
|
||||||
|
if (element === previousStateElement) {
|
||||||
|
if (changePkCheckboxState === true) {
|
||||||
|
changePkCheckboxState = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePkCheckboxState = true;
|
||||||
|
typedElement.checked = eventTargetElement.checked;
|
||||||
|
}
|
||||||
|
//The previously clicked checkbox was below the shift clicked checkbox
|
||||||
|
if (element === eventTargetElement) {
|
||||||
|
if (changePkCheckboxState === true) {
|
||||||
|
changePkCheckboxState = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
changePkCheckboxState = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IF the shift key is pressed and there is state is not null, toggleCheckboxRange between the
|
||||||
|
* event target element and the state element.
|
||||||
|
*
|
||||||
|
* @param event Mouse event.
|
||||||
|
* @param state PreviousPkCheckState object.
|
||||||
|
*/
|
||||||
|
function handlePkCheck(event: MouseEvent, state: StateManager<PreviousPkCheckState>): void {
|
||||||
|
const eventTargetElement = event.target as HTMLInputElement;
|
||||||
|
const previousStateElement = state.get('element');
|
||||||
|
updatePreviousPkCheckState(eventTargetElement, state);
|
||||||
|
//Stop if user is not holding shift key
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeTextSelection();
|
||||||
|
//If no previous state, store event target element as previous state and return
|
||||||
|
if (previousStateElement === null) {
|
||||||
|
return updatePreviousPkCheckState(eventTargetElement, state);
|
||||||
|
}
|
||||||
|
const checkboxList = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||||
|
toggleCheckboxRange(eventTargetElement, previousStateElement, checkboxList);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize table select all elements.
|
||||||
|
*/
|
||||||
|
export function initSelectMultiple(): void {
|
||||||
|
const checkboxElements = getElements<HTMLInputElement>('input[type="checkbox"][name="pk"]');
|
||||||
|
for (const element of checkboxElements) {
|
||||||
|
element.addEventListener('click', event => {
|
||||||
|
removeTextSelection();
|
||||||
|
//Stop propogation to avoid event firing multiple times
|
||||||
|
event.stopPropagation();
|
||||||
|
handlePkCheck(event, previousPkCheckState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -205,6 +205,11 @@ export class APISelect {
|
|||||||
onChange: () => this.handleSlimChange(),
|
onChange: () => this.handleSlimChange(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't close on select if multiple select
|
||||||
|
if (this.base.multiple) {
|
||||||
|
this.slim.config.closeOnSelect = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize API query properties.
|
// Initialize API query properties.
|
||||||
this.getStaticParams();
|
this.getStaticParams();
|
||||||
this.getDynamicParams();
|
this.getDynamicParams();
|
||||||
|
@ -1,2 +1,3 @@
|
|||||||
export * from './objectDepth';
|
export * from './objectDepth';
|
||||||
export * from './rackImages';
|
export * from './rackImages';
|
||||||
|
export * from './previousPkCheck';
|
||||||
|
6
netbox/project-static/src/stores/previousPkCheck.ts
Normal file
6
netbox/project-static/src/stores/previousPkCheck.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createState } from '../state';
|
||||||
|
|
||||||
|
export const previousPkCheckState = createState<{ element: Nullable<HTMLInputElement> }>(
|
||||||
|
{ element: null },
|
||||||
|
{ persist: false },
|
||||||
|
);
|
@ -10,7 +10,7 @@
|
|||||||
{% if termination_a %}
|
{% if termination_a %}
|
||||||
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
|
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
@ -18,7 +18,7 @@
|
|||||||
{% if termination_z %}
|
{% if termination_z %}
|
||||||
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
|
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -93,7 +93,7 @@
|
|||||||
{% elif termination.port_speed %}
|
{% elif termination.port_speed %}
|
||||||
{{ termination.port_speed|humanize_speed }}
|
{{ termination.port_speed|humanize_speed }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -50,7 +50,7 @@
|
|||||||
{% if object.portal_url %}
|
{% if object.portal_url %}
|
||||||
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
|
<a href="{{ object.portal_url }}">{{ object.portal_url }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -38,7 +38,7 @@
|
|||||||
{% if object.color %}
|
{% if object.color %}
|
||||||
<span class="color-label" style="background-color: #{{ object.color }}"> </span>
|
<span class="color-label" style="background-color: #{{ object.color }}"> </span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -48,7 +48,7 @@
|
|||||||
{% if object.length %}
|
{% if object.length %}
|
||||||
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
|
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -23,7 +23,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ object.site.region|linkify }}
|
{{ object.site.region|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -40,7 +40,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ object.location|linkify }}
|
{{ object.location|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -50,7 +50,7 @@
|
|||||||
{% if object.rack %}
|
{% if object.rack %}
|
||||||
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
|
<a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -69,7 +69,7 @@
|
|||||||
{% elif object.rack and object.device_type.u_height %}
|
{% elif object.rack and object.device_type.u_height %}
|
||||||
<span class="badge bg-warning">Not racked</span>
|
<span class="badge bg-warning">Not racked</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -180,7 +180,7 @@
|
|||||||
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
|
(NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -195,7 +195,7 @@
|
|||||||
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
|
(NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -54,7 +54,7 @@
|
|||||||
{% if object.vm_role %}
|
{% if object.vm_role %}
|
||||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
|
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -55,7 +55,7 @@
|
|||||||
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
|
<img src="{{ object.front_image.url }}" alt="{{ object.front_image.name }}" class="img-fluid" />
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -67,7 +67,7 @@
|
|||||||
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
|
<img src="{{ object.rear_image.url }}" alt="{{ object.rear_image.name }}" class="img-fluid" />
|
||||||
</a>
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -313,7 +313,7 @@
|
|||||||
{% if object.rf_channel_frequency %}
|
{% if object.rf_channel_frequency %}
|
||||||
{{ object.rf_channel_frequency|simplify_decimal }} MHz
|
{{ object.rf_channel_frequency|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if peer %}
|
{% if peer %}
|
||||||
@ -321,7 +321,7 @@
|
|||||||
{% if peer.rf_channel_frequency %}
|
{% if peer.rf_channel_frequency %}
|
||||||
{{ peer.rf_channel_frequency|simplify_decimal }} MHz
|
{{ peer.rf_channel_frequency|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@ -332,7 +332,7 @@
|
|||||||
{% if object.rf_channel_width %}
|
{% if object.rf_channel_width %}
|
||||||
{{ object.rf_channel_width|simplify_decimal }} MHz
|
{{ object.rf_channel_width|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% if peer %}
|
{% if peer %}
|
||||||
@ -340,7 +340,7 @@
|
|||||||
{% if peer.rf_channel_width %}
|
{% if peer.rf_channel_width %}
|
||||||
{{ peer.rf_channel_width|simplify_decimal }} MHz
|
{{ peer.rf_channel_width|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -18,25 +18,25 @@
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu" aria-labeled-by="add-components">
|
<ul class="dropdown-menu" aria-labeled-by="add-components">
|
||||||
{% if perms.dcim.add_consoleport %}
|
{% if perms.dcim.add_consoleport %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.device.pk %}">Console Ports</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_consoleserverport %}
|
{% if perms.dcim.add_consoleserverport %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.device.pk %}">Console Server Ports</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_powerport %}
|
{% if perms.dcim.add_powerport %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Ports</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.device.pk %}">Power Ports</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_poweroutlet %}
|
{% if perms.dcim.add_poweroutlet %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.device.pk %}">Power Outlets</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_interface %}
|
{% if perms.dcim.add_interface %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.device.pk %}">Interfaces</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_frontport %}
|
{% if perms.dcim.add_frontport %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Ports</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.device.pk %}">Front Ports</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if perms.dcim.add_rearport %}
|
{% if perms.dcim.add_rearport %}
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.device.pk %}">Rear Ports</a></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
{% if object.connected_endpoint %}
|
{% if object.connected_endpoint %}
|
||||||
{{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
|
{{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }})
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Power Port</th>
|
<th scope="row">Power Port</th>
|
||||||
<td>{{ object.power_port }}</td>
|
<td>{{ object.power_port|linkify|placeholder }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">Feed Leg</th>
|
<th scope="row">Feed Leg</th>
|
||||||
|
@ -53,7 +53,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ object.location|linkify }}
|
{{ object.location|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -115,7 +115,7 @@
|
|||||||
{% if object.type %}
|
{% if object.type %}
|
||||||
{{ object.get_type_display }}
|
{{ object.get_type_display }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -133,7 +133,7 @@
|
|||||||
{% if object.outer_width %}
|
{% if object.outer_width %}
|
||||||
<span>{{ object.outer_width }} {{ object.get_outer_unit_display }}</span>
|
<span>{{ object.outer_width }} {{ object.get_outer_unit_display }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -143,7 +143,7 @@
|
|||||||
{% if object.outer_depth %}
|
{% if object.outer_depth %}
|
||||||
<span>{{ object.outer_depth }} {{ object.get_outer_unit_display }}</span>
|
<span>{{ object.outer_depth }} {{ object.get_outer_unit_display }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -34,7 +34,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ object.region|linkify }}
|
{{ object.region|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -47,7 +47,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
{{ object.group|linkify }}
|
{{ object.group|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -79,7 +79,7 @@
|
|||||||
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
|
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
|
||||||
<small class="text-muted">Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}</small>
|
<small class="text-muted">Site time: {% timezone object.time_zone %}{% annotated_now %}{% endtimezone %}</small>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -94,7 +94,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -113,7 +113,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
<span>{{ object.latitude }}, {{ object.longitude }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
{% if device.rack %}
|
{% if device.rack %}
|
||||||
{{ device.rack }} / {{ device.position }}
|
{{ device.rack }} / {{ device.position }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ device.serial|placeholder }}</td>
|
<td>{{ device.serial|placeholder }}</td>
|
||||||
|
@ -69,7 +69,7 @@
|
|||||||
{% if object.choices %}
|
{% if object.choices %}
|
||||||
{{ object.choices|join:", " }}
|
{{ object.choices|join:", " }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -113,7 +113,7 @@
|
|||||||
{% if object.validation_regex %}
|
{% if object.validation_regex %}
|
||||||
<code>{{ object.validation_regex }}</code>
|
<code>{{ object.validation_regex }}</code>
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -57,7 +57,7 @@
|
|||||||
{% elif obj %}
|
{% elif obj %}
|
||||||
{{ obj }}
|
{{ obj }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="rendered-markdown">{{ message|markdown }}</td>
|
<td class="rendered-markdown">{{ message|markdown }}</td>
|
||||||
|
@ -76,14 +76,14 @@ Context:
|
|||||||
{% if field.required %}
|
{% if field.required %}
|
||||||
{% checkmark True true="Required" %}
|
{% checkmark True true="Required" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if field.to_field_name %}
|
{% if field.to_field_name %}
|
||||||
<code>{{ field.to_field_name }}</code>
|
<code>{{ field.to_field_name }}</code>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -52,7 +52,7 @@
|
|||||||
{% if object.role %}
|
{% if object.role %}
|
||||||
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
|
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -73,7 +73,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{{ object.assigned_object|linkify }}
|
{{ object.assigned_object|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -86,7 +86,7 @@
|
|||||||
({{ object.nat_inside.assigned_object.parent_object|linkify }})
|
({{ object.nat_inside.assigned_object.parent_object|linkify }})
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -39,7 +39,7 @@
|
|||||||
{% if aggregate %}
|
{% if aggregate %}
|
||||||
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
|
<a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-warning">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -52,7 +52,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{{ object.site|linkify }}
|
{{ object.site|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -65,7 +65,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{{ object.vlan|linkify }}
|
{{ object.vlan|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -138,7 +138,7 @@
|
|||||||
{{ first_available_ip }}
|
{{ first_available_ip }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
|
@ -45,7 +45,7 @@
|
|||||||
{% if ipranges_count %}
|
{% if ipranges_count %}
|
||||||
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
|
<a href="{% url 'ipam:iprange_list' %}?role_id={{ object.pk }}">{{ ipranges_count }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
@ -57,7 +57,7 @@
|
|||||||
{% if vlans_count %}
|
{% if vlans_count %}
|
||||||
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a>
|
<a href="{% url 'ipam:vlan_list' %}?role_id={{ object.pk }}">{{ vlans_count }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
—
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</td>
|
</td>
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
{% for ipaddress in object.ipaddresses.all %}
|
{% for ipaddress in object.ipaddresses.all %}
|
||||||
{{ ipaddress|linkify }}<br />
|
{{ ipaddress|linkify }}<br />
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{{ object.site|linkify }}
|
{{ object.site|linkify }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -56,7 +56,7 @@
|
|||||||
{% if object.role %}
|
{% if object.role %}
|
||||||
<a href="{% url 'ipam:vlan_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
|
<a href="{% url 'ipam:vlan_list' %}?role={{ object.role.slug }}">{{ object.role }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
{% if object.phone %}
|
{% if object.phone %}
|
||||||
<a href="tel:{{ object.phone }}">{{ object.phone }}</a>
|
<a href="tel:{{ object.phone }}">{{ object.phone }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -45,7 +45,7 @@
|
|||||||
{% if object.email %}
|
{% if object.email %}
|
||||||
<a href="mailto:{{ object.email }}">{{ object.email }}</a>
|
<a href="mailto:{{ object.email }}">{{ object.email }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">None</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -77,6 +77,10 @@
|
|||||||
<h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
|
<h2><a href="{% url 'ipam:prefix_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2>
|
||||||
<p>Prefixes</p>
|
<p>Prefixes</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col col-md-4 text-center">
|
||||||
|
<h2><a href="{% url 'ipam:iprange_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.iprange_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.iprange_count }}</a></h2>
|
||||||
|
<p>IP Ranges</p>
|
||||||
|
</div>
|
||||||
<div class="col col-md-4 text-center">
|
<div class="col col-md-4 text-center">
|
||||||
<h2><a href="{% url 'ipam:ipaddress_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.ipaddress_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.ipaddress_count }}</a></h2>
|
<h2><a href="{% url 'ipam:ipaddress_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.ipaddress_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.ipaddress_count }}</a></h2>
|
||||||
<p>IP addresses</p>
|
<p>IP addresses</p>
|
||||||
|
@ -21,7 +21,7 @@
|
|||||||
{% if request.user.first_name or request.user.last_name %}
|
{% if request.user.first_name or request.user.last_name %}
|
||||||
{{ request.user.first_name }} {{ request.user.last_name }}
|
{{ request.user.first_name }} {{ request.user.last_name }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -49,7 +49,7 @@
|
|||||||
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
|
(NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -64,7 +64,7 @@
|
|||||||
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
|
(NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -123,7 +123,7 @@
|
|||||||
{% if object.memory %}
|
{% if object.memory %}
|
||||||
{{ object.memory|humanize_megabytes }}
|
{{ object.memory|humanize_megabytes }}
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -133,7 +133,7 @@
|
|||||||
{% if object.disk %}
|
{% if object.disk %}
|
||||||
{{ object.disk }} GB
|
{{ object.disk }} GB
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
{% if interface.rf_channel_frequency %}
|
{% if interface.rf_channel_frequency %}
|
||||||
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
|
{{ interface.rf_channel_frequency|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -43,7 +43,7 @@
|
|||||||
{% if interface.rf_channel_width %}
|
{% if interface.rf_channel_width %}
|
||||||
{{ interface.rf_channel_width|simplify_decimal }} MHz
|
{{ interface.rf_channel_width|simplify_decimal }} MHz
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-muted">—</span>
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
@ -18,7 +18,7 @@ class ContactGroupTable(NetBoxTable):
|
|||||||
)
|
)
|
||||||
contact_count = columns.LinkedCountColumn(
|
contact_count = columns.LinkedCountColumn(
|
||||||
viewname='tenancy:contact_list',
|
viewname='tenancy:contact_list',
|
||||||
url_params={'role_id': 'pk'},
|
url_params={'group_id': 'pk'},
|
||||||
verbose_name='Contacts'
|
verbose_name='Contacts'
|
||||||
)
|
)
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
|
@ -3,7 +3,7 @@ from django.shortcuts import get_object_or_404
|
|||||||
|
|
||||||
from circuits.models import Circuit
|
from circuits.models import Circuit
|
||||||
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
|
from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
|
||||||
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN
|
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from virtualization.models import VirtualMachine, Cluster
|
from virtualization.models import VirtualMachine, Cluster
|
||||||
@ -104,8 +104,9 @@ class TenantView(generic.ObjectView):
|
|||||||
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'location_count': Location.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'device_count': Device.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'vrf_count': VRF.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
|
||||||
'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'aggregate_count': Aggregate.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
|
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
|
'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
|
||||||
|
@ -3,6 +3,7 @@ import json
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.db.models import Count
|
from django.db.models import Count
|
||||||
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
from django.forms.fields import JSONField as _JSONField, InvalidJSONInput
|
||||||
|
from django.templatetags.static import static
|
||||||
from netaddr import AddrFormatError, EUI
|
from netaddr import AddrFormatError, EUI
|
||||||
|
|
||||||
from utilities.forms import widgets
|
from utilities.forms import widgets
|
||||||
@ -26,10 +27,9 @@ class CommentField(forms.CharField):
|
|||||||
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
|
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
|
||||||
"""
|
"""
|
||||||
widget = forms.Textarea
|
widget = forms.Textarea
|
||||||
# TODO: Port Markdown cheat sheet to internal documentation
|
help_text = f"""
|
||||||
help_text = """
|
|
||||||
<i class="mdi mdi-information-outline"></i>
|
<i class="mdi mdi-information-outline"></i>
|
||||||
<a href="https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet" target="_blank" tabindex="-1">
|
<a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">
|
||||||
Markdown</a> syntax is supported
|
Markdown</a> syntax is supported
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.conf import settings
|
|
||||||
from django.forms.models import fields_for_model
|
from django.forms.models import fields_for_model
|
||||||
|
|
||||||
from utilities.choices import unpack_grouped_choices
|
from utilities.choices import unpack_grouped_choices
|
||||||
|
@ -11,7 +11,7 @@ from markdown import markdown
|
|||||||
|
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from utilities.markdown import StrikethroughExtension
|
from utilities.markdown import StrikethroughExtension
|
||||||
from utilities.utils import foreground_color
|
from utilities.utils import clean_html, foreground_color
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@ -144,18 +144,6 @@ def render_markdown(value):
|
|||||||
|
|
||||||
{{ md_source_text|markdown }}
|
{{ md_source_text|markdown }}
|
||||||
"""
|
"""
|
||||||
schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES)
|
|
||||||
|
|
||||||
# Strip HTML tags
|
|
||||||
value = strip_tags(value)
|
|
||||||
|
|
||||||
# Sanitize Markdown links
|
|
||||||
pattern = fr'\[([^\]]+)\]\(\s*(?!({schemes})).*:(.+)\)'
|
|
||||||
value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE)
|
|
||||||
|
|
||||||
# Sanitize Markdown reference links
|
|
||||||
pattern = fr'\[([^\]]+)\]:\s*(?!({schemes}))\w*:(.+)'
|
|
||||||
value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE)
|
|
||||||
|
|
||||||
# Render Markdown
|
# Render Markdown
|
||||||
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
|
html = markdown(value, extensions=['def_list', 'fenced_code', 'tables', StrikethroughExtension()])
|
||||||
@ -164,6 +152,11 @@ def render_markdown(value):
|
|||||||
if html:
|
if html:
|
||||||
html = f'<div class="rendered-markdown">{html}</div>'
|
html = f'<div class="rendered-markdown">{html}</div>'
|
||||||
|
|
||||||
|
schemes = get_config().ALLOWED_URL_SCHEMES
|
||||||
|
|
||||||
|
# Sanitize HTML
|
||||||
|
html = clean_html(html, schemes)
|
||||||
|
|
||||||
return mark_safe(html)
|
return mark_safe(html)
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
import decimal
|
||||||
import json
|
import json
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from itertools import count, groupby
|
from itertools import count, groupby
|
||||||
|
|
||||||
|
import bleach
|
||||||
from django.core.serializers import serialize
|
from django.core.serializers import serialize
|
||||||
from django.db.models import Count, OuterRef, Subquery
|
from django.db.models import Count, OuterRef, Subquery
|
||||||
from django.db.models.functions import Coalesce
|
from django.db.models.functions import Coalesce
|
||||||
@ -14,6 +16,7 @@ from mptt.models import MPTTModel
|
|||||||
from dcim.choices import CableLengthUnitChoices
|
from dcim.choices import CableLengthUnitChoices
|
||||||
from extras.plugins import PluginConfig
|
from extras.plugins import PluginConfig
|
||||||
from extras.utils import is_taggable
|
from extras.utils import is_taggable
|
||||||
|
from netbox.config import get_config
|
||||||
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
|
from utilities.constants import HTTP_REQUEST_META_SAFE_COPY
|
||||||
|
|
||||||
|
|
||||||
@ -224,6 +227,21 @@ def deepmerge(original, new):
|
|||||||
return merged
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def drange(start, end, step=decimal.Decimal(1)):
|
||||||
|
"""
|
||||||
|
Decimal-compatible implementation of Python's range()
|
||||||
|
"""
|
||||||
|
start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step)
|
||||||
|
if start < end:
|
||||||
|
while start < end:
|
||||||
|
yield start
|
||||||
|
start += step
|
||||||
|
else:
|
||||||
|
while start > end:
|
||||||
|
yield start
|
||||||
|
start += step
|
||||||
|
|
||||||
|
|
||||||
def to_meters(length, unit):
|
def to_meters(length, unit):
|
||||||
"""
|
"""
|
||||||
Convert the given length to meters.
|
Convert the given length to meters.
|
||||||
@ -257,7 +275,9 @@ def render_jinja2(template_code, context):
|
|||||||
"""
|
"""
|
||||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||||
"""
|
"""
|
||||||
return SandboxedEnvironment().from_string(source=template_code).render(**context)
|
environment = SandboxedEnvironment()
|
||||||
|
environment.filters.update(get_config().JINJA2_FILTERS)
|
||||||
|
return environment.from_string(source=template_code).render(**context)
|
||||||
|
|
||||||
|
|
||||||
def prepare_cloned_fields(instance):
|
def prepare_cloned_fields(instance):
|
||||||
@ -382,3 +402,33 @@ def copy_safe_request(request):
|
|||||||
'path': request.path,
|
'path': request.path,
|
||||||
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
'id': getattr(request, 'id', None), # UUID assigned by middleware
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def clean_html(html, schemes):
|
||||||
|
"""
|
||||||
|
Sanitizes HTML based on a whitelist of allowed tags and attributes.
|
||||||
|
Also takes a list of allowed URI schemes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ALLOWED_TAGS = [
|
||||||
|
"div", "pre", "code", "blockquote", "del",
|
||||||
|
"hr", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"ul", "ol", "li", "p", "br",
|
||||||
|
"strong", "em", "a", "b", "i", "img",
|
||||||
|
"table", "thead", "tbody", "tr", "th", "td",
|
||||||
|
"dl", "dt", "dd",
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_ATTRIBUTES = {
|
||||||
|
"div": ['class'],
|
||||||
|
"h1": ["id"], "h2": ["id"], "h3": ["id"], "h4": ["id"], "h5": ["id"], "h6": ["id"],
|
||||||
|
"a": ["href", "title"],
|
||||||
|
"img": ["src", "title", "alt"],
|
||||||
|
}
|
||||||
|
|
||||||
|
return bleach.clean(
|
||||||
|
html,
|
||||||
|
tags=ALLOWED_TAGS,
|
||||||
|
attributes=ALLOWED_ATTRIBUTES,
|
||||||
|
protocols=schemes
|
||||||
|
)
|
||||||
|
@ -3,7 +3,7 @@ from django import forms
|
|||||||
from dcim.choices import InterfaceModeChoices
|
from dcim.choices import InterfaceModeChoices
|
||||||
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
|
||||||
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup
|
||||||
from ipam.models import VLAN, VRF
|
from ipam.models import VLAN, VLANGroup, VRF
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
@ -202,13 +202,26 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect()
|
widget=StaticSelect()
|
||||||
)
|
)
|
||||||
|
vlan_group = DynamicModelChoiceField(
|
||||||
|
queryset=VLANGroup.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label='VLAN group'
|
||||||
|
)
|
||||||
untagged_vlan = DynamicModelChoiceField(
|
untagged_vlan = DynamicModelChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'group_id': '$vlan_group',
|
||||||
|
},
|
||||||
|
label='Untagged VLAN'
|
||||||
)
|
)
|
||||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||||
queryset=VLAN.objects.all(),
|
queryset=VLAN.objects.all(),
|
||||||
required=False
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'group_id': '$vlan_group',
|
||||||
|
},
|
||||||
|
label='Tagged VLANs'
|
||||||
)
|
)
|
||||||
vrf = DynamicModelChoiceField(
|
vrf = DynamicModelChoiceField(
|
||||||
queryset=VRF.objects.all(),
|
queryset=VRF.objects.all(),
|
||||||
@ -220,7 +233,7 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
fieldsets = (
|
fieldsets = (
|
||||||
(None, ('mtu', 'enabled', 'vrf', 'description')),
|
(None, ('mtu', 'enabled', 'vrf', 'description')),
|
||||||
('Related Interfaces', ('parent', 'bridge')),
|
('Related Interfaces', ('parent', 'bridge')),
|
||||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||||
)
|
)
|
||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'parent', 'bridge', 'mtu', 'vrf', 'description',
|
'parent', 'bridge', 'mtu', 'vrf', 'description',
|
||||||
|
@ -323,7 +323,7 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm):
|
|||||||
model = VMInterface
|
model = VMInterface
|
||||||
fields = [
|
fields = [
|
||||||
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode',
|
||||||
'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags',
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
'virtual_machine': forms.HiddenInput(),
|
'virtual_machine': forms.HiddenInput(),
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
Django==4.0.4
|
bleach==5.0.0
|
||||||
django-cors-headers==3.12.0
|
Django==4.0.5
|
||||||
django-debug-toolbar==3.2.4
|
django-cors-headers==3.13.0
|
||||||
django-filter==21.1
|
django-debug-toolbar==3.4.0
|
||||||
|
django-filter==22.1
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.13.4
|
django-mptt==0.13.4
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
django-prometheus==2.2.0
|
django-prometheus==2.2.0
|
||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
|
django-rich==1.4.0
|
||||||
django-rq==2.5.1
|
django-rq==2.5.1
|
||||||
django-tables2==2.4.1
|
django-tables2==2.4.1
|
||||||
django-taggit==2.1.0
|
django-taggit==2.1.0
|
||||||
@ -18,7 +20,7 @@ gunicorn==20.1.0
|
|||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.3.7
|
Markdown==3.3.7
|
||||||
markdown-include==0.6.0
|
markdown-include==0.6.0
|
||||||
mkdocs-material==8.2.16
|
mkdocs-material==8.3.6
|
||||||
mkdocstrings[python-legacy]==0.19.0
|
mkdocstrings[python-legacy]==0.19.0
|
||||||
netaddr==0.8.0
|
netaddr==0.8.0
|
||||||
Pillow==9.1.1
|
Pillow==9.1.1
|
||||||
@ -26,7 +28,7 @@ psycopg2-binary==2.9.3
|
|||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
sentry-sdk==1.5.12
|
sentry-sdk==1.5.12
|
||||||
social-auth-app-django==5.0.0
|
social-auth-app-django==5.0.0
|
||||||
social-auth-core==4.2.0
|
social-auth-core==4.3.0
|
||||||
svgwrite==1.4.2
|
svgwrite==1.4.2
|
||||||
tablib==3.2.1
|
tablib==3.2.1
|
||||||
tzdata==2022.1
|
tzdata==2022.1
|
||||||
|
Loading…
Reference in New Issue
Block a user