mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-29 08:37:46 -06:00
Compare commits
210 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63c6687a87 | ||
|
|
e01b7951f2 | ||
|
|
8c220cc04f | ||
|
|
9e9e90f88b | ||
|
|
6d328a82e9 | ||
|
|
dedee0f9d9 | ||
|
|
0ef1bc8490 | ||
|
|
30ab1e5a5e | ||
|
|
14821eed44 | ||
|
|
c8ecee9682 | ||
|
|
a8dd809f8e | ||
|
|
15f4b1fd5d | ||
|
|
36491b13d8 | ||
|
|
ac540b6183 | ||
|
|
6f09d94e2a | ||
|
|
f942216f3f | ||
|
|
ca0b21bef5 | ||
|
|
e4fa8af47f | ||
|
|
6cf898fa13 | ||
|
|
41ad9b242c | ||
|
|
693ad700e8 | ||
|
|
5873ad95dc | ||
|
|
6a687a9ed1 | ||
|
|
e2d5313940 | ||
|
|
a59169fa96 | ||
|
|
f74b7aa7ac | ||
|
|
9a80a491c9 | ||
|
|
aabe8f7c5b | ||
|
|
10af44c12a | ||
|
|
a9aaa8939c | ||
|
|
8f1e70f01d | ||
|
|
1c7ef73d1f | ||
|
|
c24f1f14ec | ||
|
|
b318b79027 | ||
|
|
c7faca9480 | ||
|
|
064d7f3bd0 | ||
|
|
f1877c0c5f | ||
|
|
ce7fb8ab17 | ||
|
|
caca074161 | ||
|
|
8721ad987c | ||
|
|
876251c1cf | ||
|
|
36ac83a319 | ||
|
|
90317adae7 | ||
|
|
135543683d | ||
|
|
38350a1023 | ||
|
|
0e1947bc4b | ||
|
|
7141fc8eb0 | ||
|
|
db38ed4f19 | ||
|
|
f874e9932d | ||
|
|
a2e84dd279 | ||
|
|
a397ce234a | ||
|
|
3694e5e846 | ||
|
|
c6e25f068d | ||
|
|
ff3fcb8134 | ||
|
|
d4d73674fc | ||
|
|
984d15d7fb | ||
|
|
efa449faff | ||
|
|
3af989763e | ||
|
|
9646f88384 | ||
|
|
1bbf5d214b | ||
|
|
8a075bcff9 | ||
|
|
9fe5f09742 | ||
|
|
84f2225f42 | ||
|
|
728ad51624 | ||
|
|
5ab03b7e92 | ||
|
|
890efa5400 | ||
|
|
07620db027 | ||
|
|
f8a3ffae4e | ||
|
|
62d1510c55 | ||
|
|
498b655cb7 | ||
|
|
fa94d9c82c | ||
|
|
6cee12b153 | ||
|
|
bbf4b906e4 | ||
|
|
7d6882bec2 | ||
|
|
451a0067c7 | ||
|
|
383918d83b | ||
|
|
f8cbd322ba | ||
|
|
9835d6b2df | ||
|
|
17e00ac040 | ||
|
|
1c9db2d9f8 | ||
|
|
44586743ea | ||
|
|
802d9d2b6e | ||
|
|
a7a20ad2ea | ||
|
|
124ff23e3d | ||
|
|
1a028f77d4 | ||
|
|
7603468abc | ||
|
|
b854cefb57 | ||
|
|
3d475e5afa | ||
|
|
250265c3d9 | ||
|
|
e07dd3ddcb | ||
|
|
68f53aaa87 | ||
|
|
5fda5cc08c | ||
|
|
6da171a699 | ||
|
|
fe2fae5b86 | ||
|
|
5b5160ca6f | ||
|
|
b9dd654e7a | ||
|
|
b0df24e6d1 | ||
|
|
e135f8e74d | ||
|
|
57397570c0 | ||
|
|
e106d7ac3a | ||
|
|
b72793a85a | ||
|
|
68f24755aa | ||
|
|
5a4467a4a8 | ||
|
|
7c109ffd8c | ||
|
|
6415661b61 | ||
|
|
ed7f42a803 | ||
|
|
e2af716a81 | ||
|
|
d3f91ce0a6 | ||
|
|
dde005366a | ||
|
|
85cab8d9b0 | ||
|
|
a49d3d2ddc | ||
|
|
93c30c94b3 | ||
|
|
1539769c08 | ||
|
|
c7ece43a18 | ||
|
|
69a22ffe5e | ||
|
|
e6bfde1397 | ||
|
|
bd60d46b82 | ||
|
|
3c2a55a521 | ||
|
|
a40ab9ffb1 | ||
|
|
55b3e4eeb3 | ||
|
|
13f854c91f | ||
|
|
29f629156a | ||
|
|
76e634330f | ||
|
|
ef03a2f383 | ||
|
|
5dff7433e8 | ||
|
|
fa014fcbf0 | ||
|
|
8e200a9cb4 | ||
|
|
ccb7e96d8a | ||
|
|
f75ddeb721 | ||
|
|
221ddc6d0f | ||
|
|
31c752bf3a | ||
|
|
2077378ae1 | ||
|
|
9b91c2a886 | ||
|
|
d8b40056b5 | ||
|
|
4315c4697c | ||
|
|
b77013c859 | ||
|
|
f7de2611c1 | ||
|
|
c330282919 | ||
|
|
db807ab4a6 | ||
|
|
d55e3c352a | ||
|
|
afec53cea3 | ||
|
|
6cb8b9110e | ||
|
|
52178f78d1 | ||
|
|
575e2c443b | ||
|
|
7c09259b7d | ||
|
|
7ba268946a | ||
|
|
8074ca95bd | ||
|
|
d691ea92d0 | ||
|
|
903a3e1a9c | ||
|
|
4109113319 | ||
|
|
872c11502f | ||
|
|
10cb4f359a | ||
|
|
45babf162e | ||
|
|
3434428357 | ||
|
|
25128bd06f | ||
|
|
e7620b0dd0 | ||
|
|
65683d0df1 | ||
|
|
ff2ccfd670 | ||
|
|
b1ec703ba9 | ||
|
|
3d785d836d | ||
|
|
7c79c90cd2 | ||
|
|
a6e285316a | ||
|
|
e6018cd38f | ||
|
|
92a6523bf3 | ||
|
|
2815eca260 | ||
|
|
896ebf01b1 | ||
|
|
a71b2e231b | ||
|
|
56f3aaf7c8 | ||
|
|
87a9cc0b9e | ||
|
|
972a1fdd14 | ||
|
|
723954f0d9 | ||
|
|
cf76d5c46a | ||
|
|
e8b970608e | ||
|
|
83fdfaa0eb | ||
|
|
86c35a403a | ||
|
|
e96c382138 | ||
|
|
6876c9878e | ||
|
|
29a5fb041f | ||
|
|
8ef74192ec | ||
|
|
135ce93d03 | ||
|
|
f13b090b5c | ||
|
|
d1aa820856 | ||
|
|
c81c3d11ed | ||
|
|
36c65b7b22 | ||
|
|
8a4c808be5 | ||
|
|
6ed2dbf172 | ||
|
|
1b8350fe48 | ||
|
|
15080aad66 | ||
|
|
7a7f7c5dec | ||
|
|
c958208c47 | ||
|
|
9f4e565b8e | ||
|
|
bb2d21abdd | ||
|
|
e2eb7fdfb5 | ||
|
|
3fbf1f7e71 | ||
|
|
9d308e6246 | ||
|
|
124e93f737 | ||
|
|
fbd933b56a | ||
|
|
9c5355a300 | ||
|
|
491a4e7d78 | ||
|
|
90d8395a2c | ||
|
|
11f7e3099d | ||
|
|
ef29bffb72 | ||
|
|
3effa37fa7 | ||
|
|
1493c920fd | ||
|
|
ea9258d36c | ||
|
|
db142061ff | ||
|
|
c536944a10 | ||
|
|
ae7ddecaa6 | ||
|
|
2e38e62101 | ||
|
|
2979a64ce3 |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.4
|
||||
placeholder: v3.2.9
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.2.4
|
||||
placeholder: v3.2.9
|
||||
validations:
|
||||
required: true
|
||||
- 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
|
||||
recent activity. It will be closed if no further activity occurs. NetBox
|
||||
is governed by a small group of core maintainers which means not all opened
|
||||
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
issues may receive direct feedback. **Do not** attempt to circumvent this
|
||||
process by "bumping" the issue; doing so will result in its immediate closure
|
||||
and you may be barred from participating in any future discussions. Please see
|
||||
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||
stale-pr-label: 'pending closure'
|
||||
stale-pr-message: >
|
||||
This PR has been automatically marked as stale because it has not had
|
||||
|
||||
@@ -160,9 +160,9 @@ to aid in issue management.
|
||||
|
||||
It is natural that some new issues get more attention than others. The stale
|
||||
bot helps bring renewed attention to potentially valuable issues that may have
|
||||
been overlooked. **Do not** comment on an issue that has been marked stale in
|
||||
an effort to circumvent the bot: Doing so will not remove the stale label.
|
||||
(Stale labels can be removed only by maintainers.)
|
||||
been overlooked. **Do not** comment on a stale issue merely to "bump" it in an
|
||||
effort to circumvent the bot: This will result in the immediate closure of the
|
||||
issue, and you may be barred from participating in future discussions.
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
|
||||
6
NOTICE
6
NOTICE
@@ -1 +1,7 @@
|
||||
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.
|
||||
|
||||
This project contains code developed expressly for NetBox, and its reuse in
|
||||
other projects may introduce issues affecting performance, data integrity,
|
||||
and security.
|
||||
|
||||
For more information, please see https://github.com/netbox-community/netbox.
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
# HTML sanitizer
|
||||
# https://github.com/mozilla/bleach
|
||||
bleach
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
Django
|
||||
Django<4.1
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/OttoYiu/django-cors-headers
|
||||
@@ -44,7 +48,8 @@ django-tables2
|
||||
|
||||
# User-defined tags for objects
|
||||
# https://github.com/alex/django-taggit
|
||||
django-taggit
|
||||
# Will evaluate v3.0 during NetBox v3.3 beta
|
||||
django-taggit>=2.1.0,<3.0
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
|
||||
@@ -34,4 +34,4 @@ REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
|
||||
|
||||
NetBox supports single sign-on authentication via the [python-social-auth](https://github.com/python-social-auth) library. To enable SSO, specify the path to the desired authentication backend within the `social_core` Python package. Please see the complete list of [supported authentication backends](https://github.com/python-social-auth/social-core/tree/master/social_core/backends) for the available options.
|
||||
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter.
|
||||
Most remote authentication backends require some additional configuration through settings prefixed with `SOCIAL_AUTH_`. These will be automatically imported from NetBox's `configuration.py` file. Additionally, the [authentication pipeline](https://python-social-auth.readthedocs.io/en/latest/pipeline.html) can be customized via the `SOCIAL_AUTH_PIPELINE` parameter. (NetBox's default pipeline is defined in `netbox/settings.py` for your reference.)
|
||||
|
||||
@@ -43,18 +43,6 @@ changes in the database indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## JOBRESULT_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
||||
job results in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||
|
||||
---
|
||||
|
||||
## CUSTOM_VALIDATORS
|
||||
|
||||
This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below:
|
||||
@@ -110,6 +98,18 @@ Setting this to False will disable the GraphQL API.
|
||||
|
||||
---
|
||||
|
||||
## JOBRESULT_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain job results (scripts and reports). Set this to `0` to retain
|
||||
job results in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE_MODE
|
||||
|
||||
Default: False
|
||||
@@ -185,6 +185,30 @@ The default maximum number of objects to display per page within each list of ob
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_AMPERAGE
|
||||
|
||||
Default: 15
|
||||
|
||||
The default value for the `amperage` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_MAX_UTILIZATION
|
||||
|
||||
Default: 80
|
||||
|
||||
The default value (percentage) for the `max_utilization` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## POWERFEED_DEFAULT_VOLTAGE
|
||||
|
||||
Default: 120
|
||||
|
||||
The default value for the `voltage` field when creating new power feeds.
|
||||
|
||||
---
|
||||
|
||||
## PREFER_IPV4
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -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
|
||||
|
||||
Default: `('127.0.0.1', '::1')`
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
## Front Ports
|
||||
|
||||
Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple rear ports, using numeric positions to annotate the specific alignment of each.
|
||||
Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each.
|
||||
|
||||
@@ -10,7 +10,7 @@ Within the database, custom fields are stored as JSON data directly alongside ea
|
||||
|
||||
Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field:
|
||||
|
||||
* Text: Free-form text (up to 255 characters)
|
||||
* Text: Free-form text (intended for single-line use)
|
||||
* Long text: Free-form of any length; supports Markdown rendering
|
||||
* Integer: A whole number (positive or negative)
|
||||
* Boolean: True or false
|
||||
|
||||
@@ -43,7 +43,7 @@ The following data is available as context for Jinja2 templates:
|
||||
* `username` - The name of the user account associated with the change.
|
||||
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
|
||||
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided ass a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed.
|
||||
|
||||
### Default Request Body
|
||||
|
||||
|
||||
353
docs/reference/markdown.md
Normal file
353
docs/reference/markdown.md
Normal file
@@ -0,0 +1,353 @@
|
||||
---
|
||||
hide:
|
||||
- toc
|
||||
---
|
||||
|
||||
# Markdown
|
||||
|
||||
NetBox supports markdown rendering for certain text fields.
|
||||
|
||||
## Syntax
|
||||
|
||||
##### Table of Contents
|
||||
[Headers](#headers)
|
||||
[Emphasis](#emphasis)
|
||||
[Lists](#lists)
|
||||
[Links](#links)
|
||||
[Images](#images)
|
||||
[Code Blocks](#code)
|
||||
[Tables](#tables)
|
||||
[Blockquotes](#blockquotes)
|
||||
[Inline HTML](#html)
|
||||
[Horizontal Rule](#hr)
|
||||
[Line Breaks](#lines)
|
||||
|
||||
<a name="headers"></a>
|
||||
|
||||
## Headers
|
||||
|
||||
```no-highlight
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
Alternatively, for H1 and H2, an underline-ish style:
|
||||
|
||||
Alt-H1
|
||||
======
|
||||
|
||||
Alt-H2
|
||||
------
|
||||
```
|
||||
|
||||
# H1
|
||||
## H2
|
||||
### H3
|
||||
#### H4
|
||||
##### H5
|
||||
###### H6
|
||||
|
||||
<a name="emphasis"></a>
|
||||
|
||||
## Emphasis
|
||||
|
||||
```no-highlight
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
```
|
||||
|
||||
Emphasis, aka italics, with *asterisks* or _underscores_.
|
||||
|
||||
Strong emphasis, aka bold, with **asterisks** or __underscores__.
|
||||
|
||||
Combined emphasis with **asterisks and _underscores_**.
|
||||
|
||||
Strikethrough uses two tildes. ~~Scratch this.~~
|
||||
|
||||
|
||||
<a name="lists"></a>
|
||||
|
||||
## Lists
|
||||
|
||||
(In this example, leading and trailing spaces are shown with with dots: ⋅)
|
||||
|
||||
```no-highlight
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
⋅⋅* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
⋅⋅1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
⋅⋅⋅You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
⋅⋅⋅To have a line break without a paragraph, you will need to use two trailing spaces.⋅⋅
|
||||
⋅⋅⋅Note that this line is separate, but within the same paragraph.⋅⋅
|
||||
⋅⋅⋅(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
```
|
||||
|
||||
1. First ordered list item
|
||||
2. Another item
|
||||
* Unordered sub-list.
|
||||
1. Actual numbers don't matter, just that it's a number
|
||||
1. Ordered sub-list
|
||||
4. And another item.
|
||||
|
||||
You can have properly indented paragraphs within list items. Notice the blank line above, and the leading spaces (at least one, but we'll use three here to also align the raw Markdown).
|
||||
|
||||
To have a line break without a paragraph, you will need to use two trailing spaces.
|
||||
Note that this line is separate, but within the same paragraph.
|
||||
(This is contrary to the typical GFM line break behaviour, where trailing spaces are not required.)
|
||||
|
||||
* Unordered list can use asterisks
|
||||
- Or minuses
|
||||
+ Or pluses
|
||||
|
||||
<a name="links"></a>
|
||||
|
||||
## Links
|
||||
|
||||
There are two ways to create links.
|
||||
|
||||
```no-highlight
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
```
|
||||
|
||||
[I'm an inline-style link](https://www.google.com)
|
||||
|
||||
[I'm an inline-style link with title](https://www.google.com "Google's Homepage")
|
||||
|
||||
[I'm a reference-style link][Arbitrary case-insensitive reference text]
|
||||
|
||||
[You can use numbers for reference-style link definitions][1]
|
||||
|
||||
Or leave it empty and use the [link text itself].
|
||||
|
||||
URLs and URLs in angle brackets will automatically get turned into links.
|
||||
http://www.example.com or <http://www.example.com> and sometimes
|
||||
example.com (but not on Github, for example).
|
||||
|
||||
Some text to show that the reference links can follow later.
|
||||
|
||||
[arbitrary case-insensitive reference text]: https://www.mozilla.org
|
||||
[1]: http://slashdot.org
|
||||
[link text itself]: http://www.reddit.com
|
||||
|
||||
<a name="images"></a>
|
||||
|
||||
## Images
|
||||
|
||||
```
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
```
|
||||
|
||||
Here's the Netbox logo (hover to see the title text):
|
||||
|
||||
Inline-style:
|
||||

|
||||
|
||||
Reference-style:
|
||||
![alt text][logo]
|
||||
|
||||
[logo]: /static/netbox_logo.png "Logo Title Text 2"
|
||||
|
||||
<a name="code"></a>
|
||||
|
||||
## Code blocks
|
||||
|
||||
```
|
||||
Inline `code` has `back-ticks around` it.
|
||||
```
|
||||
|
||||
Inline `code` has `back-ticks around` it.
|
||||
|
||||
Blocks of code are fenced by lines with three back-ticks <code>```</code>
|
||||
|
||||
````
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
````
|
||||
|
||||
```
|
||||
var s = "Code block";
|
||||
alert(s);
|
||||
```
|
||||
|
||||
<a name="tables"></a>
|
||||
|
||||
## Tables
|
||||
|
||||
```no-highlight
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell.
|
||||
The outer pipes (|) are optional, and you don't need to make the
|
||||
raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
```
|
||||
|
||||
Colons can be used to align columns.
|
||||
|
||||
| Tables | Are | Cool |
|
||||
| ------------- |:-------------:| -----:|
|
||||
| col 3 is | right-aligned | $1600 |
|
||||
| col 2 is | centered | $12 |
|
||||
| zebra stripes | are neat | $1 |
|
||||
|
||||
There must be at least 3 dashes separating each header cell. The outer pipes (|) are optional, and you don't need to make the raw Markdown line up prettily. You can also use inline Markdown.
|
||||
|
||||
Markdown | Less | Pretty
|
||||
--- | --- | ---
|
||||
*Still* | `renders` | **nicely**
|
||||
1 | 2 | 3
|
||||
|
||||
<a name="blockquotes"></a>
|
||||
|
||||
## Blockquotes
|
||||
|
||||
```no-highlight
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
```
|
||||
|
||||
> Blockquotes are very handy in email to emulate reply text.
|
||||
> This line is part of the same quote.
|
||||
|
||||
Quote break.
|
||||
|
||||
> This is a very long line that will still be quoted properly when it wraps. Oh boy let's keep writing to make sure this is long enough to actually wrap for everyone. Oh, you can *put* **Markdown** into a blockquote.
|
||||
|
||||
<a name="html"></a>
|
||||
|
||||
## Inline HTML
|
||||
|
||||
You can also use raw HTML in your Markdown, and it'll mostly work pretty well.
|
||||
|
||||
```no-highlight
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
```
|
||||
|
||||
<dl>
|
||||
<dt>Definition list</dt>
|
||||
<dd>Is something people use sometimes.</dd>
|
||||
|
||||
<dt>Markdown in HTML</dt>
|
||||
<dd>Does *not* work **very** well. Use HTML <em>tags</em>.</dd>
|
||||
</dl>
|
||||
|
||||
<a name="hr"></a>
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
```
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
```
|
||||
|
||||
Three or more...
|
||||
|
||||
---
|
||||
|
||||
Hyphens
|
||||
|
||||
***
|
||||
|
||||
Asterisks
|
||||
|
||||
___
|
||||
|
||||
Underscores
|
||||
|
||||
<a name="lines"></a>
|
||||
|
||||
## Line Breaks
|
||||
|
||||
|
||||
```
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
```
|
||||
|
||||
Here's a line for us to start with.
|
||||
|
||||
This line is separated from the one above by two newlines, so it will be a *separate paragraph*.
|
||||
|
||||
This line is also begins a separate paragraph, but...
|
||||
This line is only separated by a single newline, so it's a separate line in the *same paragraph*.
|
||||
|
||||
Based on [Markdown-Cheatsheet](https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet) by [adam-p](https://github.com/adam-p) licensed under [CC-BY](https://creativecommons.org/licenses/by/3.0/)
|
||||
@@ -1,5 +1,130 @@
|
||||
# NetBox v3.2
|
||||
|
||||
## v3.2.9 (2022-08-16)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8595](https://github.com/netbox-community/netbox/issues/8595) - Add PON interface types
|
||||
* [#8723](https://github.com/netbox-community/netbox/issues/8723) - Enable bulk renaming of devices
|
||||
* [#9161](https://github.com/netbox-community/netbox/issues/9161) - Pretty print JSON custom field data when editing
|
||||
* [#9505](https://github.com/netbox-community/netbox/issues/9505) - Display extra addressing details for IPv4 prefixes
|
||||
* [#9625](https://github.com/netbox-community/netbox/issues/9625) - Add phone & email details to contacts panel
|
||||
* [#9857](https://github.com/netbox-community/netbox/issues/9857) - Add clear button to quick search fields
|
||||
* [#9933](https://github.com/netbox-community/netbox/issues/9933) - Add DOCSIS interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9491](https://github.com/netbox-community/netbox/issues/9491) - Remove button for adding inventory item templates to module type components
|
||||
* [#9979](https://github.com/netbox-community/netbox/issues/9979) - Fix Markdown rendering for custom fields in table columns
|
||||
* [#9986](https://github.com/netbox-community/netbox/issues/9986) - Workaround for upstream timezone data bug
|
||||
|
||||
---
|
||||
|
||||
## v3.2.8 (2022-08-08)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9062](https://github.com/netbox-community/netbox/issues/9062) - Add/edit {module} substitution to help text for component template name
|
||||
* [#9637](https://github.com/netbox-community/netbox/issues/9637) - Add site group field to rack reservation form
|
||||
* [#9762](https://github.com/netbox-community/netbox/issues/9762) - Add `nat_outside` column to the IPAddress table
|
||||
* [#9825](https://github.com/netbox-community/netbox/issues/9825) - Add contacts column to virtual machines table
|
||||
* [#9881](https://github.com/netbox-community/netbox/issues/9881) - Increase granularity in utilization graph values
|
||||
* [#9882](https://github.com/netbox-community/netbox/issues/9882) - Add manufacturer column to modules table
|
||||
* [#9883](https://github.com/netbox-community/netbox/issues/9883) - Linkify location column in power panels table
|
||||
* [#9906](https://github.com/netbox-community/netbox/issues/9906) - Include `color` attribute in front & rear port YAML import/export
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9827](https://github.com/netbox-community/netbox/issues/9827) - Fix assignment of module bay position during bulk creation
|
||||
* [#9871](https://github.com/netbox-community/netbox/issues/9871) - Fix utilization graph value alignments
|
||||
* [#9884](https://github.com/netbox-community/netbox/issues/9884) - Prevent querying assigned VRF on prefix object init
|
||||
* [#9885](https://github.com/netbox-community/netbox/issues/9885) - Fix child prefix counts when editing/deleting aggregates in bulk
|
||||
* [#9891](https://github.com/netbox-community/netbox/issues/9891) - Ensure consistent ordering for tags during object serialization
|
||||
* [#9919](https://github.com/netbox-community/netbox/issues/9919) - Fix potential XSS avenue via linked objects in tables
|
||||
* [#9948](https://github.com/netbox-community/netbox/issues/9948) - Fix TypeError exception when requesting API tokens list as non-authenticated user
|
||||
* [#9949](https://github.com/netbox-community/netbox/issues/9949) - Fix KeyError exception resulting from invalid API token provisioning request
|
||||
* [#9950](https://github.com/netbox-community/netbox/issues/9950) - Prevent redirection to arbitrary URLs via `next` parameter on login URL
|
||||
* [#9952](https://github.com/netbox-community/netbox/issues/9952) - Prevent InvalidMove when attempting to assign a nested child object as parent
|
||||
|
||||
---
|
||||
|
||||
## v3.2.7 (2022-07-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#9705](https://github.com/netbox-community/netbox/issues/9705) - Support filter expressions for the `serial` field on racks, devices, and inventory items
|
||||
* [#9741](https://github.com/netbox-community/netbox/issues/9741) - Check for UserConfig instance during user login
|
||||
* [#9745](https://github.com/netbox-community/netbox/issues/9745) - Add wireless LANs and links to global search
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#9437](https://github.com/netbox-community/netbox/issues/9437) - Standardize form submission buttons and behavior when using enter key
|
||||
* [#9499](https://github.com/netbox-community/netbox/issues/9499) - Fix filtered bulk deletion of VM Interfaces
|
||||
* [#9634](https://github.com/netbox-community/netbox/issues/9634) - Fix image URLs in rack elevations when using external storage
|
||||
* [#9715](https://github.com/netbox-community/netbox/issues/9715) - Fix `SOCIAL_AUTH_PIPELINE` config parameter not taking effect
|
||||
* [#9754](https://github.com/netbox-community/netbox/issues/9754) - Fix regression introduced by #9632
|
||||
* [#9746](https://github.com/netbox-community/netbox/issues/9746) - Permit filtering interfaces by arbitrary speed value in UI
|
||||
* [#9749](https://github.com/netbox-community/netbox/issues/9749) - Retain original slug values when modifying object names
|
||||
* [#9775](https://github.com/netbox-community/netbox/issues/9775) - Fix exception when viewing a report with no description
|
||||
|
||||
---
|
||||
|
||||
## v3.2.6 (2022-07-11)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7702](https://github.com/netbox-community/netbox/issues/7702) - Enable dynamic configuration for default powerfeed attributes
|
||||
* [#9396](https://github.com/netbox-community/netbox/issues/9396) - Allow filtering modules by bay ID
|
||||
* [#9403](https://github.com/netbox-community/netbox/issues/9403) - Enable modifying virtual chassis properties when creating/editing a device
|
||||
* [#9540](https://github.com/netbox-community/netbox/issues/9540) - Add filters for assigned device & VM to IP addresses list
|
||||
* [#9686](https://github.com/netbox-community/netbox/issues/9686) - Add tenant group column for all object tables with tenant assignments
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8854](https://github.com/netbox-community/netbox/issues/8854) - Fix `REMOTE_AUTH_DEFAULT_GROUPS` for social-auth backends
|
||||
* [#9575](https://github.com/netbox-community/netbox/issues/9575) - Fix AttributeError exception for FHRP group with an IP address assigned
|
||||
* [#9597](https://github.com/netbox-community/netbox/issues/9597) - Include `installed_module` in module bay REST API serializer
|
||||
* [#9632](https://github.com/netbox-community/netbox/issues/9632) - Automatically focus on search box when expanding dropdowns
|
||||
* [#9657](https://github.com/netbox-community/netbox/issues/9657) - Fix filtering for custom fields and webhooks in the UI
|
||||
* [#9682](https://github.com/netbox-community/netbox/issues/9682) - Fix bulk assignment of ASNs to sites
|
||||
* [#9687](https://github.com/netbox-community/netbox/issues/9687) - Don't restrict custom text field lengths when entering via UI form
|
||||
* [#9704](https://github.com/netbox-community/netbox/issues/9704) - Include `last_updated` field on JournalEntry REST API serializer
|
||||
|
||||
---
|
||||
|
||||
## v3.2.5 (2022-06-20)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8704](https://github.com/netbox-community/netbox/issues/8704) - Shift-click to select multiple objects in a list
|
||||
* [#8882](https://github.com/netbox-community/netbox/issues/8882) - Support filtering IP addresses by multiple parent prefixes
|
||||
* [#8893](https://github.com/netbox-community/netbox/issues/8893) - Include count of IP ranges under tenant view
|
||||
* [#9417](https://github.com/netbox-community/netbox/issues/9417) - Initialize manufacturer selection when inserting a new module
|
||||
* [#9501](https://github.com/netbox-community/netbox/issues/9501) - Add support for custom Jinja2 filters
|
||||
* [#9517](https://github.com/netbox-community/netbox/issues/9517) - Linkify related power port on power outlet view
|
||||
* [#9525](https://github.com/netbox-community/netbox/issues/9525) - Provide one-click edit link for objects in tables
|
||||
* [#9533](https://github.com/netbox-community/netbox/issues/9533) - Move Markdown reference to local documentation
|
||||
* [#9534](https://github.com/netbox-community/netbox/issues/9534) - Add VLAN group selector to interface bulk edit forms
|
||||
* [#9556](https://github.com/netbox-community/netbox/issues/9556) - Leave dropdown open upon selection for multi-select fields
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8944](https://github.com/netbox-community/netbox/issues/8944) - Fix rendering of Markdown links with colons
|
||||
* [#9108](https://github.com/netbox-community/netbox/issues/9108) - Fix rendering of bracketed Markdown links
|
||||
* [#9374](https://github.com/netbox-community/netbox/issues/9374) - Improve performance when retrieving devices/VMs with config context data
|
||||
* [#9466](https://github.com/netbox-community/netbox/issues/9466) - Avoid sending webhooks after script/report failure
|
||||
* [#9480](https://github.com/netbox-community/netbox/issues/9480) - Fix sorting services & service templates by port numbers
|
||||
* [#9484](https://github.com/netbox-community/netbox/issues/9484) - Include services listening on "all IPs" under IP address view
|
||||
* [#9486](https://github.com/netbox-community/netbox/issues/9486) - Fix redirect URL when adding device components from the module view
|
||||
* [#9495](https://github.com/netbox-community/netbox/issues/9495) - Correct link to contacts in contact groups table column
|
||||
* [#9503](https://github.com/netbox-community/netbox/issues/9503) - Hyperlinks in rack elevation SVGs must always use absolute URLs
|
||||
* [#9512](https://github.com/netbox-community/netbox/issues/9512) - Fix duplicate site results when searching by ASN
|
||||
* [#9524](https://github.com/netbox-community/netbox/issues/9524) - Correct order of VLAN fields under VM interface creation form
|
||||
* [#9537](https://github.com/netbox-community/netbox/issues/9537) - Ensure consistent use of placeholder tag throughout UI
|
||||
* [#9549](https://github.com/netbox-community/netbox/issues/9549) - Fix device counts for rack list under rack role view
|
||||
|
||||
---
|
||||
|
||||
## v3.2.4 (2022-05-31)
|
||||
|
||||
### Enhancements
|
||||
@@ -25,7 +150,6 @@
|
||||
* [#9425](https://github.com/netbox-community/netbox/issues/9425) - Fix bulk import for object and multi-object custom fields
|
||||
* [#9430](https://github.com/netbox-community/netbox/issues/9430) - Fix passing of initial form data for DynamicModelChoiceFields
|
||||
|
||||
|
||||
---
|
||||
|
||||
## v3.2.3 (2022-05-12)
|
||||
|
||||
@@ -136,6 +136,7 @@ nav:
|
||||
- Overview: 'graphql-api/overview.md'
|
||||
- Reference:
|
||||
- Conditions: 'reference/conditions.md'
|
||||
- Markdown: 'reference/markdown.md'
|
||||
- Development:
|
||||
- Introduction: 'development/index.md'
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from circuits.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from .columns import CommitRateColumn
|
||||
|
||||
__all__ = (
|
||||
@@ -39,7 +39,7 @@ class CircuitTypeTable(NetBoxTable):
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
class CircuitTable(NetBoxTable):
|
||||
class CircuitTable(TenancyColumnsMixin, NetBoxTable):
|
||||
cid = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Circuit ID'
|
||||
@@ -48,7 +48,6 @@ class CircuitTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
termination_a = tables.TemplateColumn(
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side A'
|
||||
@@ -69,7 +68,7 @@ class CircuitTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -30,7 +30,7 @@ class ProviderView(generic.ObjectView):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=instance
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
'type', 'tenant', 'tenant__group', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',))
|
||||
circuits_table.configure(request)
|
||||
@@ -91,7 +91,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
Q(termination_a__provider_network=instance.pk) |
|
||||
Q(termination_z__provider_network=instance.pk)
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
'type', 'tenant', 'tenant__group', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, user=request.user)
|
||||
circuits_table.configure(request)
|
||||
@@ -192,7 +192,7 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
class CircuitListView(generic.ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'termination_a', 'termination_z'
|
||||
'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z'
|
||||
)
|
||||
filterset = filtersets.CircuitFilterSet
|
||||
filterset_form = forms.CircuitFilterForm
|
||||
|
||||
@@ -5,6 +5,7 @@ from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'ComponentNestedModuleSerializer',
|
||||
'ModuleBayNestedModuleSerializer',
|
||||
'NestedCableSerializer',
|
||||
'NestedConsolePortSerializer',
|
||||
'NestedConsolePortTemplateSerializer',
|
||||
@@ -281,6 +282,14 @@ class ModuleNestedModuleBaySerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'display', 'name']
|
||||
|
||||
|
||||
class ModuleBayNestedModuleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.Module
|
||||
fields = ['id', 'url', 'display', 'serial']
|
||||
|
||||
|
||||
class ComponentNestedModuleSerializer(WritableNestedSerializer):
|
||||
"""
|
||||
Used by device component serializers.
|
||||
|
||||
@@ -15,6 +15,7 @@ from netbox.api.serializers import (
|
||||
NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
)
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
@@ -41,7 +42,7 @@ class LinkTerminationSerializer(serializers.ModelSerializer):
|
||||
Return the appropriate serializer for the link termination model.
|
||||
"""
|
||||
if obj._link_peer is not None:
|
||||
serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._link_peer, context=context).data
|
||||
return None
|
||||
@@ -67,7 +68,7 @@ class ConnectedEndpointSerializer(serializers.ModelSerializer):
|
||||
Return the appropriate serializer for the type of connected object.
|
||||
"""
|
||||
if obj._path is not None and obj._path.destination is not None:
|
||||
serializer = get_serializer_for_model(obj._path.destination, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj._path.destination, context=context).data
|
||||
return None
|
||||
@@ -543,7 +544,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer):
|
||||
def get_component(self, obj):
|
||||
if obj.component is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.component, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.component, context=context).data
|
||||
|
||||
@@ -886,12 +887,12 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer):
|
||||
class ModuleBaySerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
# installed_module = NestedModuleSerializer(required=False, allow_null=True)
|
||||
installed_module = ModuleBayNestedModuleSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = ModuleBay
|
||||
fields = [
|
||||
'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields',
|
||||
'id', 'url', 'display', 'device', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -935,7 +936,7 @@ class InventoryItemSerializer(NetBoxModelSerializer):
|
||||
def get_component(self, obj):
|
||||
if obj.component is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.component, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.component, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.component, context=context).data
|
||||
|
||||
@@ -991,7 +992,7 @@ class CableSerializer(NetBoxModelSerializer):
|
||||
termination = getattr(obj, 'termination_{}'.format(side.lower()))
|
||||
if termination is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(termination, prefix='Nested')
|
||||
serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
data = serializer(termination, context=context).data
|
||||
|
||||
@@ -1037,7 +1038,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
"""
|
||||
Return the appropriate serializer for the origin.
|
||||
"""
|
||||
serializer = get_serializer_for_model(obj.origin, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.origin, context=context).data
|
||||
|
||||
@@ -1047,7 +1048,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
Return the appropriate serializer for the destination, if any.
|
||||
"""
|
||||
if obj.destination_id is not None:
|
||||
serializer = get_serializer_for_model(obj.destination, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.destination, context=context).data
|
||||
return None
|
||||
@@ -1056,7 +1057,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
def get_path(self, obj):
|
||||
ret = []
|
||||
for node in obj.get_path():
|
||||
serializer = get_serializer_for_model(node, prefix='Nested')
|
||||
serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
ret.append(serializer(node, context=context).data)
|
||||
return ret
|
||||
|
||||
@@ -19,8 +19,10 @@ from ipam.models import Prefix, VLAN
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
from netbox.api.exceptions import ServiceUnavailable
|
||||
from netbox.api.metadata import ContentTypeMetadata
|
||||
from netbox.api.pagination import StripCountAnnotationsPaginator
|
||||
from netbox.api.viewsets import NetBoxModelViewSet
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine
|
||||
@@ -68,14 +70,14 @@ class PathEndpointMixin(object):
|
||||
break
|
||||
|
||||
# Serialize each object
|
||||
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
|
||||
serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
x = serializer_a(near_end, context={'request': request}).data
|
||||
if cable is not None:
|
||||
y = serializers.TracedCableSerializer(cable, context={'request': request}).data
|
||||
else:
|
||||
y = None
|
||||
if far_end is not None:
|
||||
serializer_b = get_serializer_for_model(far_end, prefix='Nested')
|
||||
serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
z = serializer_b(far_end, context={'request': request}).data
|
||||
else:
|
||||
z = None
|
||||
@@ -392,6 +394,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet):
|
||||
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
pagination_class = StripCountAnnotationsPaginator
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
@@ -609,7 +612,7 @@ class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
|
||||
|
||||
|
||||
class ModuleBayViewSet(NetBoxModelViewSet):
|
||||
queryset = ModuleBay.objects.prefetch_related('tags')
|
||||
queryset = ModuleBay.objects.prefetch_related('tags', 'installed_module')
|
||||
serializer_class = serializers.ModuleBaySerializer
|
||||
filterset_class = filtersets.ModuleBayFilterSet
|
||||
brief_prefetch_fields = ['device']
|
||||
|
||||
@@ -814,6 +814,17 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
# ATM/DSL
|
||||
TYPE_XDSL = 'xdsl'
|
||||
|
||||
# Coaxial
|
||||
TYPE_DOCSIS = 'docsis'
|
||||
|
||||
# PON
|
||||
TYPE_GPON = 'gpon'
|
||||
TYPE_XG_PON = 'xg-pon'
|
||||
TYPE_XGS_PON = 'xgs-pon'
|
||||
TYPE_NG_PON2 = 'ng-pon2'
|
||||
TYPE_EPON = 'epon'
|
||||
TYPE_10G_EPON = '10g-epon'
|
||||
|
||||
# Stacking
|
||||
TYPE_STACKWISE = 'cisco-stackwise'
|
||||
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
|
||||
@@ -950,6 +961,23 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_XDSL, 'xDSL'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Coaxial',
|
||||
(
|
||||
(TYPE_DOCSIS, 'DOCSIS'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'PON',
|
||||
(
|
||||
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'),
|
||||
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
|
||||
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
|
||||
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
|
||||
(TYPE_EPON, 'EPON (1 Gbps)'),
|
||||
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
'Stacking',
|
||||
(
|
||||
|
||||
@@ -49,15 +49,6 @@ WIRELESS_IFACE_TYPES = [
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
POWERFEED_VOLTAGE_DEFAULT = 120
|
||||
POWERFEED_AMPERAGE_DEFAULT = 20
|
||||
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
|
||||
|
||||
#
|
||||
# Device components
|
||||
#
|
||||
|
||||
@@ -163,7 +163,7 @@ class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
qs_filter |= Q(asns__asn=int(value.strip()))
|
||||
except ValueError:
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(qs_filter).distinct()
|
||||
|
||||
|
||||
class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalModelFilterSet):
|
||||
@@ -307,7 +307,7 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
|
||||
to_field_name='slug',
|
||||
label='Role (slug)',
|
||||
)
|
||||
serial = django_filters.CharFilter(
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
@@ -992,14 +992,23 @@ class ModuleFilterSet(NetBoxModelFilterSet):
|
||||
to_field_name='model',
|
||||
label='Module type (model)',
|
||||
)
|
||||
module_bay_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='module_bay',
|
||||
queryset=ModuleBay.objects.all(),
|
||||
to_field_name='id',
|
||||
label='Module Bay (ID)'
|
||||
)
|
||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Device.objects.all(),
|
||||
label='Device (ID)',
|
||||
)
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = ['id', 'serial', 'asset_tag']
|
||||
fields = ['id', 'asset_tag']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -1394,7 +1403,7 @@ class InventoryItemFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet):
|
||||
)
|
||||
component_type = ContentTypeFilter()
|
||||
component_id = MultiValueNumberFilter()
|
||||
serial = django_filters.CharFilter(
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from timezone_field import TimeZoneFormField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.models import *
|
||||
from ipam.models import ASN, VLAN, VRF
|
||||
from ipam.models import ASN, VLAN, VLANGroup, VRF
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -1067,13 +1067,32 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect
|
||||
)
|
||||
mode = forms.ChoiceField(
|
||||
choices=add_blank_choice(InterfaceModeChoices),
|
||||
required=False,
|
||||
initial='',
|
||||
widget=StaticSelect()
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
},
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
@@ -1087,13 +1106,13 @@ class InterfaceBulkEditForm(
|
||||
('Addressing', ('vrf', 'mac_address', 'wwn')),
|
||||
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans',
|
||||
'vrf',
|
||||
'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan',
|
||||
'tagged_vlans', 'vrf',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -287,7 +287,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'tag')),
|
||||
('User', ('user_id',)),
|
||||
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||
('Rack', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@@ -295,25 +295,38 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.prefetch_related('site'),
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
},
|
||||
label=_('Location'),
|
||||
null_option='None'
|
||||
)
|
||||
rack_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
},
|
||||
label=_('Rack')
|
||||
)
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
@@ -982,8 +995,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
)
|
||||
speed = forms.IntegerField(
|
||||
required=False,
|
||||
label='Select Speed',
|
||||
widget=SelectSpeedWidget(attrs={'readonly': None})
|
||||
label='Speed',
|
||||
widget=SelectSpeedWidget()
|
||||
)
|
||||
duplex = MultipleChoiceField(
|
||||
choices=InterfaceDuplexChoices,
|
||||
|
||||
@@ -321,7 +321,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
|
||||
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
@@ -521,13 +521,28 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=''
|
||||
)
|
||||
virtual_chassis = DynamicModelChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
required=False
|
||||
)
|
||||
vc_position = forms.IntegerField(
|
||||
required=False,
|
||||
label='Position',
|
||||
help_text="The position in the virtual chassis this device is identified by"
|
||||
)
|
||||
vc_priority = forms.IntegerField(
|
||||
required=False,
|
||||
label='Priority',
|
||||
help_text="The priority of the device in the virtual chassis"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
|
||||
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data'
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'comments', 'tags', 'local_context_data'
|
||||
]
|
||||
help_texts = {
|
||||
'device_role': "The function this device serves",
|
||||
|
||||
@@ -64,6 +64,14 @@ class ModularComponentTemplateCreateForm(ComponentCreateForm):
|
||||
"""
|
||||
Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType.
|
||||
"""
|
||||
name_pattern = ExpandableNameField(
|
||||
label='Name',
|
||||
help_text="""
|
||||
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
|
||||
are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>. {module} is accepted as a substitution for
|
||||
the module bay position.
|
||||
"""
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
required=False
|
||||
|
||||
@@ -146,7 +146,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
|
||||
'device_type', 'module_type', 'name', 'type', 'color', 'rear_port', 'rear_port_position', 'label', 'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -158,7 +158,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = [
|
||||
'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description',
|
||||
'device_type', 'module_type', 'name', 'type', 'color', 'positions', 'label', 'description',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -386,9 +386,9 @@ class Migration(migrations.Migration):
|
||||
('type', models.CharField(default='primary', max_length=50)),
|
||||
('supply', models.CharField(default='ac', max_length=50)),
|
||||
('phase', models.CharField(default='single-phase', max_length=50)),
|
||||
('voltage', models.SmallIntegerField(default=120, validators=[utilities.validators.ExclusionValidator([0])])),
|
||||
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||
('voltage', models.SmallIntegerField(validators=[utilities.validators.ExclusionValidator([0])])),
|
||||
('amperage', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('max_utilization', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||
('available_power', models.PositiveIntegerField(default=0, editable=False)),
|
||||
('comments', models.TextField(blank=True)),
|
||||
],
|
||||
|
||||
@@ -39,7 +39,10 @@ class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
|
||||
related_name='%(class)ss'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
max_length=64,
|
||||
help_text="""
|
||||
{module} is accepted as a substitution for the module bay position when attached to a module type.
|
||||
"""
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
@@ -157,6 +160,14 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -185,6 +196,14 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -236,6 +255,16 @@ class PowerPortTemplate(ModularComponentTemplateModel):
|
||||
'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)."
|
||||
})
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'maximum_draw': self.maximum_draw,
|
||||
'allocated_draw': self.allocated_draw,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -298,6 +327,16 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'power_port': self.power_port.name if self.power_port else None,
|
||||
'feed_leg': self.feed_leg,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -337,6 +376,15 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'mgmt_only': self.mgmt_only,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -410,6 +458,17 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'color': self.color,
|
||||
'rear_port': self.rear_port.name,
|
||||
'rear_port_position': self.rear_port_position,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class RearPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
@@ -449,6 +508,16 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'type': self.type,
|
||||
'color': self.color,
|
||||
'positions': self.positions,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class ModuleBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -474,6 +543,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
||||
position=self.position
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'position': self.position,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -498,6 +575,13 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
|
||||
)
|
||||
|
||||
def to_yaml(self):
|
||||
return {
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
"""
|
||||
|
||||
@@ -95,8 +95,7 @@ class ModularComponentModel(ComponentModel):
|
||||
inventory_items = GenericRelation(
|
||||
to='dcim.InventoryItem',
|
||||
content_type_field='component_type',
|
||||
object_id_field='component_id',
|
||||
related_name='%(class)ss',
|
||||
object_id_field='component_id'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
import yaml
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -161,115 +159,54 @@ class DeviceType(NetBoxModel):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
def to_yaml(self):
|
||||
data = OrderedDict((
|
||||
('manufacturer', self.manufacturer.name),
|
||||
('model', self.model),
|
||||
('slug', self.slug),
|
||||
('part_number', self.part_number),
|
||||
('u_height', self.u_height),
|
||||
('is_full_depth', self.is_full_depth),
|
||||
('subdevice_role', self.subdevice_role),
|
||||
('airflow', self.airflow),
|
||||
('comments', self.comments),
|
||||
))
|
||||
data = {
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'slug': self.slug,
|
||||
'part_number': self.part_number,
|
||||
'u_height': self.u_height,
|
||||
'is_full_depth': self.is_full_depth,
|
||||
'subdevice_role': self.subdevice_role,
|
||||
'airflow': self.airflow,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
if self.consoleporttemplates.exists():
|
||||
data['console-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleporttemplates.all()
|
||||
]
|
||||
if self.consoleserverporttemplates.exists():
|
||||
data['console-server-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleserverporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleserverporttemplates.all()
|
||||
]
|
||||
if self.powerporttemplates.exists():
|
||||
data['power-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'maximum_draw': c.maximum_draw,
|
||||
'allocated_draw': c.allocated_draw,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.powerporttemplates.all()
|
||||
c.to_yaml() for c in self.powerporttemplates.all()
|
||||
]
|
||||
if self.poweroutlettemplates.exists():
|
||||
data['power-outlets'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'power_port': c.power_port.name if c.power_port else None,
|
||||
'feed_leg': c.feed_leg,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.poweroutlettemplates.all()
|
||||
c.to_yaml() for c in self.poweroutlettemplates.all()
|
||||
]
|
||||
if self.interfacetemplates.exists():
|
||||
data['interfaces'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'mgmt_only': c.mgmt_only,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.interfacetemplates.all()
|
||||
c.to_yaml() for c in self.interfacetemplates.all()
|
||||
]
|
||||
if self.frontporttemplates.exists():
|
||||
data['front-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'rear_port': c.rear_port.name,
|
||||
'rear_port_position': c.rear_port_position,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.frontporttemplates.all()
|
||||
c.to_yaml() for c in self.frontporttemplates.all()
|
||||
]
|
||||
if self.rearporttemplates.exists():
|
||||
data['rear-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'positions': c.positions,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.rearporttemplates.all()
|
||||
c.to_yaml() for c in self.rearporttemplates.all()
|
||||
]
|
||||
if self.modulebaytemplates.exists():
|
||||
data['module-bays'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'label': c.label,
|
||||
'position': c.position,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.modulebaytemplates.all()
|
||||
c.to_yaml() for c in self.modulebaytemplates.all()
|
||||
]
|
||||
if self.devicebaytemplates.exists():
|
||||
data['device-bays'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.devicebaytemplates.all()
|
||||
c.to_yaml() for c in self.devicebaytemplates.all()
|
||||
]
|
||||
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
@@ -395,91 +332,41 @@ class ModuleType(NetBoxModel):
|
||||
return reverse('dcim:moduletype', args=[self.pk])
|
||||
|
||||
def to_yaml(self):
|
||||
data = OrderedDict((
|
||||
('manufacturer', self.manufacturer.name),
|
||||
('model', self.model),
|
||||
('part_number', self.part_number),
|
||||
('comments', self.comments),
|
||||
))
|
||||
data = {
|
||||
'manufacturer': self.manufacturer.name,
|
||||
'model': self.model,
|
||||
'part_number': self.part_number,
|
||||
'comments': self.comments,
|
||||
}
|
||||
|
||||
# Component templates
|
||||
if self.consoleporttemplates.exists():
|
||||
data['console-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleporttemplates.all()
|
||||
]
|
||||
if self.consoleserverporttemplates.exists():
|
||||
data['console-server-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.consoleserverporttemplates.all()
|
||||
c.to_yaml() for c in self.consoleserverporttemplates.all()
|
||||
]
|
||||
if self.powerporttemplates.exists():
|
||||
data['power-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'maximum_draw': c.maximum_draw,
|
||||
'allocated_draw': c.allocated_draw,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.powerporttemplates.all()
|
||||
c.to_yaml() for c in self.powerporttemplates.all()
|
||||
]
|
||||
if self.poweroutlettemplates.exists():
|
||||
data['power-outlets'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'power_port': c.power_port.name if c.power_port else None,
|
||||
'feed_leg': c.feed_leg,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.poweroutlettemplates.all()
|
||||
c.to_yaml() for c in self.poweroutlettemplates.all()
|
||||
]
|
||||
if self.interfacetemplates.exists():
|
||||
data['interfaces'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'mgmt_only': c.mgmt_only,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.interfacetemplates.all()
|
||||
c.to_yaml() for c in self.interfacetemplates.all()
|
||||
]
|
||||
if self.frontporttemplates.exists():
|
||||
data['front-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'rear_port': c.rear_port.name,
|
||||
'rear_port_position': c.rear_port_position,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.frontporttemplates.all()
|
||||
c.to_yaml() for c in self.frontporttemplates.all()
|
||||
]
|
||||
if self.rearporttemplates.exists():
|
||||
data['rear-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'positions': c.positions,
|
||||
'label': c.label,
|
||||
'description': c.description,
|
||||
}
|
||||
for c in self.rearporttemplates.all()
|
||||
c.to_yaml() for c in self.rearporttemplates.all()
|
||||
]
|
||||
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
|
||||
@@ -6,6 +6,7 @@ from django.urls import reverse
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import NetBoxModel
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import LinkTermination, PathEndpoint
|
||||
@@ -105,16 +106,16 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination):
|
||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||
)
|
||||
voltage = models.SmallIntegerField(
|
||||
default=POWERFEED_VOLTAGE_DEFAULT,
|
||||
default=ConfigItem('POWERFEED_DEFAULT_VOLTAGE'),
|
||||
validators=[ExclusionValidator([0])]
|
||||
)
|
||||
amperage = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1)],
|
||||
default=POWERFEED_AMPERAGE_DEFAULT
|
||||
default=ConfigItem('POWERFEED_DEFAULT_AMPERAGE')
|
||||
)
|
||||
max_utilization = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
default=POWERFEED_MAX_UTILIZATION_DEFAULT,
|
||||
default=ConfigItem('POWERFEED_DEFAULT_MAX_UTILIZATION'),
|
||||
help_text="Maximum permissible draw (percentage)"
|
||||
)
|
||||
available_power = models.PositiveIntegerField(
|
||||
|
||||
@@ -113,8 +113,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
url = device.device_type.front_image.url
|
||||
# Convert any relative URLs to absolute
|
||||
if url.startswith('/'):
|
||||
url = '{}{}'.format(self.base_url, url)
|
||||
image = drawing.image(
|
||||
href=device.device_type.front_image.url,
|
||||
href=url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -139,8 +143,12 @@ class RackElevationSVG:
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
url = device.device_type.rear_image.url
|
||||
# Convert any relative URLs to absolute
|
||||
if url.startswith('/'):
|
||||
url = '{}{}'.format(self.base_url, url)
|
||||
image = drawing.image(
|
||||
href=device.device_type.rear_image.url,
|
||||
href=url,
|
||||
insert=start,
|
||||
size=end,
|
||||
class_='device-image'
|
||||
@@ -151,9 +159,9 @@ class RackElevationSVG:
|
||||
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(
|
||||
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link_url = '{}{}?{}'.format(
|
||||
self.base_url,
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({
|
||||
'site': rack.site.pk,
|
||||
|
||||
@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Cable
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
|
||||
|
||||
__all__ = (
|
||||
@@ -15,7 +15,7 @@ __all__ = (
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableTable(NetBoxTable):
|
||||
class CableTable(TenancyColumnsMixin, NetBoxTable):
|
||||
termination_a_parent = tables.TemplateColumn(
|
||||
template_code=CABLE_TERMINATION_PARENT,
|
||||
accessor=Accessor('termination_a'),
|
||||
@@ -53,7 +53,6 @@ class CableTable(NetBoxTable):
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
length = columns.TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by=('_abs_length', 'length_unit')
|
||||
@@ -67,7 +66,7 @@ class CableTable(NetBoxTable):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
|
||||
'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
|
||||
@@ -6,7 +6,7 @@ from dcim.models import (
|
||||
InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
|
||||
)
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from .template_code import *
|
||||
|
||||
__all__ = (
|
||||
@@ -137,13 +137,12 @@ class PlatformTable(NetBoxTable):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceTable(NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
order_by=('_name',),
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -200,7 +199,7 @@ class DeviceTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Device
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated',
|
||||
@@ -211,12 +210,11 @@ class DeviceTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
class DeviceImportTable(NetBoxTable):
|
||||
class DeviceImportTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code=DEVICE_LINK
|
||||
)
|
||||
status = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -232,7 +230,7 @@ class DeviceImportTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Device
|
||||
fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
fields = ('id', 'name', 'status', 'tenant', 'tenant_group', 'site', 'rack', 'position', 'device_role', 'device_type')
|
||||
empty_text = False
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,9 @@ class ModuleTypeTable(NetBoxTable):
|
||||
linkify=True,
|
||||
verbose_name='Module Type'
|
||||
)
|
||||
manufacturer = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
instance_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:module_list',
|
||||
url_params={'module_type_id': 'pk'},
|
||||
@@ -41,6 +44,10 @@ class ModuleTable(NetBoxTable):
|
||||
module_bay = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
manufacturer = tables.Column(
|
||||
accessor=tables.A('module_type__manufacturer'),
|
||||
linkify=True
|
||||
)
|
||||
module_type = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -52,8 +59,9 @@ class ModuleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Module
|
||||
fields = (
|
||||
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'comments',
|
||||
'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
|
||||
'pk', 'id', 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag',
|
||||
)
|
||||
|
||||
@@ -21,6 +21,9 @@ class PowerPanelTable(NetBoxTable):
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
location = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
powerfeed_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:powerfeed_list',
|
||||
url_params={'power_panel_id': 'pk'},
|
||||
@@ -35,7 +38,9 @@ class PowerPanelTable(NetBoxTable):
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',)
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
__all__ = (
|
||||
'RackTable',
|
||||
@@ -37,7 +37,7 @@ class RackRoleTable(NetBoxTable):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackTable(NetBoxTable):
|
||||
class RackTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
order_by=('_name',),
|
||||
linkify=True
|
||||
@@ -48,7 +48,6 @@ class RackTable(NetBoxTable):
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = columns.ChoiceFieldColumn()
|
||||
role = columns.ColoredLabelColumn()
|
||||
u_height = tables.TemplateColumn(
|
||||
@@ -87,7 +86,7 @@ class RackTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'asset_tag',
|
||||
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
|
||||
'get_power_utilization', 'contacts', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
@@ -101,7 +100,7 @@ class RackTable(NetBoxTable):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationTable(NetBoxTable):
|
||||
class RackReservationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
reservation = tables.Column(
|
||||
accessor='pk',
|
||||
linkify=True
|
||||
@@ -110,7 +109,10 @@ class RackReservationTable(NetBoxTable):
|
||||
accessor=Accessor('rack__site'),
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
location = tables.Column(
|
||||
accessor=Accessor('rack__location'),
|
||||
linkify=True
|
||||
)
|
||||
rack = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -125,7 +127,7 @@ class RackReservationTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'user', 'created', 'tenant', 'tenant_group', 'description', 'tags',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
from .template_code import LOCATION_BUTTONS
|
||||
|
||||
__all__ = (
|
||||
@@ -75,7 +75,7 @@ class SiteGroupTable(NetBoxTable):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteTable(NetBoxTable):
|
||||
class SiteTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -96,7 +96,6 @@ class SiteTable(NetBoxTable):
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name='ASN Count'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
comments = columns.MarkdownColumn()
|
||||
contacts = columns.ManyToManyColumn(
|
||||
linkify_item=True
|
||||
@@ -108,7 +107,7 @@ class SiteTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Site
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'tenant_group', 'asns', 'asn_count',
|
||||
'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments',
|
||||
'contacts', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
@@ -119,14 +118,13 @@ class SiteTable(NetBoxTable):
|
||||
# Locations
|
||||
#
|
||||
|
||||
class LocationTable(NetBoxTable):
|
||||
class LocationTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = columns.MPTTColumn(
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
rack_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:rack_list',
|
||||
url_params={'location_id': 'pk'},
|
||||
@@ -150,7 +148,7 @@ class LocationTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts',
|
||||
'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
|
||||
|
||||
@@ -100,7 +100,7 @@ LOCATION_BUTTONS = """
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
|
||||
{% load helpers %}
|
||||
{% if perms.dcim.add_inventoryitemtemplate %}
|
||||
{% if perms.dcim.add_inventoryitemtemplate and record.device_type_id %}
|
||||
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
</a>
|
||||
@@ -385,7 +385,7 @@ MODULEBAY_BUTTONS = """
|
||||
<i class="mdi mdi-server-minus" aria-hidden="true" title="Remove module"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&manufacturer={{ object.device_type.manufacturer_id }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-server-plus" aria-hidden="true" title="Install module"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -494,10 +494,10 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_serial(self):
|
||||
params = {'serial': 'ABC'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'serial': 'abc'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'serial': ['ABC', 'DEF']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'serial': ['abc', 'def']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
@@ -1849,13 +1849,20 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'module_type': [module_types[0].model, module_types[1].model]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_module_bay(self):
|
||||
module_bays = ModuleBay.objects.all()[:2]
|
||||
params = {'module_bay_id': [module_bays[0].pk, module_bays[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
device_types = Device.objects.all()[:2]
|
||||
params = {'device_id': [device_types[0].pk, device_types[1].pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
||||
def test_serial(self):
|
||||
params = {'asset_tag': ['A', 'B']}
|
||||
params = {'serial': ['A', 'B']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'serial': ['a', 'b']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_asset_tag(self):
|
||||
@@ -3408,10 +3415,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_serial(self):
|
||||
params = {'serial': 'ABC'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'serial': 'abc'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'serial': ['ABC', 'DEF']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'serial': ['abc', 'def']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_component_type(self):
|
||||
params = {'component_type': 'dcim.interface'}
|
||||
|
||||
@@ -194,14 +194,14 @@ class RackTestCase(TestCase):
|
||||
# Validate inventory (front face)
|
||||
rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT)
|
||||
self.assertEqual(rack1_inventory_front[-10]['device'], device1)
|
||||
del(rack1_inventory_front[-10])
|
||||
del rack1_inventory_front[-10]
|
||||
for u in rack1_inventory_front:
|
||||
self.assertIsNone(u['device'])
|
||||
|
||||
# Validate inventory (rear face)
|
||||
rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR)
|
||||
self.assertEqual(rack1_inventory_rear[-10]['device'], device1)
|
||||
del(rack1_inventory_rear[-10])
|
||||
del rack1_inventory_rear[-10]
|
||||
for u in rack1_inventory_rear:
|
||||
self.assertIsNone(u['device'])
|
||||
|
||||
|
||||
@@ -248,6 +248,7 @@ urlpatterns = [
|
||||
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
|
||||
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
||||
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||
path('devices/rename/', views.DeviceBulkRenameView.as_view(), name='device_bulk_rename'),
|
||||
path('devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
|
||||
path('devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
|
||||
path('devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
|
||||
|
||||
@@ -510,8 +510,8 @@ class RackRoleView(generic.ObjectView):
|
||||
queryset = RackRole.objects.all()
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(
|
||||
role=instance
|
||||
racks = Rack.objects.restrict(request.user, 'view').filter(role=instance).annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
|
||||
racks_table = tables.RackTable(racks, user=request.user, exclude=(
|
||||
@@ -560,9 +560,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
#
|
||||
|
||||
class RackListView(generic.ObjectListView):
|
||||
queryset = Rack.objects.prefetch_related(
|
||||
'site', 'location', 'tenant', 'role', 'devices__device_type'
|
||||
).annotate(
|
||||
queryset = Rack.objects.prefetch_related('devices__device_type').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
)
|
||||
filterset = filtersets.RackFilterSet
|
||||
@@ -1786,6 +1784,12 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Device.objects.all()
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
#
|
||||
@@ -2709,6 +2713,7 @@ class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
default_return_url = 'dcim:device_list'
|
||||
patterned_fields = ('name', 'label', 'position')
|
||||
|
||||
|
||||
class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
|
||||
@@ -3084,7 +3089,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
||||
if membership_form.is_valid():
|
||||
|
||||
membership_form.save()
|
||||
msg = 'Added member <a href="{}">{}</a>'.format(device.get_absolute_url(), escape(device))
|
||||
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
@@ -3129,8 +3134,7 @@ class VirtualChassisRemoveMemberView(ObjectPermissionRequiredMixin, GetReturnURL
|
||||
# Protect master device from being removed
|
||||
virtual_chassis = VirtualChassis.objects.filter(master=device).first()
|
||||
if virtual_chassis is not None:
|
||||
msg = 'Unable to remove master device {} from the virtual chassis.'.format(escape(device))
|
||||
messages.error(request, mark_safe(msg))
|
||||
messages.error(request, f'Unable to remove master device {device} from the virtual chassis.')
|
||||
return redirect(device.get_absolute_url())
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
@@ -15,6 +15,9 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
('Rack Elevations', {
|
||||
'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'),
|
||||
}),
|
||||
('Power', {
|
||||
'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')
|
||||
}),
|
||||
('IPAM', {
|
||||
'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'),
|
||||
}),
|
||||
|
||||
@@ -3,6 +3,7 @@ from rest_framework.fields import Field
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
|
||||
|
||||
#
|
||||
@@ -51,10 +52,10 @@ class CustomFieldsDataField(Field):
|
||||
for cf in self._get_custom_fields():
|
||||
value = cf.deserialize(obj.get(cf.name))
|
||||
if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
|
||||
value = serializer(value, context=self.parent.context).data
|
||||
elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested')
|
||||
serializer = get_serializer_for_model(cf.object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
|
||||
value = serializer(value, many=True, context=self.parent.context).data
|
||||
data[cf.name] = value
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
@@ -192,7 +193,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_parent(self, obj):
|
||||
serializer = get_serializer_for_model(obj.parent, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.parent, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
return serializer(obj.parent, context={'request': self.context['request']}).data
|
||||
|
||||
|
||||
@@ -221,7 +222,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
||||
model = JournalEntry
|
||||
fields = [
|
||||
'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created',
|
||||
'created_by', 'kind', 'comments', 'tags', 'custom_fields',
|
||||
'created_by', 'kind', 'comments', 'tags', 'custom_fields', 'last_updated',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -242,7 +243,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_assigned_object(self, instance):
|
||||
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested')
|
||||
serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(instance.assigned_object, context=context).data
|
||||
|
||||
@@ -462,7 +463,7 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
||||
return None
|
||||
|
||||
try:
|
||||
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.changed_object, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
except SerializerNotFound:
|
||||
return obj.object_repr
|
||||
context = {
|
||||
|
||||
@@ -32,6 +32,9 @@ class WebhookFilterSet(BaseFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
content_type_id = MultiValueNumberFilter(
|
||||
field_name='content_types__id'
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
http_method = django_filters.MultipleChoiceFilter(
|
||||
choices=WebhookHttpMethodChoices
|
||||
@@ -40,8 +43,8 @@ class WebhookFilterSet(BaseFilterSet):
|
||||
class Meta:
|
||||
model = Webhook
|
||||
fields = [
|
||||
'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled',
|
||||
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
'id', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method',
|
||||
'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -58,11 +61,17 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=CustomFieldTypeChoices
|
||||
)
|
||||
content_type_id = MultiValueNumberFilter(
|
||||
field_name='content_types__id'
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
|
||||
fields = ['id', 'name', 'required', 'filter_logic', 'weight', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
||||
@@ -32,12 +32,13 @@ __all__ = (
|
||||
class CustomFieldFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
('Attributes', ('type', 'content_types', 'weight', 'required')),
|
||||
('Attributes', ('type', 'content_type_id', 'weight', 'required')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
required=False,
|
||||
label='Object type'
|
||||
)
|
||||
type = MultipleChoiceField(
|
||||
choices=CustomFieldTypeChoices,
|
||||
@@ -110,13 +111,14 @@ class ExportTemplateFilterForm(FilterForm):
|
||||
class WebhookFilterForm(FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q',)),
|
||||
('Attributes', ('content_types', 'http_method', 'enabled')),
|
||||
('Attributes', ('content_type_id', 'http_method', 'enabled')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('webhooks'),
|
||||
required=False
|
||||
required=False,
|
||||
label='Object type'
|
||||
)
|
||||
http_method = MultipleChoiceField(
|
||||
choices=WebhookHttpMethodChoices,
|
||||
|
||||
@@ -133,6 +133,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
'http_method': StaticSelect(),
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'conditions': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from extras.choices import JobResultStatusChoices
|
||||
from extras.context_managers import change_logging
|
||||
from extras.models import JobResult
|
||||
from extras.scripts import get_script
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.utils import NetBoxFakeRequest
|
||||
|
||||
@@ -49,7 +50,7 @@ class Command(BaseCommand):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -58,7 +59,7 @@ class Command(BaseCommand):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
@@ -18,7 +18,7 @@ from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
JSONField, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
@@ -169,7 +169,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
for instance in instances:
|
||||
del(instance.custom_field_data[self.name])
|
||||
del instance.custom_field_data[self.name]
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
@@ -343,7 +343,7 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
field = forms.JSONField(required=required, initial=initial)
|
||||
field = JSONField(required=required, initial=initial)
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
@@ -365,13 +365,8 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
|
||||
# Text
|
||||
else:
|
||||
if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT:
|
||||
max_length = None
|
||||
widget = forms.Textarea
|
||||
else:
|
||||
max_length = 255
|
||||
widget = None
|
||||
field = forms.CharField(max_length=max_length, required=required, initial=initial, widget=widget)
|
||||
widget = forms.Textarea if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT else None
|
||||
field = forms.CharField(required=required, initial=initial, widget=widget)
|
||||
if self.validation_regex:
|
||||
field.validators = [
|
||||
RegexValidator(
|
||||
|
||||
@@ -17,6 +17,7 @@ from django.utils.functional import classproperty
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.signals import clear_webhooks
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
@@ -465,7 +466,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
|
||||
except AbortTransaction:
|
||||
script.log_info("Database changes have been reverted automatically.")
|
||||
|
||||
clear_webhooks.send(request)
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -474,7 +475,7 @@ def run_script(data, request, commit=True, *args, **kwargs):
|
||||
script.log_info("Database changes have been reverted due to error.")
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
clear_webhooks.send(request)
|
||||
finally:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.save()
|
||||
|
||||
@@ -992,7 +992,7 @@ class CustomFieldModelTest(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
site.clean()
|
||||
|
||||
del(site.cf['bar'])
|
||||
del site.cf['bar']
|
||||
site.clean()
|
||||
|
||||
def test_missing_required_field(self):
|
||||
|
||||
@@ -7,7 +7,9 @@ from django.test import TestCase
|
||||
|
||||
from circuits.models import Provider
|
||||
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
|
||||
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
|
||||
from extras.choices import (
|
||||
CustomFieldTypeChoices, CustomFieldFilterLogicChoices, JournalEntryKindChoices, ObjectChangeActionChoices,
|
||||
)
|
||||
from extras.filtersets import *
|
||||
from extras.models import *
|
||||
from ipam.models import IPAddress
|
||||
@@ -16,6 +18,65 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
|
||||
class CustomFieldTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = CustomField.objects.all()
|
||||
filterset = CustomFieldFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
|
||||
|
||||
custom_fields = (
|
||||
CustomField(
|
||||
name='Custom Field 1',
|
||||
type=CustomFieldTypeChoices.TYPE_TEXT,
|
||||
required=True,
|
||||
weight=100,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE
|
||||
),
|
||||
CustomField(
|
||||
name='Custom Field 2',
|
||||
type=CustomFieldTypeChoices.TYPE_INTEGER,
|
||||
required=False,
|
||||
weight=200,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT
|
||||
),
|
||||
CustomField(
|
||||
name='Custom Field 3',
|
||||
type=CustomFieldTypeChoices.TYPE_BOOLEAN,
|
||||
required=False,
|
||||
weight=300,
|
||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||
),
|
||||
)
|
||||
CustomField.objects.bulk_create(custom_fields)
|
||||
custom_fields[0].content_types.add(content_types[0])
|
||||
custom_fields[1].content_types.add(content_types[1])
|
||||
custom_fields[2].content_types.add(content_types[2])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Custom Field 1', 'Custom Field 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_content_types(self):
|
||||
params = {'content_types': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_required(self):
|
||||
params = {'required': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_weight(self):
|
||||
params = {'weight': [100, 200]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_filter_logic(self):
|
||||
params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
queryset = Webhook.objects.all()
|
||||
filterset = WebhookFilterSet
|
||||
@@ -62,6 +123,8 @@ class WebhookTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_content_types(self):
|
||||
params = {'content_types': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'content_type_id': [ContentType.objects.get_for_model(Site).pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_type_create(self):
|
||||
params = {'type_create': True}
|
||||
|
||||
@@ -30,4 +30,4 @@ class RegistryTest(TestCase):
|
||||
reg['foo'] = 123
|
||||
|
||||
with self.assertRaises(TypeError):
|
||||
del(reg['foo'])
|
||||
del reg['foo']
|
||||
|
||||
@@ -10,6 +10,7 @@ from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
|
||||
from ipam.models import *
|
||||
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
|
||||
from netbox.api.serializers import NetBoxModelSerializer
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import get_serializer_for_model
|
||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
@@ -145,7 +146,7 @@ class FHRPGroupAssignmentSerializer(NetBoxModelSerializer):
|
||||
def get_interface(self, obj):
|
||||
if obj.interface is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.interface, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.interface, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.interface, context=context).data
|
||||
|
||||
@@ -191,7 +192,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
|
||||
def get_scope(self, obj):
|
||||
if obj.scope_id is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.scope, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.scope, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
|
||||
return serializer(obj.scope, context=context).data
|
||||
@@ -375,7 +376,7 @@ class IPAddressSerializer(NetBoxModelSerializer):
|
||||
def get_assigned_object(self, obj):
|
||||
if obj.assigned_object is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
|
||||
serializer = get_serializer_for_model(obj.assigned_object, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.assigned_object, context=context).data
|
||||
|
||||
|
||||
@@ -464,7 +464,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
field_name='address',
|
||||
lookup_expr='family'
|
||||
)
|
||||
parent = django_filters.CharFilter(
|
||||
parent = MultiValueCharFilter(
|
||||
method='search_by_parent',
|
||||
label='Parent prefix',
|
||||
)
|
||||
@@ -571,14 +571,16 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def search_by_parent(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
return queryset
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
return queryset.filter(address__net_host_contained=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
q = Q()
|
||||
for prefix in value:
|
||||
try:
|
||||
query = str(netaddr.IPNetwork(prefix.strip()).cidr)
|
||||
q |= Q(address__net_host_contained=query)
|
||||
except (AddrFormatError, ValueError):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
try:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup, Device
|
||||
from virtualization.models import VirtualMachine
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
@@ -265,6 +266,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
('Attributes', ('parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface')),
|
||||
('VRF', ('vrf_id', 'present_in_vrf_id')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Device/VM', ('device_id', 'virtual_machine_id')),
|
||||
)
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
@@ -298,6 +300,16 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
||||
required=False,
|
||||
label=_('Present in VRF')
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned Device'),
|
||||
)
|
||||
virtual_machine_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Assigned VM'),
|
||||
)
|
||||
status = MultipleChoiceField(
|
||||
choices=IPAddressStatusChoices,
|
||||
required=False
|
||||
|
||||
@@ -848,7 +848,7 @@ class ServiceCreateForm(ServiceForm):
|
||||
# Fields which may be populated from a ServiceTemplate are not required
|
||||
for field in ('name', 'protocol', 'ports'):
|
||||
self.fields[field].required = False
|
||||
del(self.fields[field].widget.attrs['required'])
|
||||
del self.fields[field].widget.attrs['required']
|
||||
|
||||
def clean(self):
|
||||
if self.cleaned_data['service_template']:
|
||||
|
||||
@@ -373,7 +373,7 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel):
|
||||
|
||||
# Cache the original prefix and VRF so we can check if they have changed on post_save
|
||||
self._prefix = self.prefix
|
||||
self._vrf = self.vrf
|
||||
self._vrf_id = self.vrf_id
|
||||
|
||||
def __str__(self):
|
||||
return str(self.prefix)
|
||||
|
||||
@@ -30,14 +30,14 @@ def update_children_depth(prefix):
|
||||
def handle_prefix_saved(instance, created, **kwargs):
|
||||
|
||||
# Prefix has changed (or new instance has been created)
|
||||
if created or instance.vrf != instance._vrf or instance.prefix != instance._prefix:
|
||||
if created or instance.vrf_id != instance._vrf_id or instance.prefix != instance._prefix:
|
||||
|
||||
update_parents_children(instance)
|
||||
update_children_depth(instance)
|
||||
|
||||
# If this is not a new prefix, clean up parent/children of previous prefix
|
||||
if not created:
|
||||
old_prefix = Prefix(vrf=instance._vrf, prefix=instance._prefix)
|
||||
old_prefix = Prefix(vrf_id=instance._vrf_id, prefix=instance._prefix)
|
||||
update_parents_children(old_prefix)
|
||||
update_children_depth(old_prefix)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from django_tables2.utils import Accessor
|
||||
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin, TenantColumn
|
||||
|
||||
__all__ = (
|
||||
'AggregateTable',
|
||||
@@ -99,7 +99,7 @@ class RIRTable(NetBoxTable):
|
||||
# ASNs
|
||||
#
|
||||
|
||||
class ASNTable(NetBoxTable):
|
||||
class ASNTable(TenancyColumnsMixin, NetBoxTable):
|
||||
asn = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -122,7 +122,6 @@ class ASNTable(NetBoxTable):
|
||||
linkify_item=True,
|
||||
verbose_name='Sites'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:asn_list'
|
||||
)
|
||||
@@ -130,7 +129,7 @@ class ASNTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ASN
|
||||
fields = (
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'description', 'sites', 'tags',
|
||||
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'provider_count', 'tenant', 'tenant_group', 'description', 'sites', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'provider_count', 'sites', 'description', 'tenant')
|
||||
@@ -140,12 +139,11 @@ class ASNTable(NetBoxTable):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateTable(NetBoxTable):
|
||||
class AggregateTable(TenancyColumnsMixin, NetBoxTable):
|
||||
prefix = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='Aggregate'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
date_added = tables.DateColumn(
|
||||
format="Y-m-d",
|
||||
verbose_name='Added'
|
||||
@@ -164,7 +162,7 @@ class AggregateTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Aggregate
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
|
||||
'pk', 'id', 'prefix', 'rir', 'tenant', 'tenant_group', 'child_count', 'utilization', 'date_added', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
|
||||
@@ -225,7 +223,7 @@ class PrefixUtilizationColumn(columns.UtilizationColumn):
|
||||
"""
|
||||
|
||||
|
||||
class PrefixTable(NetBoxTable):
|
||||
class PrefixTable(TenancyColumnsMixin, NetBoxTable):
|
||||
prefix = columns.TemplateColumn(
|
||||
template_code=PREFIX_LINK,
|
||||
export_raw=True,
|
||||
@@ -256,7 +254,6 @@ class PrefixTable(NetBoxTable):
|
||||
template_code=VRF_LINK,
|
||||
verbose_name='VRF'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -289,7 +286,7 @@ class PrefixTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', 'site',
|
||||
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -303,7 +300,7 @@ class PrefixTable(NetBoxTable):
|
||||
#
|
||||
# IP ranges
|
||||
#
|
||||
class IPRangeTable(NetBoxTable):
|
||||
class IPRangeTable(TenancyColumnsMixin, NetBoxTable):
|
||||
start_address = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
@@ -317,7 +314,6 @@ class IPRangeTable(NetBoxTable):
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
utilization = columns.UtilizationColumn(
|
||||
accessor='utilization',
|
||||
orderable=False
|
||||
@@ -329,7 +325,7 @@ class IPRangeTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = IPRange
|
||||
fields = (
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'description',
|
||||
'utilization', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
@@ -344,7 +340,7 @@ class IPRangeTable(NetBoxTable):
|
||||
# IPAddresses
|
||||
#
|
||||
|
||||
class IPAddressTable(NetBoxTable):
|
||||
class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
|
||||
address = tables.TemplateColumn(
|
||||
template_code=IPADDRESS_LINK,
|
||||
verbose_name='IP Address'
|
||||
@@ -357,7 +353,6 @@ class IPAddressTable(NetBoxTable):
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = columns.ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
@@ -374,6 +369,11 @@ class IPAddressTable(NetBoxTable):
|
||||
orderable=False,
|
||||
verbose_name='NAT (Inside)'
|
||||
)
|
||||
nat_outside = tables.Column(
|
||||
linkify=True,
|
||||
orderable=False,
|
||||
verbose_name='NAT (Outside)'
|
||||
)
|
||||
assigned = columns.BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
linkify=True,
|
||||
@@ -386,7 +386,7 @@ class IPAddressTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'tenant_group', 'nat_inside', 'nat_outside', 'assigned', 'dns_name', 'description',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -14,7 +14,8 @@ class ServiceTemplateTable(NetBoxTable):
|
||||
linkify=True
|
||||
)
|
||||
ports = tables.Column(
|
||||
accessor=tables.A('port_list')
|
||||
accessor=tables.A('port_list'),
|
||||
order_by=tables.A('ports'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:servicetemplate_list'
|
||||
@@ -35,7 +36,8 @@ class ServiceTable(NetBoxTable):
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
ports = tables.Column(
|
||||
accessor=tables.A('port_list')
|
||||
accessor=tables.A('port_list'),
|
||||
order_by=tables.A('ports'),
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
|
||||
@@ -5,7 +5,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Interface
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin, TenantColumn
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
__all__ = (
|
||||
@@ -90,7 +90,7 @@ class VLANGroupTable(NetBoxTable):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANTable(NetBoxTable):
|
||||
class VLANTable(TenancyColumnsMixin, NetBoxTable):
|
||||
vid = tables.TemplateColumn(
|
||||
template_code=VLAN_LINK,
|
||||
verbose_name='VID'
|
||||
@@ -104,7 +104,6 @@ class VLANTable(NetBoxTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = columns.ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
@@ -123,7 +122,7 @@ class VLANTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VLAN
|
||||
fields = (
|
||||
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
|
||||
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'tenant_group', 'status', 'role', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
@@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
|
||||
from ipam.models import *
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from tenancy.tables import TenantColumn
|
||||
from tenancy.tables import TenancyColumnsMixin
|
||||
|
||||
__all__ = (
|
||||
'RouteTargetTable',
|
||||
@@ -20,14 +20,13 @@ VRF_TARGETS = """
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFTable(NetBoxTable):
|
||||
class VRFTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
rd = tables.Column(
|
||||
verbose_name='RD'
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
enforce_unique = columns.BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
@@ -46,7 +45,7 @@ class VRFTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'tenant_group', 'enforce_unique', 'description', 'import_targets', 'export_targets',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
@@ -56,16 +55,15 @@ class VRFTable(NetBoxTable):
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class RouteTargetTable(NetBoxTable):
|
||||
class RouteTargetTable(TenancyColumnsMixin, NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:vrf_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'tenant_group', 'description', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
|
||||
@@ -823,10 +823,8 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_parent(self):
|
||||
params = {'parent': '10.0.0.0/24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'parent': '2001:db8::/64'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
params = {'parent': ['10.0.0.0/30', '2001:db8::/126']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
|
||||
|
||||
def test_filter_address(self):
|
||||
# Check IPv4 and IPv6, with and without a mask
|
||||
|
||||
@@ -7,12 +7,12 @@ from django.urls import reverse
|
||||
from circuits.models import Provider, Circuit
|
||||
from circuits.tables import ProviderTable
|
||||
from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from dcim.models import Interface, Site, Device
|
||||
from dcim.tables import SiteTable
|
||||
from netbox.views import generic
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from virtualization.models import VMInterface, VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .constants import *
|
||||
from .models import *
|
||||
@@ -298,7 +298,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
|
||||
def get_children(self, request, parent):
|
||||
return Prefix.objects.restrict(request.user, 'view').filter(
|
||||
prefix__net_contained_or_equal=str(parent.prefix)
|
||||
).prefetch_related('site', 'role', 'tenant', 'vlan')
|
||||
).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
# Determine whether to show assigned prefixes, available prefixes, or both
|
||||
@@ -333,14 +333,18 @@ class AggregateBulkImportView(generic.BulkImportView):
|
||||
|
||||
|
||||
class AggregateBulkEditView(generic.BulkEditView):
|
||||
queryset = Aggregate.objects.prefetch_related('rir')
|
||||
queryset = Aggregate.objects.annotate(
|
||||
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
|
||||
)
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
table = tables.AggregateTable
|
||||
form = forms.AggregateBulkEditForm
|
||||
|
||||
|
||||
class AggregateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Aggregate.objects.prefetch_related('rir')
|
||||
queryset = Aggregate.objects.annotate(
|
||||
child_count=RawSQL('SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', ())
|
||||
)
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
table = tables.AggregateTable
|
||||
|
||||
@@ -470,7 +474,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
|
||||
'site', 'vrf', 'vlan', 'role', 'tenant',
|
||||
'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group'
|
||||
)
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
@@ -499,7 +503,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
'vrf', 'role', 'tenant', 'tenant__group',
|
||||
)
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
@@ -586,9 +590,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/iprange/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
return parent.get_child_ips().restrict(request.user, 'view')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
@@ -676,7 +678,22 @@ class IPAddressView(generic.ObjectView):
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
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
|
||||
try:
|
||||
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))
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
services = Service.objects.restrict(request.user, 'view').filter(service_filter)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
|
||||
@@ -16,7 +16,7 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
self.count = queryset.count()
|
||||
self.count = self.get_queryset_count(queryset)
|
||||
else:
|
||||
# We're dealing with an iterable, not a QuerySet
|
||||
self.count = len(queryset)
|
||||
@@ -52,6 +52,9 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
return self.default_limit
|
||||
|
||||
def get_queryset_count(self, queryset):
|
||||
return queryset.count()
|
||||
|
||||
def get_next_link(self):
|
||||
|
||||
# Pagination has been disabled
|
||||
@@ -67,3 +70,16 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return None
|
||||
|
||||
return super().get_previous_link()
|
||||
|
||||
|
||||
class StripCountAnnotationsPaginator(OptionalLimitOffsetPagination):
|
||||
"""
|
||||
Strips the annotations on the queryset before getting the count
|
||||
to optimize pagination of complex queries.
|
||||
"""
|
||||
def get_queryset_count(self, queryset):
|
||||
# Clone the queryset to avoid messing up the actual query
|
||||
cloned_queryset = queryset.all()
|
||||
cloned_queryset.query.annotations.clear()
|
||||
|
||||
return cloned_queryset.count()
|
||||
|
||||
@@ -10,6 +10,7 @@ from rest_framework.viewsets import ModelViewSet
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from netbox.api.exceptions import SerializerNotFound
|
||||
from netbox.constants import NESTED_SERIALIZER_PREFIX
|
||||
from utilities.api import get_serializer_for_model
|
||||
from .mixins import *
|
||||
|
||||
@@ -60,7 +61,7 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali
|
||||
if self.brief:
|
||||
logger.debug("Request is for 'brief' format; initializing nested serializer")
|
||||
try:
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix='Nested')
|
||||
serializer = get_serializer_for_model(self.queryset.model, prefix=NESTED_SERIALIZER_PREFIX)
|
||||
logger.debug(f"Using serializer {serializer}")
|
||||
return serializer
|
||||
except SerializerNotFound:
|
||||
|
||||
@@ -348,3 +348,26 @@ class LDAPBackend:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
# Custom Social Auth Pipeline Handlers
|
||||
def user_default_groups_handler(backend, user, response, *args, **kwargs):
|
||||
"""
|
||||
Custom pipeline handler which adds remote auth users to the default group specified in the
|
||||
configuration file.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.auth.user_default_groups_handler')
|
||||
if settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||
# Assign default groups to the user
|
||||
group_list = []
|
||||
for name in settings.REMOTE_AUTH_DEFAULT_GROUPS:
|
||||
try:
|
||||
group_list.append(Group.objects.get(name=name))
|
||||
except Group.DoesNotExist:
|
||||
logging.error(
|
||||
f"Could not assign group {name} to remotely-authenticated user {user}: Group not found")
|
||||
if group_list:
|
||||
user.groups.add(*group_list)
|
||||
else:
|
||||
user.groups.clear()
|
||||
logger.debug(f"Stripping user {user} from Groups")
|
||||
|
||||
@@ -82,6 +82,31 @@ PARAMS = (
|
||||
field=forms.IntegerField
|
||||
),
|
||||
|
||||
# Power
|
||||
ConfigParam(
|
||||
name='POWERFEED_DEFAULT_VOLTAGE',
|
||||
label='Powerfeed voltage',
|
||||
default=120,
|
||||
description="Default voltage for powerfeeds",
|
||||
field=forms.IntegerField
|
||||
),
|
||||
|
||||
ConfigParam(
|
||||
name='POWERFEED_DEFAULT_AMPERAGE',
|
||||
label='Powerfeed amperage',
|
||||
default=15,
|
||||
description="Default amperage for powerfeeds",
|
||||
field=forms.IntegerField
|
||||
),
|
||||
|
||||
ConfigParam(
|
||||
name='POWERFEED_DEFAULT_MAX_UTILIZATION',
|
||||
label='Powerfeed max utilization',
|
||||
default=80,
|
||||
description="Default max utilization for powerfeeds",
|
||||
field=forms.IntegerField
|
||||
),
|
||||
|
||||
# Security
|
||||
ConfigParam(
|
||||
name='ALLOWED_URL_SCHEMES',
|
||||
|
||||
@@ -36,3 +36,8 @@ REDIS = {
|
||||
}
|
||||
|
||||
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': True
|
||||
}
|
||||
|
||||
@@ -1,256 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Dict
|
||||
|
||||
import circuits.filtersets
|
||||
import circuits.tables
|
||||
import dcim.filtersets
|
||||
import dcim.tables
|
||||
import ipam.filtersets
|
||||
import ipam.tables
|
||||
import tenancy.filtersets
|
||||
import tenancy.tables
|
||||
import virtualization.filtersets
|
||||
import virtualization.tables
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceType, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site, VirtualChassis,
|
||||
)
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
|
||||
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
# Prefix for nested serializers
|
||||
NESTED_SERIALIZER_PREFIX = 'Nested'
|
||||
|
||||
# Max results per object type
|
||||
SEARCH_MAX_RESULTS = 15
|
||||
|
||||
CIRCUIT_TYPES = OrderedDict(
|
||||
(
|
||||
('provider', {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': circuits.filtersets.ProviderFilterSet,
|
||||
'table': circuits.tables.ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
}),
|
||||
('circuit', {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'terminations__site'
|
||||
),
|
||||
'filterset': circuits.filtersets.CircuitFilterSet,
|
||||
'table': circuits.tables.CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
}),
|
||||
('providernetwork', {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
|
||||
'table': circuits.tables.ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
DCIM_TYPES = OrderedDict(
|
||||
(
|
||||
('site', {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant'),
|
||||
'filterset': dcim.filtersets.SiteFilterSet,
|
||||
'table': dcim.tables.SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
}),
|
||||
('rack', {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
),
|
||||
'filterset': dcim.filtersets.RackFilterSet,
|
||||
'table': dcim.tables.RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': dcim.filtersets.RackReservationFilterSet,
|
||||
'table': dcim.tables.RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': dcim.filtersets.LocationFilterSet,
|
||||
'table': dcim.tables.LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
}),
|
||||
('devicetype', {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': dcim.filtersets.DeviceTypeFilterSet,
|
||||
'table': dcim.tables.DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
}),
|
||||
('device', {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': dcim.filtersets.DeviceFilterSet,
|
||||
'table': dcim.tables.DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
}),
|
||||
('moduletype', {
|
||||
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
),
|
||||
'filterset': dcim.filtersets.ModuleTypeFilterSet,
|
||||
'table': dcim.tables.ModuleTypeTable,
|
||||
'url': 'dcim:moduletype_list',
|
||||
}),
|
||||
('module', {
|
||||
'queryset': Module.objects.prefetch_related(
|
||||
'module_type__manufacturer', 'device', 'module_bay',
|
||||
),
|
||||
'filterset': dcim.filtersets.ModuleFilterSet,
|
||||
'table': dcim.tables.ModuleTable,
|
||||
'url': 'dcim:module_list',
|
||||
}),
|
||||
('virtualchassis', {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': dcim.filtersets.VirtualChassisFilterSet,
|
||||
'table': dcim.tables.VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': dcim.filtersets.CableFilterSet,
|
||||
'table': dcim.tables.CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': dcim.filtersets.PowerFeedFilterSet,
|
||||
'table': dcim.tables.PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
IPAM_TYPES = OrderedDict(
|
||||
(
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.prefetch_related('tenant'),
|
||||
'filterset': ipam.filtersets.VRFFilterSet,
|
||||
'table': ipam.tables.VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
}),
|
||||
('aggregate', {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': ipam.filtersets.AggregateFilterSet,
|
||||
'table': ipam.tables.AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
}),
|
||||
('prefix', {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role'),
|
||||
'filterset': ipam.filtersets.PrefixFilterSet,
|
||||
'table': ipam.tables.PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
}),
|
||||
('ipaddress', {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant'),
|
||||
'filterset': ipam.filtersets.IPAddressFilterSet,
|
||||
'table': ipam.tables.IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
}),
|
||||
('vlan', {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role'),
|
||||
'filterset': ipam.filtersets.VLANFilterSet,
|
||||
'table': ipam.tables.VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
}),
|
||||
('asn', {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant'),
|
||||
'filterset': ipam.filtersets.ASNFilterSet,
|
||||
'table': ipam.tables.ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
}),
|
||||
('service', {
|
||||
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
|
||||
'filterset': ipam.filtersets.ServiceFilterSet,
|
||||
'table': ipam.tables.ServiceTable,
|
||||
'url': 'ipam:service_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
TENANCY_TYPES = OrderedDict(
|
||||
(
|
||||
('tenant', {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
'filterset': tenancy.filtersets.TenantFilterSet,
|
||||
'table': tenancy.tables.TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
}),
|
||||
('contact', {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
|
||||
assignment_count=count_related(ContactAssignment, 'contact')),
|
||||
'filterset': tenancy.filtersets.ContactFilterSet,
|
||||
'table': tenancy.tables.ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
VIRTUALIZATION_TYPES = OrderedDict(
|
||||
(
|
||||
('cluster', {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': virtualization.filtersets.ClusterFilterSet,
|
||||
'table': virtualization.tables.ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
}),
|
||||
('virtualmachine', {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
|
||||
'table': virtualization.tables.VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
}),
|
||||
)
|
||||
)
|
||||
|
||||
SEARCH_TYPE_HIERARCHY = OrderedDict(
|
||||
(
|
||||
("Circuits", CIRCUIT_TYPES),
|
||||
("DCIM", DCIM_TYPES),
|
||||
("IPAM", IPAM_TYPES),
|
||||
("Tenancy", TENANCY_TYPES),
|
||||
("Virtualization", VIRTUALIZATION_TYPES),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def build_search_types() -> Dict[str, Dict]:
|
||||
result = dict()
|
||||
|
||||
for app_types in SEARCH_TYPE_HIERARCHY.values():
|
||||
for name, items in app_types.items():
|
||||
result[name] = items
|
||||
|
||||
return result
|
||||
|
||||
|
||||
SEARCH_TYPES = build_search_types()
|
||||
|
||||
@@ -125,7 +125,7 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
return {}
|
||||
|
||||
# Skip nonstandard lookup expressions
|
||||
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
|
||||
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']:
|
||||
return {}
|
||||
|
||||
# Choose the lookup expression map based on the filter type
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import forms
|
||||
|
||||
from netbox.constants import SEARCH_TYPE_HIERARCHY
|
||||
from netbox.search import SEARCH_TYPE_HIERARCHY
|
||||
from utilities.forms import BootstrapMixin
|
||||
from .base import *
|
||||
|
||||
|
||||
@@ -89,9 +89,9 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
|
||||
super().clean()
|
||||
|
||||
# An MPTT model cannot be its own parent
|
||||
if self.pk and self.parent_id == self.pk:
|
||||
if self.pk and self.parent and self.parent in self.get_descendants(include_self=True):
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
"parent": f"Cannot assign self or child {self._meta.verbose_name} as parent."
|
||||
})
|
||||
|
||||
|
||||
|
||||
261
netbox/netbox/search.py
Normal file
261
netbox/netbox/search.py
Normal file
@@ -0,0 +1,261 @@
|
||||
import circuits.filtersets
|
||||
import circuits.tables
|
||||
import dcim.filtersets
|
||||
import dcim.tables
|
||||
import ipam.filtersets
|
||||
import ipam.tables
|
||||
import tenancy.filtersets
|
||||
import tenancy.tables
|
||||
import virtualization.filtersets
|
||||
import wireless.tables
|
||||
import wireless.filtersets
|
||||
import virtualization.tables
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceType, Interface, Location, Module, ModuleType, PowerFeed, Rack, RackReservation, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, Service, VLAN, VRF
|
||||
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||
from utilities.utils import count_related
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
|
||||
CIRCUIT_TYPES = {
|
||||
'provider': {
|
||||
'queryset': Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
),
|
||||
'filterset': circuits.filtersets.ProviderFilterSet,
|
||||
'table': circuits.tables.ProviderTable,
|
||||
'url': 'circuits:provider_list',
|
||||
},
|
||||
'circuit': {
|
||||
'queryset': Circuit.objects.prefetch_related(
|
||||
'type', 'provider', 'tenant', 'tenant__group', 'terminations__site'
|
||||
),
|
||||
'filterset': circuits.filtersets.CircuitFilterSet,
|
||||
'table': circuits.tables.CircuitTable,
|
||||
'url': 'circuits:circuit_list',
|
||||
},
|
||||
'providernetwork': {
|
||||
'queryset': ProviderNetwork.objects.prefetch_related('provider'),
|
||||
'filterset': circuits.filtersets.ProviderNetworkFilterSet,
|
||||
'table': circuits.tables.ProviderNetworkTable,
|
||||
'url': 'circuits:providernetwork_list',
|
||||
},
|
||||
}
|
||||
|
||||
DCIM_TYPES = {
|
||||
'site': {
|
||||
'queryset': Site.objects.prefetch_related('region', 'tenant', 'tenant__group'),
|
||||
'filterset': dcim.filtersets.SiteFilterSet,
|
||||
'table': dcim.tables.SiteTable,
|
||||
'url': 'dcim:site_list',
|
||||
},
|
||||
'rack': {
|
||||
'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'tenant__group', 'role').annotate(
|
||||
device_count=count_related(Device, 'rack')
|
||||
),
|
||||
'filterset': dcim.filtersets.RackFilterSet,
|
||||
'table': dcim.tables.RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
},
|
||||
'rackreservation': {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': dcim.filtersets.RackReservationFilterSet,
|
||||
'table': dcim.tables.RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
},
|
||||
'location': {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
Device,
|
||||
'location',
|
||||
'device_count',
|
||||
cumulative=True
|
||||
),
|
||||
Rack,
|
||||
'location',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site'),
|
||||
'filterset': dcim.filtersets.LocationFilterSet,
|
||||
'table': dcim.tables.LocationTable,
|
||||
'url': 'dcim:location_list',
|
||||
},
|
||||
'devicetype': {
|
||||
'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
),
|
||||
'filterset': dcim.filtersets.DeviceTypeFilterSet,
|
||||
'table': dcim.tables.DeviceTypeTable,
|
||||
'url': 'dcim:devicetype_list',
|
||||
},
|
||||
'device': {
|
||||
'queryset': Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'tenant__group', 'site', 'rack', 'primary_ip4',
|
||||
'primary_ip6',
|
||||
),
|
||||
'filterset': dcim.filtersets.DeviceFilterSet,
|
||||
'table': dcim.tables.DeviceTable,
|
||||
'url': 'dcim:device_list',
|
||||
},
|
||||
'moduletype': {
|
||||
'queryset': ModuleType.objects.prefetch_related('manufacturer').annotate(
|
||||
instance_count=count_related(Module, 'module_type')
|
||||
),
|
||||
'filterset': dcim.filtersets.ModuleTypeFilterSet,
|
||||
'table': dcim.tables.ModuleTypeTable,
|
||||
'url': 'dcim:moduletype_list',
|
||||
},
|
||||
'module': {
|
||||
'queryset': Module.objects.prefetch_related(
|
||||
'module_type__manufacturer', 'device', 'module_bay',
|
||||
),
|
||||
'filterset': dcim.filtersets.ModuleFilterSet,
|
||||
'table': dcim.tables.ModuleTable,
|
||||
'url': 'dcim:module_list',
|
||||
},
|
||||
'virtualchassis': {
|
||||
'queryset': VirtualChassis.objects.prefetch_related('master').annotate(
|
||||
member_count=count_related(Device, 'virtual_chassis')
|
||||
),
|
||||
'filterset': dcim.filtersets.VirtualChassisFilterSet,
|
||||
'table': dcim.tables.VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
},
|
||||
'cable': {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filterset': dcim.filtersets.CableFilterSet,
|
||||
'table': dcim.tables.CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
},
|
||||
'powerfeed': {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filterset': dcim.filtersets.PowerFeedFilterSet,
|
||||
'table': dcim.tables.PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
},
|
||||
}
|
||||
|
||||
IPAM_TYPES = {
|
||||
'vrf': {
|
||||
'queryset': VRF.objects.prefetch_related('tenant', 'tenant__group'),
|
||||
'filterset': ipam.filtersets.VRFFilterSet,
|
||||
'table': ipam.tables.VRFTable,
|
||||
'url': 'ipam:vrf_list',
|
||||
},
|
||||
'aggregate': {
|
||||
'queryset': Aggregate.objects.prefetch_related('rir'),
|
||||
'filterset': ipam.filtersets.AggregateFilterSet,
|
||||
'table': ipam.tables.AggregateTable,
|
||||
'url': 'ipam:aggregate_list',
|
||||
},
|
||||
'prefix': {
|
||||
'queryset': Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'tenant__group', 'vlan', 'role'),
|
||||
'filterset': ipam.filtersets.PrefixFilterSet,
|
||||
'table': ipam.tables.PrefixTable,
|
||||
'url': 'ipam:prefix_list',
|
||||
},
|
||||
'ipaddress': {
|
||||
'queryset': IPAddress.objects.prefetch_related('vrf__tenant', 'tenant', 'tenant__group'),
|
||||
'filterset': ipam.filtersets.IPAddressFilterSet,
|
||||
'table': ipam.tables.IPAddressTable,
|
||||
'url': 'ipam:ipaddress_list',
|
||||
},
|
||||
'vlan': {
|
||||
'queryset': VLAN.objects.prefetch_related('site', 'group', 'tenant', 'tenant__group', 'role'),
|
||||
'filterset': ipam.filtersets.VLANFilterSet,
|
||||
'table': ipam.tables.VLANTable,
|
||||
'url': 'ipam:vlan_list',
|
||||
},
|
||||
'asn': {
|
||||
'queryset': ASN.objects.prefetch_related('rir', 'tenant', 'tenant__group'),
|
||||
'filterset': ipam.filtersets.ASNFilterSet,
|
||||
'table': ipam.tables.ASNTable,
|
||||
'url': 'ipam:asn_list',
|
||||
},
|
||||
'service': {
|
||||
'queryset': Service.objects.prefetch_related('device', 'virtual_machine'),
|
||||
'filterset': ipam.filtersets.ServiceFilterSet,
|
||||
'table': ipam.tables.ServiceTable,
|
||||
'url': 'ipam:service_list',
|
||||
},
|
||||
}
|
||||
|
||||
TENANCY_TYPES = {
|
||||
'tenant': {
|
||||
'queryset': Tenant.objects.prefetch_related('group'),
|
||||
'filterset': tenancy.filtersets.TenantFilterSet,
|
||||
'table': tenancy.tables.TenantTable,
|
||||
'url': 'tenancy:tenant_list',
|
||||
},
|
||||
'contact': {
|
||||
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(
|
||||
assignment_count=count_related(ContactAssignment, 'contact')),
|
||||
'filterset': tenancy.filtersets.ContactFilterSet,
|
||||
'table': tenancy.tables.ContactTable,
|
||||
'url': 'tenancy:contact_list',
|
||||
},
|
||||
}
|
||||
|
||||
VIRTUALIZATION_TYPES = {
|
||||
'cluster': {
|
||||
'queryset': Cluster.objects.prefetch_related('type', 'group').annotate(
|
||||
device_count=count_related(Device, 'cluster'),
|
||||
vm_count=count_related(VirtualMachine, 'cluster')
|
||||
),
|
||||
'filterset': virtualization.filtersets.ClusterFilterSet,
|
||||
'table': virtualization.tables.ClusterTable,
|
||||
'url': 'virtualization:cluster_list',
|
||||
},
|
||||
'virtualmachine': {
|
||||
'queryset': VirtualMachine.objects.prefetch_related(
|
||||
'cluster', 'tenant', 'tenant__group', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
),
|
||||
'filterset': virtualization.filtersets.VirtualMachineFilterSet,
|
||||
'table': virtualization.tables.VirtualMachineTable,
|
||||
'url': 'virtualization:virtualmachine_list',
|
||||
},
|
||||
}
|
||||
|
||||
WIRELESS_TYPES = {
|
||||
'wirelesslan': {
|
||||
'queryset': WirelessLAN.objects.prefetch_related('group', 'vlan').annotate(
|
||||
interface_count=count_related(Interface, 'wireless_lans')
|
||||
),
|
||||
'filterset': wireless.filtersets.WirelessLANFilterSet,
|
||||
'table': wireless.tables.WirelessLANTable,
|
||||
'url': 'wireless:wirelesslan_list',
|
||||
},
|
||||
'wirelesslink': {
|
||||
'queryset': WirelessLink.objects.prefetch_related('interface_a__device', 'interface_b__device'),
|
||||
'filterset': wireless.filtersets.WirelessLinkFilterSet,
|
||||
'table': wireless.tables.WirelessLinkTable,
|
||||
'url': 'wireless:wirelesslink_list',
|
||||
},
|
||||
}
|
||||
|
||||
SEARCH_TYPE_HIERARCHY = {
|
||||
'Circuits': CIRCUIT_TYPES,
|
||||
'DCIM': DCIM_TYPES,
|
||||
'IPAM': IPAM_TYPES,
|
||||
'Tenancy': TENANCY_TYPES,
|
||||
'Virtualization': VIRTUALIZATION_TYPES,
|
||||
'Wireless': WIRELESS_TYPES,
|
||||
}
|
||||
|
||||
|
||||
def build_search_types():
|
||||
result = dict()
|
||||
|
||||
for app_types in SEARCH_TYPE_HIERARCHY.values():
|
||||
for name, items in app_types.items():
|
||||
result[name] = items
|
||||
|
||||
return result
|
||||
|
||||
|
||||
SEARCH_TYPES = build_search_types()
|
||||
@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.2.4'
|
||||
VERSION = '3.2.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -96,6 +96,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {})
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
JINJA2_FILTERS = getattr(configuration, 'JINJA2_FILTERS', {})
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
@@ -475,11 +476,25 @@ if SENTRY_ENABLED:
|
||||
# Django social auth
|
||||
#
|
||||
|
||||
SOCIAL_AUTH_PIPELINE = (
|
||||
'social_core.pipeline.social_auth.social_details',
|
||||
'social_core.pipeline.social_auth.social_uid',
|
||||
'social_core.pipeline.social_auth.social_user',
|
||||
'social_core.pipeline.user.get_username',
|
||||
'social_core.pipeline.social_auth.associate_by_email',
|
||||
'social_core.pipeline.user.create_user',
|
||||
'social_core.pipeline.social_auth.associate_user',
|
||||
'netbox.authentication.user_default_groups_handler',
|
||||
'social_core.pipeline.social_auth.load_extra_data',
|
||||
'social_core.pipeline.user.user_details',
|
||||
)
|
||||
|
||||
# Load all SOCIAL_AUTH_* settings from the user configuration
|
||||
for param in dir(configuration):
|
||||
if param.startswith('SOCIAL_AUTH_'):
|
||||
globals()[param] = getattr(configuration, param)
|
||||
|
||||
# Force usage of PostgreSQL's JSONB field for extra data
|
||||
SOCIAL_AUTH_JSONFIELD_ENABLED = True
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ from django.contrib.auth.models import AnonymousUser
|
||||
from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.columns import library
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import content_type_identifier, content_type_name, get_viewname
|
||||
|
||||
__all__ = (
|
||||
@@ -166,6 +168,7 @@ class ActionsItem:
|
||||
title: str
|
||||
icon: str
|
||||
permission: Optional[str] = None
|
||||
css_class: Optional[str] = 'secondary'
|
||||
|
||||
|
||||
class ActionsColumn(tables.Column):
|
||||
@@ -175,19 +178,22 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
:param actions: The ordered list of dropdown menu items to include
|
||||
:param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown
|
||||
:param split_actions: When True, converts the actions dropdown menu into a split button with first action as the
|
||||
direct button link and icon (default: True)
|
||||
"""
|
||||
attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
|
||||
empty_values = ()
|
||||
actions = {
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'),
|
||||
'edit': ActionsItem('Edit', 'pencil', 'change', 'warning'),
|
||||
'delete': ActionsItem('Delete', 'trash-can-outline', 'delete', 'danger'),
|
||||
'changelog': ActionsItem('Changelog', 'history'),
|
||||
}
|
||||
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs):
|
||||
def __init__(self, *args, actions=('edit', 'delete', 'changelog'), extra_buttons='', split_actions=True, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.extra_buttons = extra_buttons
|
||||
self.split_actions = split_actions
|
||||
|
||||
# Determine which actions to enable
|
||||
self.actions = {
|
||||
@@ -208,22 +214,49 @@ class ActionsColumn(tables.Column):
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
links = []
|
||||
button = None
|
||||
dropdown_class = 'secondary'
|
||||
dropdown_links = []
|
||||
user = getattr(request, 'user', AnonymousUser())
|
||||
for action, attrs in self.actions.items():
|
||||
for idx, (action, attrs) in enumerate(self.actions.items()):
|
||||
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
|
||||
if attrs.permission is None or user.has_perm(permission):
|
||||
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
|
||||
links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
if links:
|
||||
|
||||
# Render a separate button if a) only one action exists, or b) if split_actions is True
|
||||
if len(self.actions) == 1 or (self.split_actions and idx == 0):
|
||||
dropdown_class = attrs.css_class
|
||||
button = (
|
||||
f'<a class="btn btn-sm btn-{attrs.css_class}" href="{url}{url_appendix}" type="button">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i></a>'
|
||||
)
|
||||
|
||||
# Add dropdown menu items
|
||||
else:
|
||||
dropdown_links.append(
|
||||
f'<li><a class="dropdown-item" href="{url}{url_appendix}">'
|
||||
f'<i class="mdi mdi-{attrs.icon}"></i> {attrs.title}</a></li>'
|
||||
)
|
||||
|
||||
# Create the actions dropdown menu
|
||||
if button and dropdown_links:
|
||||
html += (
|
||||
f'<span class="dropdown">'
|
||||
f'<a class="btn btn-sm btn-secondary dropdown-toggle" href="#" type="button" data-bs-toggle="dropdown">'
|
||||
f'<i class="mdi mdi-wrench"></i></a>'
|
||||
f'<ul class="dropdown-menu">{"".join(links)}</ul></span>'
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' {button}'
|
||||
f' <a class="btn btn-sm btn-{dropdown_class} dropdown-toggle" type="button" data-bs-toggle="dropdown" style="padding-left: 2px">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
elif button:
|
||||
html += button
|
||||
elif dropdown_links:
|
||||
html += (
|
||||
f'<span class="btn-group dropdown">'
|
||||
f' <a class="btn btn-sm btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">'
|
||||
f' <span class="visually-hidden">Toggle Dropdown</span></a>'
|
||||
f' <ul class="dropdown-menu">{"".join(dropdown_links)}</ul>'
|
||||
f'</span>'
|
||||
)
|
||||
|
||||
# Render any extra buttons from template code
|
||||
@@ -395,10 +428,10 @@ class CustomFieldColumn(tables.Column):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _likify_item(item):
|
||||
def _linkify_item(item):
|
||||
if hasattr(item, 'get_absolute_url'):
|
||||
return f'<a href="{item.get_absolute_url()}">{item}</a>'
|
||||
return item
|
||||
return f'<a href="{item.get_absolute_url()}">{escape(item)}</a>'
|
||||
return escape(item)
|
||||
|
||||
def render(self, value):
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True:
|
||||
@@ -406,16 +439,18 @@ class CustomFieldColumn(tables.Column):
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False:
|
||||
return mark_safe('<i class="mdi mdi-close-thick text-danger"></i>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_URL:
|
||||
return mark_safe(f'<a href="{value}">{value}</a>')
|
||||
return mark_safe(f'<a href="{escape(value)}">{escape(value)}</a>')
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||
return ', '.join(v for v in value)
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
return mark_safe(', '.join([
|
||||
self._likify_item(obj) for obj in self.customfield.deserialize(value)
|
||||
]))
|
||||
return mark_safe(', '.join(
|
||||
self._linkify_item(obj) for obj in self.customfield.deserialize(value)
|
||||
))
|
||||
if self.customfield.type == CustomFieldTypeChoices.TYPE_LONGTEXT and value:
|
||||
return render_markdown(value)
|
||||
if value is not None:
|
||||
obj = self.customfield.deserialize(value)
|
||||
return mark_safe(self._likify_item(obj))
|
||||
return mark_safe(self._linkify_item(obj))
|
||||
return self.default
|
||||
|
||||
def value(self, value):
|
||||
|
||||
@@ -22,8 +22,9 @@ from dcim.models import (
|
||||
from extras.models import ObjectChange
|
||||
from extras.tables import ObjectChangeTable
|
||||
from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF
|
||||
from netbox.constants import SEARCH_MAX_RESULTS, SEARCH_TYPES
|
||||
from netbox.constants import SEARCH_MAX_RESULTS
|
||||
from netbox.forms import SearchForm
|
||||
from netbox.search import SEARCH_TYPES
|
||||
from tenancy.models import Tenant
|
||||
from virtualization.models import Cluster, VirtualMachine
|
||||
from wireless.models import WirelessLAN, WirelessLink
|
||||
|
||||
@@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -484,7 +485,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, ManyToManyField):
|
||||
elif isinstance(model_field, (ManyToManyField, ManyToManyRel)):
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
@@ -632,7 +633,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
replace = form.cleaned_data['replace']
|
||||
if form.cleaned_data['use_regex']:
|
||||
try:
|
||||
obj.new_name = re.sub(find, replace, obj.name)
|
||||
obj.new_name = re.sub(find, replace, obj.name or '')
|
||||
# Catch regex group reference errors
|
||||
except re.error:
|
||||
obj.new_name = obj.name
|
||||
@@ -794,6 +795,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
model_form = None
|
||||
filterset = None
|
||||
table = None
|
||||
patterned_fields = ('name', 'label')
|
||||
|
||||
def get_required_permission(self):
|
||||
return f'dcim.add_{self.queryset.model._meta.model_name}'
|
||||
@@ -829,16 +831,16 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
for obj in data['pk']:
|
||||
|
||||
names = data['name_pattern']
|
||||
labels = data['label_pattern'] if 'label_pattern' in data else None
|
||||
for i, name in enumerate(names):
|
||||
label = labels[i] if labels else None
|
||||
|
||||
pattern_count = len(data[f'{self.patterned_fields[0]}_pattern'])
|
||||
for i in range(pattern_count):
|
||||
component_data = {
|
||||
self.parent_field: obj.pk,
|
||||
'name': name,
|
||||
'label': label
|
||||
self.parent_field: obj.pk
|
||||
}
|
||||
|
||||
for field_name in self.patterned_fields:
|
||||
if data.get(f'{field_name}_pattern'):
|
||||
component_data[field_name] = data[f'{field_name}_pattern'][i]
|
||||
|
||||
component_data.update(data)
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
|
||||
@@ -386,10 +386,10 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
)
|
||||
logger.info(f"{msg} {obj} (PK: {obj.pk})")
|
||||
if hasattr(obj, 'get_absolute_url'):
|
||||
msg = '{} <a href="{}">{}</a>'.format(msg, obj.get_absolute_url(), escape(obj))
|
||||
msg = mark_safe(f'{msg} <a href="{obj.get_absolute_url()}">{escape(obj)}</a>')
|
||||
else:
|
||||
msg = '{} {}'.format(msg, escape(obj))
|
||||
messages.success(request, mark_safe(msg))
|
||||
msg = f'{msg} {obj}'
|
||||
messages.success(request, msg)
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
redirect_url = request.path
|
||||
|
||||
2
netbox/project-static/dist/netbox-dark.css
vendored
2
netbox/project-static/dist/netbox-dark.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-light.css
vendored
2
netbox/project-static/dist/netbox-light.css
vendored
File diff suppressed because one or more lines are too long
2
netbox/project-static/dist/netbox-print.css
vendored
2
netbox/project-static/dist/netbox-print.css
vendored
File diff suppressed because one or more lines are too long
14
netbox/project-static/dist/netbox.js
vendored
14
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle';
|
||||
import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@@ -10,6 +11,7 @@ export function initButtons(): void {
|
||||
initConnectionToggle,
|
||||
initReslug,
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
]) {
|
||||
func();
|
||||
|
||||
@@ -38,7 +38,9 @@ export function initReslug(): void {
|
||||
slugLength = Number(slugLengthAttr);
|
||||
}
|
||||
sourceField.addEventListener('blur', () => {
|
||||
slugField.value = slugify(sourceField.value, slugLength);
|
||||
if (!slugField.value) {
|
||||
slugField.value = slugify(sourceField.value, slugLength);
|
||||
}
|
||||
});
|
||||
slugButton.addEventListener('click', () => {
|
||||
slugField.value = slugify(sourceField.value, slugLength);
|
||||
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,4 @@
|
||||
import { getElements, scrollTo, isTruthy } from '../util';
|
||||
|
||||
/**
|
||||
* When editing an object, it is sometimes desirable to customize the form action *without*
|
||||
* overriding the form's `submit` event. For example, the 'Save & Continue' button. We don't want
|
||||
* to use the `formaction` attribute on that element because it will be included on the form even
|
||||
* if the button isn't clicked.
|
||||
*
|
||||
* @example
|
||||
* ```html
|
||||
* <button type="button" return-url="/special-url/">
|
||||
* Save & Continue
|
||||
* </button>
|
||||
* ```
|
||||
*
|
||||
* @param event Click event.
|
||||
*/
|
||||
function handleSubmitWithReturnUrl(event: MouseEvent): void {
|
||||
const element = event.target as HTMLElement;
|
||||
if (element.tagName === 'BUTTON') {
|
||||
const button = element as HTMLButtonElement;
|
||||
const action = button.getAttribute('return-url');
|
||||
const form = button.form;
|
||||
if (form !== null && isTruthy(action)) {
|
||||
form.action = action;
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
}
|
||||
import { getElements, scrollTo } from '../util';
|
||||
|
||||
function handleFormSubmit(event: Event, form: HTMLFormElement): void {
|
||||
// Track the names of each invalid field.
|
||||
@@ -57,15 +29,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to form buttons with the `return-url` attribute present.
|
||||
*/
|
||||
function initReturnUrlSubmitButtons(): void {
|
||||
for (const button of getElements<HTMLButtonElement>('button[return-url]')) {
|
||||
button.addEventListener('click', handleSubmitWithReturnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach an event listener to each form's submitter (button[type=submit]). When called, the
|
||||
* callback checks the validity of each form field and adds the appropriate Bootstrap CSS class
|
||||
@@ -82,5 +45,4 @@ export function initFormElements(): void {
|
||||
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
|
||||
}
|
||||
}
|
||||
initReturnUrlSubmitButtons();
|
||||
}
|
||||
|
||||
@@ -27,6 +27,23 @@ function handleSearchDropdownClick(event: Event, button: HTMLButtonElement): voi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show/hide quicksearch clear button.
|
||||
*
|
||||
* @param event "keyup" or "search" event for the quicksearch input
|
||||
*/
|
||||
function quickSearchEventHandler(event: Event): void {
|
||||
const quicksearch = event.currentTarget as HTMLInputElement;
|
||||
const inputgroup = quicksearch.parentElement as HTMLDivElement;
|
||||
if (isTruthy(inputgroup)) {
|
||||
if (quicksearch.value === "") {
|
||||
inputgroup.classList.add("hide-last-child");
|
||||
} else {
|
||||
inputgroup.classList.remove("hide-last-child");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Search Bar Elements.
|
||||
*/
|
||||
@@ -40,8 +57,35 @@ function initSearchBar(): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize Quicksearch Event listener/handlers.
|
||||
*/
|
||||
function initQuickSearch(): void {
|
||||
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLButtonElement;
|
||||
if (isTruthy(quicksearch)) {
|
||||
quicksearch.addEventListener("keyup", quickSearchEventHandler, {
|
||||
passive: true
|
||||
})
|
||||
quicksearch.addEventListener("search", quickSearchEventHandler, {
|
||||
passive: true
|
||||
})
|
||||
if (isTruthy(clearbtn)) {
|
||||
clearbtn.addEventListener("click", async () => {
|
||||
const search = new Event('search');
|
||||
quicksearch.value = '';
|
||||
await new Promise(f => setTimeout(f, 100));
|
||||
quicksearch.dispatchEvent(search);
|
||||
}, {
|
||||
passive: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function initSearch(): void {
|
||||
for (const func of [initSearchBar]) {
|
||||
func();
|
||||
}
|
||||
initQuickSearch();
|
||||
}
|
||||
|
||||
@@ -205,6 +205,11 @@ export class APISelect {
|
||||
onChange: () => this.handleSlimChange(),
|
||||
});
|
||||
|
||||
// Don't close on select if multiple select
|
||||
if (this.base.multiple) {
|
||||
this.slim.config.closeOnSelect = false;
|
||||
}
|
||||
|
||||
// Initialize API query properties.
|
||||
this.getStaticParams();
|
||||
this.getDynamicParams();
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './objectDepth';
|
||||
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 },
|
||||
);
|
||||
@@ -416,6 +416,27 @@ nav.search {
|
||||
}
|
||||
}
|
||||
|
||||
// Styles for the quicksearch and its clear button;
|
||||
// Overrides input-group styles and adds transition effects
|
||||
.quicksearch {
|
||||
input[type="search"] {
|
||||
border-radius: $border-radius !important;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-left: -32px !important;
|
||||
z-index: 100 !important;
|
||||
outline: none !important;
|
||||
border-radius: $border-radius !important;
|
||||
transition: visibility 0s, opacity 0.2s linear;
|
||||
}
|
||||
|
||||
button :hover {
|
||||
opacity: 50%;
|
||||
transition: visibility 0s, opacity 0.1s linear;
|
||||
}
|
||||
}
|
||||
|
||||
main.layout {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
@@ -714,11 +735,8 @@ textarea.form-control[rows='10'] {
|
||||
height: 18rem;
|
||||
}
|
||||
|
||||
textarea#id_local_context_data,
|
||||
textarea.markdown,
|
||||
textarea#id_public_key,
|
||||
textarea.form-control[name='csv'],
|
||||
textarea.form-control[name='data'] {
|
||||
textarea.form-control[name='csv'] {
|
||||
font-family: $font-family-monospace;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,3 +34,11 @@ a[type='button'] {
|
||||
.badge {
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
/* clears the 'X' in search inputs from webkit browsers */
|
||||
input[type='search']::-webkit-search-decoration,
|
||||
input[type='search']::-webkit-search-cancel-button,
|
||||
input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
@@ -92,6 +92,10 @@ $input-focus-color: $input-color;
|
||||
$input-placeholder-color: $gray-700;
|
||||
$input-plaintext-color: $body-color;
|
||||
|
||||
input {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
$form-check-input-active-filter: brightness(90%);
|
||||
$form-check-input-bg: $input-bg;
|
||||
$form-check-input-border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user