mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-08 18:09:34 +01:00
Compare commits
178 Commits
v3.1-beta1
...
v3.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
779249ff81 | ||
|
|
66d206a710 | ||
|
|
bfc1cab6df | ||
|
|
5b0c79629e | ||
|
|
7922d3909a | ||
|
|
ee6e2e0af1 | ||
|
|
326a6be91c | ||
|
|
58095e1916 | ||
|
|
3dae077b4d | ||
|
|
7c14c0812b | ||
|
|
3a05eda63a | ||
|
|
d850b3ac7e | ||
|
|
08de6c32c9 | ||
|
|
91fe158c26 | ||
|
|
661b3c4bfb | ||
|
|
35eabc0353 | ||
|
|
ef5bbdb1e2 | ||
|
|
88fae2171d | ||
|
|
de698154cd | ||
|
|
1df05715c2 | ||
|
|
e5524da40e | ||
|
|
50d393e0f9 | ||
|
|
cd08836f3e | ||
|
|
45ac1cfd54 | ||
|
|
dda11ec69e | ||
|
|
7be6206d9d | ||
|
|
4d896573b1 | ||
|
|
988383648c | ||
|
|
d59847537d | ||
|
|
36859d89c8 | ||
|
|
ba8b593351 | ||
|
|
5a59f2352c | ||
|
|
5164b78da1 | ||
|
|
5561b46a59 | ||
|
|
26b2431cbf | ||
|
|
029605f926 | ||
|
|
0cd173f9df | ||
|
|
414810bdf5 | ||
|
|
f94c1e91ea | ||
|
|
b7129e1456 | ||
|
|
dc6decd404 | ||
|
|
40c6b172f7 | ||
|
|
7cb9cedfe1 | ||
|
|
b43980d660 | ||
|
|
09b612546b | ||
|
|
a99d14c13f | ||
|
|
68f322a03b | ||
|
|
97f0414ff3 | ||
|
|
d5f308d9c9 | ||
|
|
1377eda0ba | ||
|
|
70259b0d04 | ||
|
|
f1466d6da3 | ||
|
|
83010e278c | ||
|
|
dc3040550d | ||
|
|
3b25db919a | ||
|
|
09f038f997 | ||
|
|
bbdd3804c7 | ||
|
|
a0b9ac7bcc | ||
|
|
8bb0cba949 | ||
|
|
870aa3a265 | ||
|
|
86ada33577 | ||
|
|
869808b3f9 | ||
|
|
57ccbf44b8 | ||
|
|
416caa8f50 | ||
|
|
1e42fecf66 | ||
|
|
c9b00891ed | ||
|
|
497eacbea3 | ||
|
|
f90c591c78 | ||
|
|
175498940e | ||
|
|
eded00cbb3 | ||
|
|
b7c9ca720a | ||
|
|
7072f207c0 | ||
|
|
5f59f458f4 | ||
|
|
b6fe613329 | ||
|
|
cd128e557c | ||
|
|
30a5c70260 | ||
|
|
beca978af5 | ||
|
|
98a830a6a0 | ||
|
|
ed2231e34b | ||
|
|
55049bb303 | ||
|
|
c210c6937b | ||
|
|
d2767f39f0 | ||
|
|
1c9d39d3e6 | ||
|
|
f16c6d81cf | ||
|
|
e8d6281007 | ||
|
|
8299845615 | ||
|
|
9ae5865c2d | ||
|
|
c2d0cfdfc0 | ||
|
|
5dd252731e | ||
|
|
7b9436d2b9 | ||
|
|
6a369ac985 | ||
|
|
23d90823a3 | ||
|
|
4bfb6b476c | ||
|
|
0d60099588 | ||
|
|
9a45547cda | ||
|
|
a000ded350 | ||
|
|
424ac29131 | ||
|
|
b7b5a5788f | ||
|
|
9de179cba8 | ||
|
|
94069e76c9 | ||
|
|
df9d67b873 | ||
|
|
6f7fbf7686 | ||
|
|
f32e694499 | ||
|
|
e5900a3fe3 | ||
|
|
6e151b044d | ||
|
|
516bea6a0a | ||
|
|
496cabcc53 | ||
|
|
d051db5083 | ||
|
|
660fc23e15 | ||
|
|
a5a480133f | ||
|
|
68b544c676 | ||
|
|
a8c958ece2 | ||
|
|
f77f7ca0ec | ||
|
|
6b21c8453f | ||
|
|
fa8a8abc98 | ||
|
|
98cc36c458 | ||
|
|
f3beabba69 | ||
|
|
467fa5a847 | ||
|
|
50f283cf28 | ||
|
|
f49d7008a0 | ||
|
|
1fed564c47 | ||
|
|
bb99c3e6f9 | ||
|
|
8820cac792 | ||
|
|
ada911c20b | ||
|
|
17e01644f5 | ||
|
|
9458521f3e | ||
|
|
8aa73c5900 | ||
|
|
500f213c6b | ||
|
|
cede27b5fe | ||
|
|
c0ca1eaf90 | ||
|
|
b29a5511df | ||
|
|
49e77841e0 | ||
|
|
daf6c8e327 | ||
|
|
9f8068e8d1 | ||
|
|
0b705553a5 | ||
|
|
a799094227 | ||
|
|
d529c1b5b3 | ||
|
|
834f68e6e4 | ||
|
|
83b2102705 | ||
|
|
2f064cdfd1 | ||
|
|
6c28182dd3 | ||
|
|
3cb8c5db28 | ||
|
|
251abdb4dd | ||
|
|
726e4df54b | ||
|
|
bd32a6ac8e | ||
|
|
27d7400c36 | ||
|
|
53e52aeaa8 | ||
|
|
ae6ed97a80 | ||
|
|
34f24de3e4 | ||
|
|
f93d6813a9 | ||
|
|
3ad773beb3 | ||
|
|
be91235858 | ||
|
|
95fc0bbc94 | ||
|
|
9dad7e4daf | ||
|
|
d08ed9fe5f | ||
|
|
82210cc116 | ||
|
|
94d3e76517 | ||
|
|
3f72492a59 | ||
|
|
c0653da736 | ||
|
|
f3d8f1b1fb | ||
|
|
d2391b9c63 | ||
|
|
f8e44c09eb | ||
|
|
2a00519b93 | ||
|
|
3292a2aecc | ||
|
|
b7aa44837f | ||
|
|
17fd6e692e | ||
|
|
2ce8ef5704 | ||
|
|
7b7afd3e7b | ||
|
|
9c2514fce4 | ||
|
|
e04402ed57 | ||
|
|
3eda8d8482 | ||
|
|
79f2f03fb2 | ||
|
|
f7d0db9cd2 | ||
|
|
fab1d3651b | ||
|
|
e5d7578663 | ||
|
|
830cf4b31f | ||
|
|
b07e88869a | ||
|
|
94bd27bcf5 |
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
7
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -13,11 +13,8 @@ body:
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: >
|
||||
What version of NetBox are you currently running? (If you don't have access to the most
|
||||
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
|
||||
before opening a bug report to see if your issue has already been addressed.)
|
||||
placeholder: v3.0.9
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.1
|
||||
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.0.9
|
||||
placeholder: v3.1.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -76,14 +76,10 @@ free to add a comment with any additional justification for the feature.
|
||||
(However, note that comments with no substance other than a "+1" will be
|
||||
deleted. Please use GitHub's reactions feature to indicate your support.)
|
||||
|
||||
* Due to a large backlog of feature requests, we are not currently accepting
|
||||
any proposals which substantially extend NetBox's functionality beyond its
|
||||
current feature set. This includes the introduction of any new views or models
|
||||
which have not already been proposed in an existing feature request.
|
||||
|
||||
* Before filing a new feature request, consider raising your idea on the
|
||||
mailing list first. Feedback you receive there will help validate and shape the
|
||||
proposed feature before filing a formal issue.
|
||||
* Before filing a new feature request, consider raising your idea in a
|
||||
[GitHub discussion](https://github.com/netbox-community/netbox/discussions)
|
||||
first. Feedback you receive there will help validate and shape the proposed
|
||||
feature before filing a formal issue.
|
||||
|
||||
* Good feature requests are very narrowly defined. Be sure to thoroughly
|
||||
describe the functionality and data model(s) being proposed. The more effort
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
Django
|
||||
Django<4.0
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/OttoYiu/django-cors-headers
|
||||
|
||||
@@ -8,7 +8,7 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
```shell
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
!!! note
|
||||
|
||||
@@ -31,6 +31,41 @@ This defines custom content to be displayed on the login page above the login fo
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
|
||||
changes in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite changelog 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:
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
"dcim.site": [
|
||||
{
|
||||
"name": {
|
||||
"min_length": 5,
|
||||
"max_length": 30
|
||||
}
|
||||
},
|
||||
"my_plugin.validators.Validator1"
|
||||
],
|
||||
"dim.device": [
|
||||
"my_plugin.validators.Validator1"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
Default: False
|
||||
@@ -39,6 +74,14 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## GRAPHQL_ENABLED
|
||||
|
||||
Default: True
|
||||
|
||||
Setting this to False will disable the GraphQL API.
|
||||
|
||||
---
|
||||
|
||||
## MAINTENANCE_MODE
|
||||
|
||||
Default: False
|
||||
|
||||
@@ -25,18 +25,6 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
Default: 90
|
||||
|
||||
The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain
|
||||
changes in the database indefinitely.
|
||||
|
||||
!!! warning
|
||||
If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity.
|
||||
|
||||
---
|
||||
|
||||
## CORS_ORIGIN_ALLOW_ALL
|
||||
|
||||
Default: False
|
||||
@@ -61,22 +49,6 @@ CORS_ORIGIN_WHITELIST = [
|
||||
|
||||
---
|
||||
|
||||
## 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:
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
'dcim.site': (
|
||||
Validator1,
|
||||
Validator2,
|
||||
Validator3
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## DEBUG
|
||||
|
||||
Default: False
|
||||
@@ -168,14 +140,6 @@ EXEMPT_VIEW_PERMISSIONS = ['*']
|
||||
|
||||
---
|
||||
|
||||
## GRAPHQL_ENABLED
|
||||
|
||||
Default: True
|
||||
|
||||
Setting this to False will disable the GraphQL API.
|
||||
|
||||
---
|
||||
|
||||
## HTTP_PROXIES
|
||||
|
||||
Default: None
|
||||
|
||||
@@ -27,3 +27,13 @@ Device components represent discrete objects within a device which are used to t
|
||||
---
|
||||
|
||||
{!models/dcim/cable.md!}
|
||||
|
||||
In the example below, three individual cables comprise a path between devices A and D:
|
||||
|
||||

|
||||
|
||||
Traced from Interface 1 on Device A, NetBox will show the following path:
|
||||
|
||||
* Cable 1: Interface 1 to Front Port 1
|
||||
* Cable 2: Rear Port 1 to Rear Port 2
|
||||
* Cable 3: Front Port 2 to Interface 2
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
# Custom Validation
|
||||
|
||||
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using NetBox's `CustomValidator` class.
|
||||
NetBox validates every object prior to it being written to the database to ensure data integrity. This validation includes things like checking for proper formatting and that references to related objects are valid. However, you may wish to supplement this validation with some rules of your own. For example, perhaps you require that every site's name conforms to a specific pattern. This can be done using custom validation rules.
|
||||
|
||||
## CustomValidator
|
||||
## Custom Validation Rules
|
||||
|
||||
### Validation Rules
|
||||
Custom validation rules are expressed as a mapping of model attributes to a set of rules to which that attribute must conform. For example:
|
||||
|
||||
A custom validator can be instantiated by passing a mapping of attributes to a set of rules to which that attribute must conform. For example:
|
||||
|
||||
```python
|
||||
from extras.validators import CustomValidator
|
||||
|
||||
CustomValidator({
|
||||
'name': {
|
||||
'min_length': 5,
|
||||
'max_length': 30,
|
||||
}
|
||||
})
|
||||
```json
|
||||
{
|
||||
"name": {
|
||||
"min_length": 5,
|
||||
"max_length": 30
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This defines a custom validator which checks that the length of the `name` attribute for an object is at least five characters long, and no longer than 30 characters. This validation is executed _after_ NetBox has performed its own internal validation.
|
||||
@@ -38,12 +34,13 @@ The `min` and `max` types should be defined for numeric values, whereas `min_len
|
||||
|
||||
### Custom Validation Logic
|
||||
|
||||
There may be instances where the provided validation types are insufficient. The `CustomValidator` class can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
|
||||
There may be instances where the provided validation types are insufficient. NetBox provides a `CustomValidator` class which can be extended to enforce arbitrary validation logic by overriding its `validate()` method, and calling `fail()` when an unsatisfactory condition is detected.
|
||||
|
||||
```python
|
||||
from extras.validators import CustomValidator
|
||||
|
||||
class MyValidator(CustomValidator):
|
||||
|
||||
def validate(self, instance):
|
||||
if instance.status == 'active' and not instance.description:
|
||||
self.fail("Active sites must have a description set!", field='status')
|
||||
@@ -53,34 +50,69 @@ The `fail()` method may optionally specify a field with which to associate the s
|
||||
|
||||
## Assigning Custom Validators
|
||||
|
||||
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter, as such:
|
||||
Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/optional-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined:
|
||||
|
||||
1. Plain JSON mapping (no custom logic)
|
||||
2. Dotted path to a custom validator class
|
||||
3. Direct reference to a custom validator class
|
||||
|
||||
### Plain Data
|
||||
|
||||
For cases where custom logic is not needed, it is sufficient to pass validation rules as plain JSON-compatible objects. This approach typically affords the most portability for your configuration. For instance:
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
"dcim.site": [
|
||||
{
|
||||
"name": {
|
||||
"min_length": 5,
|
||||
"max_length": 30,
|
||||
}
|
||||
}
|
||||
],
|
||||
"dcim.device": [
|
||||
{
|
||||
"platform": {
|
||||
"required": True,
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Dotted Path
|
||||
|
||||
In instances where a custom validator class is needed, it can be referenced by its Python path (relative to NetBox's working directory):
|
||||
|
||||
```python
|
||||
CUSTOM_VALIDATORS = {
|
||||
'dcim.site': (
|
||||
'my_validators.Validator1',
|
||||
'my_validators.Validator2',
|
||||
),
|
||||
'dcim.device': (
|
||||
'my_validators.Validator3',
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Direct Class Reference
|
||||
|
||||
This approach requires each class being instantiated to be imported directly within the Python configuration file.
|
||||
|
||||
```python
|
||||
from my_validators import Validator1, Validator2, Validator3
|
||||
|
||||
CUSTOM_VALIDATORS = {
|
||||
'dcim.site': (
|
||||
Validator1,
|
||||
Validator2,
|
||||
Validator3
|
||||
),
|
||||
'dcim.device': (
|
||||
Validator3,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
!!! note
|
||||
Even if defining only a single validator, it must be passed as an iterable.
|
||||
|
||||
When it is not necessary to define a custom `validate()` method, you may opt to pass a `CustomValidator` instance directly:
|
||||
|
||||
```python
|
||||
from extras.validators import CustomValidator
|
||||
|
||||
CUSTOM_VALIDATORS = {
|
||||
'dcim.site': (
|
||||
CustomValidator({
|
||||
'name': {
|
||||
'min_length': 5,
|
||||
'max_length': 30,
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -41,15 +41,20 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
||||
* [dcim.Site](../models/dcim/site.md)
|
||||
* [dcim.VirtualChassis](../models/dcim/virtualchassis.md)
|
||||
* [ipam.Aggregate](../models/ipam/aggregate.md)
|
||||
* [ipam.ASN](../models/ipam/asn.md)
|
||||
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
|
||||
* [ipam.IPAddress](../models/ipam/ipaddress.md)
|
||||
* [ipam.Prefix](../models/ipam/prefix.md)
|
||||
* [ipam.RouteTarget](../models/ipam/routetarget.md)
|
||||
* [ipam.Service](../models/ipam/service.md)
|
||||
* [ipam.VLAN](../models/ipam/vlan.md)
|
||||
* [ipam.VRF](../models/ipam/vrf.md)
|
||||
* [tenancy.Contact](../models/tenancy/contact.md)
|
||||
* [tenancy.Tenant](../models/tenancy/tenant.md)
|
||||
* [virtualization.Cluster](../models/virtualization/cluster.md)
|
||||
* [virtualization.VirtualMachine](../models/virtualization/virtualmachine.md)
|
||||
* [wireless.WirelessLAN](../models/wireless/wirelesslan.md)
|
||||
* [wireless.WirelessLink](../models/wireless/wirelesslink.md)
|
||||
|
||||
### Organizational Models
|
||||
|
||||
@@ -61,6 +66,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
||||
* [ipam.RIR](../models/ipam/rir.md)
|
||||
* [ipam.Role](../models/ipam/role.md)
|
||||
* [ipam.VLANGroup](../models/ipam/vlangroup.md)
|
||||
* [tenancy.ContactRole](../models/tenancy/contactrole.md)
|
||||
* [virtualization.ClusterGroup](../models/virtualization/clustergroup.md)
|
||||
* [virtualization.ClusterType](../models/virtualization/clustertype.md)
|
||||
|
||||
@@ -69,7 +75,9 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
|
||||
* [dcim.Location](../models/dcim/location.md) (formerly RackGroup)
|
||||
* [dcim.Region](../models/dcim/region.md)
|
||||
* [dcim.SiteGroup](../models/dcim/sitegroup.md)
|
||||
* [tenancy.ContactGroup](../models/tenancy/contactgroup.md)
|
||||
* [tenancy.TenantGroup](../models/tenancy/tenantgroup.md)
|
||||
* [wireless.WirelessLANGroup](../models/wireless/wirelesslangroup.md)
|
||||
|
||||
### Component Models
|
||||
|
||||
|
||||
@@ -267,7 +267,7 @@ NetBox includes a `housekeeping` management command that handles some recurring
|
||||
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
@@ -114,7 +114,7 @@ sudo systemctl restart netbox netbox-rq
|
||||
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
|
||||
|
||||
```shell
|
||||
ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
```
|
||||
|
||||
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
|
||||
|
||||
@@ -22,13 +22,3 @@ Each cable may be assigned a type, label, length, and color. Each cable is also
|
||||
## Tracing Cables
|
||||
|
||||
A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user.
|
||||
|
||||
In the example below, three individual cables comprise a path between devices A and D:
|
||||
|
||||

|
||||
|
||||
Traced from Interface 1 on Device A, NetBox will show the following path:
|
||||
|
||||
* Cable 1: Interface 1 to Front Port 1
|
||||
* Cable 2: Rear Port 1 to Rear Port 2
|
||||
* Cable 3: Front Port 2 to Interface 2
|
||||
|
||||
@@ -20,7 +20,7 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net
|
||||
* Selection: A selection of one of several pre-defined custom choices
|
||||
* Multiple selection: A selection field which supports the assignment of multiple values
|
||||
|
||||
Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
|
||||
Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form.
|
||||
|
||||
Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields.
|
||||
|
||||
|
||||
@@ -12,3 +12,5 @@ NetBox models these redundancy groups by protocol and group ID. Each group may o
|
||||
## FHRP Group Assignments
|
||||
|
||||
Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority.
|
||||
|
||||
Interfaces are assigned to FHRP groups under the interface detail view.
|
||||
|
||||
@@ -1,10 +1,72 @@
|
||||
# NetBox v3.0
|
||||
|
||||
## v3.0.10 (FUTURE)
|
||||
## v3.0.12 (2021-12-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7751](https://github.com/netbox-community/netbox/issues/7751) - Get API user from LDAP only when `FIND_GROUP_PERMS` is enabled
|
||||
* [#7885](https://github.com/netbox-community/netbox/issues/7885) - Linkify VLAN name in VLANs table
|
||||
* [#7892](https://github.com/netbox-community/netbox/issues/7892) - Add L22-30 power port & outlet types
|
||||
* [#7932](https://github.com/netbox-community/netbox/issues/7932) - Improve performance of the "quick find" function
|
||||
* [#7941](https://github.com/netbox-community/netbox/issues/7941) - Add multi-standard ITA power outlet type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7823](https://github.com/netbox-community/netbox/issues/7823) - Fix issue where `return_url` is not honored when 'Save & Continue' button is present
|
||||
* [#7981](https://github.com/netbox-community/netbox/issues/7981) - Fix Markdown sanitization regex
|
||||
|
||||
---
|
||||
|
||||
## v3.0.11 (2021-11-24)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#2101](https://github.com/netbox-community/netbox/issues/2101) - Add missing `q` filters for necessary models
|
||||
* [#7424](https://github.com/netbox-community/netbox/issues/7424) - Add virtual chassis filters for device components
|
||||
* [#7531](https://github.com/netbox-community/netbox/issues/7531) - Add Markdown support for strikethrough formatting
|
||||
* [#7542](https://github.com/netbox-community/netbox/issues/7542) - Add optional VLAN group column to prefixes table
|
||||
* [#7803](https://github.com/netbox-community/netbox/issues/7803) - Improve live reloading of custom scripts
|
||||
* [#7810](https://github.com/netbox-community/netbox/issues/7810) - Add IEEE 802.15.1 interface type
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7399](https://github.com/netbox-community/netbox/issues/7399) - Fix excessive CPU utilization when `AUTH_LDAP_FIND_GROUP_PERMS` is enabled
|
||||
* [#7657](https://github.com/netbox-community/netbox/issues/7657) - Make change logging middleware thread-safe
|
||||
* [#7720](https://github.com/netbox-community/netbox/issues/7720) - Fix initialization of custom script MultiObjectVar field with multiple values
|
||||
* [#7729](https://github.com/netbox-community/netbox/issues/7729) - Fix permissions evaluation when displaying VLAN group VLANs table
|
||||
* [#7739](https://github.com/netbox-community/netbox/issues/7739) - Fix exception when tracing cable across circuit with no far end termination
|
||||
* [#7813](https://github.com/netbox-community/netbox/issues/7813) - Fix handling of errors during export template rendering
|
||||
* [#7851](https://github.com/netbox-community/netbox/issues/7851) - Add missing cluster name filter for virtual machines
|
||||
* [#7857](https://github.com/netbox-community/netbox/issues/7857) - Fix ordering IP addresses by assignment status
|
||||
* [#7859](https://github.com/netbox-community/netbox/issues/7859) - Fix styling of form widgets under cable connection views
|
||||
* [#7864](https://github.com/netbox-community/netbox/issues/7864) - `power_port` can be null when creating power outlets via REST API
|
||||
* [#7865](https://github.com/netbox-community/netbox/issues/7865) - REST API should support null values for console port speeds
|
||||
|
||||
---
|
||||
|
||||
## v3.0.10 (2021-11-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#7740](https://github.com/netbox-community/netbox/issues/7740) - Add mini-DIN 8 console port type
|
||||
* [#7760](https://github.com/netbox-community/netbox/issues/7760) - Add `vid` filter field to VLANs list
|
||||
* [#7767](https://github.com/netbox-community/netbox/issues/7767) - Add visual aids to interfaces table for type, enabled status
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7564](https://github.com/netbox-community/netbox/issues/7564) - Fix assignment of members to virtual chassis with initial position of zero
|
||||
* [#7701](https://github.com/netbox-community/netbox/issues/7701) - Fix conflation of assigned IP status & role in interface tables
|
||||
* [#7741](https://github.com/netbox-community/netbox/issues/7741) - Fix 404 when attaching multiple images in succession
|
||||
* [#7752](https://github.com/netbox-community/netbox/issues/7752) - Fix minimum version check under Python v3.10
|
||||
* [#7766](https://github.com/netbox-community/netbox/issues/7766) - Add missing outer dimension columns to rack table
|
||||
* [#7780](https://github.com/netbox-community/netbox/issues/7780) - Preserve multi-line values during CSV file import
|
||||
* [#7783](https://github.com/netbox-community/netbox/issues/7783) - Fix indentation of locations under site view
|
||||
* [#7788](https://github.com/netbox-community/netbox/issues/7788) - Improve XSS mitigation in Markdown renderer
|
||||
* [#7791](https://github.com/netbox-community/netbox/issues/7791) - Enable sorting device bays table by installed device status
|
||||
* [#7802](https://github.com/netbox-community/netbox/issues/7802) - Differentiate ID and VID columns in VLANs table
|
||||
* [#7808](https://github.com/netbox-community/netbox/issues/7808) - Fix reference values for content type under custom field import form
|
||||
* [#7809](https://github.com/netbox-community/netbox/issues/7809) - Add missing export template support for various models
|
||||
* [#7814](https://github.com/netbox-community/netbox/issues/7814) - Fix restriction of user & group objects in GraphQL API queries
|
||||
|
||||
---
|
||||
|
||||
@@ -404,7 +466,7 @@ Note that NetBox's `rqworker` process will _not_ service custom queues by defaul
|
||||
* [#6154](https://github.com/netbox-community/netbox/issues/6154) - Allow decimal values for cable lengths
|
||||
* [#6328](https://github.com/netbox-community/netbox/issues/6328) - Build and serve documentation locally
|
||||
|
||||
### Bug Fixes (from v3.2-beta2)
|
||||
### Bug Fixes (from v3.0-beta2)
|
||||
|
||||
* [#6977](https://github.com/netbox-community/netbox/issues/6977) - Truncate global search dropdown on small screens
|
||||
* [#6979](https://github.com/netbox-community/netbox/issues/6979) - Hide "create & add another" button for circuit terminations
|
||||
|
||||
@@ -1,4 +1,34 @@
|
||||
## v3.1-beta1 (2021-11-05)
|
||||
# NetBox v3.1
|
||||
|
||||
## v3.1.1 (2021-12-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8047](https://github.com/netbox-community/netbox/issues/8047) - Display sorting indicator in table column headers
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5869](https://github.com/netbox-community/netbox/issues/5869) - Fix permissions evaluation under available prefix/IP REST API endpoints
|
||||
* [#7519](https://github.com/netbox-community/netbox/issues/7519) - Return a 409 status for unfulfillable available prefix/IP requests
|
||||
* [#7690](https://github.com/netbox-community/netbox/issues/7690) - Fix custom field integer support for MultiValueNumberFilter
|
||||
* [#7990](https://github.com/netbox-community/netbox/issues/7990) - Fix `title` display on contact detail view
|
||||
* [#7996](https://github.com/netbox-community/netbox/issues/7996) - Show WWN field in interface creation form
|
||||
* [#8001](https://github.com/netbox-community/netbox/issues/8001) - Correct verbose name for wireless LAN group model
|
||||
* [#8003](https://github.com/netbox-community/netbox/issues/8003) - Fix cable tracing across bridged interfaces with no cable
|
||||
* [#8005](https://github.com/netbox-community/netbox/issues/8005) - Fix contact email display
|
||||
* [#8009](https://github.com/netbox-community/netbox/issues/8009) - Validate IP addresses for uniqueness when creating an FHRP group
|
||||
* [#8010](https://github.com/netbox-community/netbox/issues/8010) - Allow filtering devices by multiple serial numbers
|
||||
* [#8019](https://github.com/netbox-community/netbox/issues/8019) - Exclude metrics endpoint when `LOGIN_REQUIRED` is true
|
||||
* [#8030](https://github.com/netbox-community/netbox/issues/8030) - Validate custom field names
|
||||
* [#8033](https://github.com/netbox-community/netbox/issues/8033) - Fix display of zero values for custom integer fields in tables
|
||||
* [#8035](https://github.com/netbox-community/netbox/issues/8035) - Redirect back to parent prefix after creating IP address(es) where applicable
|
||||
* [#8038](https://github.com/netbox-community/netbox/issues/8038) - Placeholder filter should display zero integer values
|
||||
* [#8042](https://github.com/netbox-community/netbox/issues/8042) - Fix filtering cables list by site slug or rack name
|
||||
* [#8051](https://github.com/netbox-community/netbox/issues/8051) - Contact group parent assignment should not be required under REST API
|
||||
|
||||
---
|
||||
|
||||
## v3.1.0 (2021-12-06)
|
||||
|
||||
!!! warning "PostgreSQL 10 Required"
|
||||
NetBox v3.1 requires PostgreSQL 10 or later.
|
||||
@@ -7,6 +37,8 @@
|
||||
|
||||
* The `tenant` and `tenant_id` filters for the Cable model now filter on the tenant assigned directly to each cable, rather than on the parent object of either termination.
|
||||
* The `cable_peer` and `cable_peer_type` attributes of cable termination models have been renamed to `link_peer` and `link_peer_type`, respectively, to accommodate wireless links between interfaces.
|
||||
* Exported webhooks and custom fields now reference associated content types by raw string value (e.g. "dcim.site") rather than by human-friendly name.
|
||||
* The 128GFC interface type has been corrected from `128gfc-sfp28` to `128gfc-qsfp28`.
|
||||
|
||||
### New Features
|
||||
|
||||
@@ -76,6 +108,7 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
|
||||
* [#1337](https://github.com/netbox-community/netbox/issues/1337) - Add WWN field to interfaces
|
||||
* [#1943](https://github.com/netbox-community/netbox/issues/1943) - Relax uniqueness constraint on cluster names
|
||||
* [#3839](https://github.com/netbox-community/netbox/issues/3839) - Add `airflow` field for devices types and devices
|
||||
* [#5143](https://github.com/netbox-community/netbox/issues/5143) - Include a device's asset tag in its display value
|
||||
* [#6497](https://github.com/netbox-community/netbox/issues/6497) - Extend tag support to organizational models
|
||||
* [#6615](https://github.com/netbox-community/netbox/issues/6615) - Add filter lookups for custom fields
|
||||
* [#6711](https://github.com/netbox-community/netbox/issues/6711) - Add `longtext` custom field type with Markdown support
|
||||
@@ -85,6 +118,14 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
|
||||
* [#7452](https://github.com/netbox-community/netbox/issues/7452) - Add `json` custom field type
|
||||
* [#7530](https://github.com/netbox-community/netbox/issues/7530) - Move device type component lists to separate views
|
||||
* [#7606](https://github.com/netbox-community/netbox/issues/7606) - Model transmit power for interfaces
|
||||
* [#7619](https://github.com/netbox-community/netbox/issues/7619) - Permit custom validation rules to be defined as plain data or dotted path to class
|
||||
* [#7761](https://github.com/netbox-community/netbox/issues/7761) - Extend cable tracing across bridged interfaces
|
||||
* [#7812](https://github.com/netbox-community/netbox/issues/7812) - Enable change logging for image attachments
|
||||
* [#7858](https://github.com/netbox-community/netbox/issues/7858) - Standardize the representation of content types across import & export functions
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#7589](https://github.com/netbox-community/netbox/issues/7589) - Correct 128GFC interface type identifier
|
||||
|
||||
### Other Changes
|
||||
|
||||
@@ -123,12 +164,25 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
|
||||
* tenancy.TenantGroup
|
||||
* virtualization.ClusterGroup
|
||||
* virtualization.ClusterType
|
||||
* circuits.CircuitTermination
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.Cable
|
||||
* Added `tenant` field
|
||||
* dcim.ConsolePort
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.ConsoleServerPort
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.Device
|
||||
* The `display` field now includes the device's asset tag, if set
|
||||
* Added `airflow` field
|
||||
* dcim.DeviceType
|
||||
* Added `airflow` field
|
||||
* dcim.FrontPort
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.Interface
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
@@ -143,8 +197,22 @@ Support for single sign-on (SSO) authentication has been added via the [python-s
|
||||
* Added `count_fhrp_groups` read-only field
|
||||
* dcim.Location
|
||||
* Added `tenant` field
|
||||
* dcim.PowerFeed
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.PowerOutlet
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.PowerPort
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.RearPort
|
||||
* `cable_peer` has been renamed to `link_peer`
|
||||
* `cable_peer_type` has been renamed to `link_peer_type`
|
||||
* dcim.Site
|
||||
* Added `asns` relationship to ipam.ASN
|
||||
* extras.ImageAttachment
|
||||
* Added the `last_updated` field
|
||||
* extras.Webhook
|
||||
* Added the `conditions` field
|
||||
* virtualization.VMInterface
|
||||
|
||||
@@ -4,9 +4,7 @@ from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import *
|
||||
from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect,
|
||||
)
|
||||
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect
|
||||
|
||||
__all__ = (
|
||||
'CircuitBulkEditForm',
|
||||
@@ -16,7 +14,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -55,7 +53,7 @@ class ProviderBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
|
||||
]
|
||||
|
||||
|
||||
class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -79,7 +77,7 @@ class ProviderNetworkBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
|
||||
]
|
||||
|
||||
|
||||
class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -93,7 +91,7 @@ class CircuitTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
|
||||
@@ -6,7 +6,7 @@ from circuits.models import *
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from extras.forms import CustomFieldModelFilterForm
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField
|
||||
|
||||
__all__ = (
|
||||
'CircuitFilterForm',
|
||||
@@ -16,18 +16,13 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class ProviderFilterForm(CustomFieldModelFilterForm):
|
||||
model = Provider
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['asn'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -57,17 +52,12 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
|
||||
model = ProviderNetwork
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('provider_id',),
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
provider_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
@@ -77,17 +67,12 @@ class ProviderNetworkFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CircuitTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class CircuitTypeFilterForm(CustomFieldModelFilterForm):
|
||||
model = CircuitType
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Circuit
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -96,11 +81,6 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
type_id = DynamicModelMultipleChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -19,7 +19,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ProviderForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
@@ -53,7 +53,7 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ProviderNetworkForm(CustomFieldModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all()
|
||||
)
|
||||
@@ -73,7 +73,7 @@ class ProviderNetworkForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class CircuitTypeForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -87,7 +87,7 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class CircuitForm(TenancyForm, CustomFieldModelForm):
|
||||
provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all()
|
||||
)
|
||||
|
||||
@@ -7,7 +7,6 @@ from circuits.choices import *
|
||||
from dcim.models import LinkTermination
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'Circuit',
|
||||
@@ -35,8 +34,6 @@ class CircuitType(OrganizationalModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -123,8 +120,6 @@ class Circuit(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
]
|
||||
@@ -195,8 +190,6 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['circuit', 'term_side']
|
||||
unique_together = ['circuit', 'term_side']
|
||||
|
||||
@@ -59,8 +59,6 @@ class Provider(PrimaryModel):
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact',
|
||||
]
|
||||
@@ -97,8 +95,6 @@ class ProviderNetwork(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('provider', 'name')
|
||||
constraints = (
|
||||
|
||||
@@ -340,7 +340,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualChassis
|
||||
fields = ['id', 'name', 'url', 'master', 'member_count']
|
||||
fields = ['id', 'url', 'display', 'name', 'master', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -352,7 +352,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
required=False
|
||||
)
|
||||
power_port = NestedPowerPortTemplateSerializer(
|
||||
required=False
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
feed_leg = ChoiceField(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
@@ -524,7 +525,7 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali
|
||||
)
|
||||
speed = ChoiceField(
|
||||
choices=ConsolePortSpeedChoices,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
@@ -548,7 +549,7 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
||||
)
|
||||
speed = ChoiceField(
|
||||
choices=ConsolePortSpeedChoices,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
@@ -571,7 +572,8 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C
|
||||
required=False
|
||||
)
|
||||
power_port = NestedPowerPortSerializer(
|
||||
required=False
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
feed_leg = ChoiceField(
|
||||
choices=PowerOutletFeedLegChoices,
|
||||
|
||||
@@ -204,6 +204,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
TYPE_RJ11 = 'rj-11'
|
||||
TYPE_RJ12 = 'rj-12'
|
||||
TYPE_RJ45 = 'rj-45'
|
||||
TYPE_MINI_DIN_8 = 'mini-din-8'
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_B = 'usb-b'
|
||||
TYPE_USB_C = 'usb-c'
|
||||
@@ -221,6 +222,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
(TYPE_RJ11, 'RJ-11'),
|
||||
(TYPE_RJ12, 'RJ-12'),
|
||||
(TYPE_RJ45, 'RJ-45'),
|
||||
(TYPE_MINI_DIN_8, 'Mini-DIN 8'),
|
||||
)),
|
||||
('USB', (
|
||||
(TYPE_USB_A, 'USB Type A'),
|
||||
@@ -329,6 +331,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L1560P = 'nema-l15-60p'
|
||||
TYPE_NEMA_L2120P = 'nema-l21-20p'
|
||||
TYPE_NEMA_L2130P = 'nema-l21-30p'
|
||||
TYPE_NEMA_L2230P = 'nema-l22-30p'
|
||||
# California style
|
||||
TYPE_CS6361C = 'cs6361c'
|
||||
TYPE_CS6365C = 'cs6365c'
|
||||
@@ -434,6 +437,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L1560P, 'NEMA L15-60P'),
|
||||
(TYPE_NEMA_L2120P, 'NEMA L21-20P'),
|
||||
(TYPE_NEMA_L2130P, 'NEMA L21-30P'),
|
||||
(TYPE_NEMA_L2230P, 'NEMA L22-30P'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6361C, 'CS6361C'),
|
||||
@@ -445,7 +449,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
)),
|
||||
('International/ITA', (
|
||||
(TYPE_ITA_C, 'ITA Type C (CEE 7/16)'),
|
||||
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
|
||||
(TYPE_ITA_E, 'ITA Type E (CEE 7/6)'),
|
||||
(TYPE_ITA_F, 'ITA Type F (CEE 7/4)'),
|
||||
(TYPE_ITA_EF, 'ITA Type E/F (CEE 7/7)'),
|
||||
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
|
||||
@@ -550,6 +554,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_NEMA_L1560R = 'nema-l15-60r'
|
||||
TYPE_NEMA_L2120R = 'nema-l21-20r'
|
||||
TYPE_NEMA_L2130R = 'nema-l21-30r'
|
||||
TYPE_NEMA_L2230R = 'nema-l22-30r'
|
||||
# California style
|
||||
TYPE_CS6360C = 'CS6360C'
|
||||
TYPE_CS6364C = 'CS6364C'
|
||||
@@ -569,6 +574,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_ITA_M = 'ita-m'
|
||||
TYPE_ITA_N = 'ita-n'
|
||||
TYPE_ITA_O = 'ita-o'
|
||||
TYPE_ITA_MULTISTANDARD = 'ita-multistandard'
|
||||
# USB
|
||||
TYPE_USB_A = 'usb-a'
|
||||
TYPE_USB_MICROB = 'usb-micro-b'
|
||||
@@ -647,6 +653,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_NEMA_L1560R, 'NEMA L15-60R'),
|
||||
(TYPE_NEMA_L2120R, 'NEMA L21-20R'),
|
||||
(TYPE_NEMA_L2130R, 'NEMA L21-30R'),
|
||||
(TYPE_NEMA_L2230R, 'NEMA L22-30R'),
|
||||
)),
|
||||
('California Style', (
|
||||
(TYPE_CS6360C, 'CS6360C'),
|
||||
@@ -657,8 +664,8 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_CS8464C, 'CS8464C'),
|
||||
)),
|
||||
('ITA/International', (
|
||||
(TYPE_ITA_E, 'ITA Type E (CEE7/5)'),
|
||||
(TYPE_ITA_F, 'ITA Type F (CEE7/3)'),
|
||||
(TYPE_ITA_E, 'ITA Type E (CEE 7/5)'),
|
||||
(TYPE_ITA_F, 'ITA Type F (CEE 7/3)'),
|
||||
(TYPE_ITA_G, 'ITA Type G (BS 1363)'),
|
||||
(TYPE_ITA_H, 'ITA Type H'),
|
||||
(TYPE_ITA_I, 'ITA Type I'),
|
||||
@@ -668,6 +675,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_ITA_M, 'ITA Type M (BS 546)'),
|
||||
(TYPE_ITA_N, 'ITA Type N'),
|
||||
(TYPE_ITA_O, 'ITA Type O'),
|
||||
(TYPE_ITA_MULTISTANDARD, 'ITA Multistandard'),
|
||||
)),
|
||||
('USB', (
|
||||
(TYPE_USB_A, 'USB Type A'),
|
||||
@@ -757,6 +765,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_80211AC = 'ieee802.11ac'
|
||||
TYPE_80211AD = 'ieee802.11ad'
|
||||
TYPE_80211AX = 'ieee802.11ax'
|
||||
TYPE_802151 = 'ieee802.15.1'
|
||||
|
||||
# Cellular
|
||||
TYPE_GSM = 'gsm'
|
||||
@@ -780,7 +789,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_16GFC_SFP_PLUS = '16gfc-sfpp'
|
||||
TYPE_32GFC_SFP28 = '32gfc-sfp28'
|
||||
TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp'
|
||||
TYPE_128GFC_QSFP28 = '128gfc-sfp28'
|
||||
TYPE_128GFC_QSFP28 = '128gfc-qsfp28'
|
||||
|
||||
# InfiniBand
|
||||
TYPE_INFINIBAND_SDR = 'infiniband-sdr'
|
||||
@@ -869,6 +878,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_80211AC, 'IEEE 802.11ac'),
|
||||
(TYPE_80211AD, 'IEEE 802.11ad'),
|
||||
(TYPE_80211AX, 'IEEE 802.11ax'),
|
||||
(TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'),
|
||||
)
|
||||
),
|
||||
(
|
||||
|
||||
@@ -718,7 +718,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
|
||||
field_name='interfaces__mac_address',
|
||||
label='MAC address',
|
||||
)
|
||||
serial = django_filters.CharFilter(
|
||||
serial = MultiValueCharFilter(
|
||||
lookup_expr='iexact'
|
||||
)
|
||||
has_primary_ip = django_filters.BooleanFilter(
|
||||
@@ -876,6 +876,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
to_field_name='name',
|
||||
label='Device (name)',
|
||||
)
|
||||
virtual_chassis_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__virtual_chassis',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
label='Virtual Chassis (ID)'
|
||||
)
|
||||
virtual_chassis = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='device__virtual_chassis__name',
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Virtual Chassis',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@@ -1247,7 +1258,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
method='filter_device',
|
||||
field_name='device__rack_id'
|
||||
)
|
||||
rack = MultiValueNumberFilter(
|
||||
rack = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__rack__name'
|
||||
)
|
||||
@@ -1255,7 +1266,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
|
||||
method='filter_device',
|
||||
field_name='device__site_id'
|
||||
)
|
||||
site = MultiValueNumberFilter(
|
||||
site = MultiValueCharFilter(
|
||||
method='filter_device',
|
||||
field_name='device__site__slug'
|
||||
)
|
||||
@@ -1416,6 +1427,10 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE
|
||||
#
|
||||
|
||||
class ConnectionFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = MultiValueNumberFilter(
|
||||
method='filter_connections',
|
||||
field_name='device__site_id'
|
||||
@@ -1438,6 +1453,15 @@ class ConnectionFilterSet(BaseFilterSet):
|
||||
return queryset
|
||||
return queryset.filter(**{f'{name}__in': value})
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(device__name__icontains=value) |
|
||||
Q(cable__label__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class ConsoleConnectionFilterSet(ConnectionFilterSet):
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ from django import forms
|
||||
from dcim.models import *
|
||||
from extras.forms import CustomFieldsMixin
|
||||
from extras.models import Tag
|
||||
from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, form_from_model
|
||||
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
|
||||
from .object_create import ComponentForm
|
||||
|
||||
__all__ = (
|
||||
@@ -23,7 +23,7 @@ __all__ = (
|
||||
# Device components
|
||||
#
|
||||
|
||||
class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
|
||||
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
|
||||
@@ -11,8 +11,8 @@ from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX
|
||||
from ipam.models import VLAN, ASN
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
|
||||
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
|
||||
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -52,7 +52,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -70,7 +70,7 @@ class RegionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
|
||||
nullable_fields = ['parent', 'description']
|
||||
|
||||
|
||||
class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -88,7 +88,7 @@ class SiteGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
nullable_fields = ['parent', 'description']
|
||||
|
||||
|
||||
class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -138,7 +138,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
||||
]
|
||||
|
||||
|
||||
class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -167,7 +167,7 @@ class LocationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
|
||||
nullable_fields = ['parent', 'tenant', 'description']
|
||||
|
||||
|
||||
class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RackRole.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -184,7 +184,7 @@ class RackRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
|
||||
nullable_fields = ['color', 'description']
|
||||
|
||||
|
||||
class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -284,7 +284,7 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
||||
]
|
||||
|
||||
|
||||
class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RackReservation.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -309,7 +309,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -323,7 +323,7 @@ class ManufacturerBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMod
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -351,7 +351,7 @@ class DeviceTypeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
|
||||
nullable_fields = ['airflow']
|
||||
|
||||
|
||||
class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -373,7 +373,7 @@ class DeviceRoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
|
||||
nullable_fields = ['color', 'description']
|
||||
|
||||
|
||||
class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -396,7 +396,7 @@ class PlatformBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBu
|
||||
nullable_fields = ['manufacturer', 'napalm_driver', 'description']
|
||||
|
||||
|
||||
class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -457,7 +457,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
|
||||
]
|
||||
|
||||
|
||||
class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Cable.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -513,7 +513,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
|
||||
})
|
||||
|
||||
|
||||
class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -527,7 +527,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldM
|
||||
nullable_fields = ['domain']
|
||||
|
||||
|
||||
class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -566,7 +566,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModel
|
||||
nullable_fields = ['location']
|
||||
|
||||
|
||||
class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerFeed.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -631,7 +631,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
# Device component templates
|
||||
#
|
||||
|
||||
class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class ConsolePortTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConsolePortTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -650,7 +650,7 @@ class ConsolePortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('label', 'type', 'description')
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class ConsoleServerPortTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConsoleServerPortTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -672,7 +672,7 @@ class ConsoleServerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('label', 'type', 'description')
|
||||
|
||||
|
||||
class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class PowerPortTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerPortTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -704,7 +704,7 @@ class PowerPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
|
||||
class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class PowerOutletTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=PowerOutletTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -752,7 +752,7 @@ class PowerOutletTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
self.fields['power_port'].widget.attrs['disabled'] = True
|
||||
|
||||
|
||||
class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class InterfaceTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=InterfaceTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -779,7 +779,7 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('label', 'description')
|
||||
|
||||
|
||||
class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class FrontPortTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FrontPortTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -804,7 +804,7 @@ class FrontPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class RearPortTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -829,7 +829,7 @@ class RearPortTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ('description',)
|
||||
|
||||
|
||||
class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class DeviceBayTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=DeviceBayTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -852,7 +852,6 @@ class DeviceBayTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
|
||||
class ConsolePortBulkEditForm(
|
||||
form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -871,7 +870,6 @@ class ConsolePortBulkEditForm(
|
||||
|
||||
class ConsoleServerPortBulkEditForm(
|
||||
form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -890,7 +888,6 @@ class ConsoleServerPortBulkEditForm(
|
||||
|
||||
class PowerPortBulkEditForm(
|
||||
form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -909,7 +906,6 @@ class PowerPortBulkEditForm(
|
||||
|
||||
class PowerOutletBulkEditForm(
|
||||
form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -948,7 +944,6 @@ class InterfaceBulkEditForm(
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
||||
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
]),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -1061,7 +1056,6 @@ class InterfaceBulkEditForm(
|
||||
|
||||
class FrontPortBulkEditForm(
|
||||
form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -1076,7 +1070,6 @@ class FrontPortBulkEditForm(
|
||||
|
||||
class RearPortBulkEditForm(
|
||||
form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -1091,7 +1084,6 @@ class RearPortBulkEditForm(
|
||||
|
||||
class DeviceBayBulkEditForm(
|
||||
form_from_model(DeviceBay, ['label', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
@@ -1106,7 +1098,6 @@ class DeviceBayBulkEditForm(
|
||||
|
||||
class InventoryItemBulkEditForm(
|
||||
form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
|
||||
BootstrapMixin,
|
||||
AddRemoveTagsForm,
|
||||
CustomFieldModelBulkEditForm
|
||||
):
|
||||
|
||||
@@ -3,7 +3,7 @@ from dcim.models import *
|
||||
from extras.forms import CustomFieldModelForm
|
||||
from extras.models import Tag
|
||||
from tenancy.forms import TenancyForm
|
||||
from utilities.forms import BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect
|
||||
|
||||
__all__ = (
|
||||
'ConnectCableToCircuitTerminationForm',
|
||||
@@ -18,7 +18,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ConnectCableToDeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
"""
|
||||
Base form for connecting a Cable to a Device component
|
||||
"""
|
||||
@@ -171,7 +171,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm):
|
||||
)
|
||||
|
||||
|
||||
class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm):
|
||||
termination_b_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label='Provider',
|
||||
@@ -217,8 +217,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_provider', 'termination_b_region', 'termination_b_site', 'termination_b_circuit',
|
||||
'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
@@ -230,7 +229,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, TenancyForm, CustomFi
|
||||
return getattr(self.cleaned_data['termination_b_id'], 'pk', None)
|
||||
|
||||
|
||||
class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm):
|
||||
termination_b_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label='Region',
|
||||
@@ -280,8 +279,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, TenancyForm, CustomFieldModelF
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
class Meta(ConnectCableToDeviceForm.Meta):
|
||||
fields = [
|
||||
'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group',
|
||||
'tenant', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
|
||||
@@ -9,7 +9,7 @@ from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterFor
|
||||
from ipam.models import ASN
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from utilities.forms import (
|
||||
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
|
||||
APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect,
|
||||
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from wireless.choices import *
|
||||
@@ -47,15 +47,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
field_order = [
|
||||
'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id',
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
class DeviceComponentFilterForm(CustomFieldModelFilterForm):
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
@@ -93,25 +85,27 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
label=_('Location'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
virtual_chassis_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Chassis'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'location_id': '$location_id',
|
||||
'virtual_chassis_id': '$virtual_chassis_id'
|
||||
},
|
||||
label=_('Device'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
|
||||
|
||||
class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class RegionFilterForm(CustomFieldModelFilterForm):
|
||||
model = Region
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -121,13 +115,8 @@ class RegionFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class SiteGroupFilterForm(CustomFieldModelFilterForm):
|
||||
model = SiteGroup
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
parent_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
@@ -137,20 +126,14 @@ class SiteGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Site
|
||||
field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id', 'asn_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['status', 'region_id', 'group_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['asn_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
status = forms.MultipleChoiceField(
|
||||
choices=SiteStatusChoices,
|
||||
required=False,
|
||||
@@ -177,18 +160,13 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Location
|
||||
field_groups = [
|
||||
['q'],
|
||||
['region_id', 'site_group_id', 'site_id', 'parent_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -224,19 +202,13 @@ class LocationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilt
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class RackRoleFilterForm(CustomFieldModelFilterForm):
|
||||
model = RackRole
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Rack
|
||||
field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_id', 'location_id'],
|
||||
@@ -244,11 +216,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
['type', 'width', 'serial', 'asset_tag'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -306,10 +273,6 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
|
||||
|
||||
class RackElevationFilterForm(RackFilterForm):
|
||||
field_order = [
|
||||
'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id',
|
||||
'tenant_id',
|
||||
]
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack'),
|
||||
@@ -322,20 +285,14 @@ class RackElevationFilterForm(RackFilterForm):
|
||||
)
|
||||
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['user_id'],
|
||||
['region_id', 'site_id', 'location_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -370,28 +327,18 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMo
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ManufacturerFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class ManufacturerFilterForm(CustomFieldModelFilterForm):
|
||||
model = Manufacturer
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class DeviceTypeFilterForm(CustomFieldModelFilterForm):
|
||||
model = DeviceType
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['manufacturer_id', 'subdevice_role', 'airflow'],
|
||||
['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -453,23 +400,13 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceRoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class DeviceRoleFilterForm(CustomFieldModelFilterForm):
|
||||
model = DeviceRole
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class PlatformFilterForm(CustomFieldModelFilterForm):
|
||||
model = Platform
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
@@ -479,12 +416,8 @@ class PlatformFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Device
|
||||
field_order = [
|
||||
'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
|
||||
'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
|
||||
]
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'],
|
||||
@@ -496,11 +429,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data',
|
||||
],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -651,19 +579,13 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = VirtualChassis
|
||||
field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id']
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -689,7 +611,7 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldMod
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Cable
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -697,11 +619,6 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
|
||||
['type', 'status', 'color'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -754,17 +671,12 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class PowerPanelFilterForm(CustomFieldModelFilterForm):
|
||||
model = PowerPanel
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('region_id', 'site_group_id', 'site_id', 'location_id')
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -800,7 +712,7 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class PowerFeedFilterForm(CustomFieldModelFilterForm):
|
||||
model = PowerFeed
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -808,11 +720,6 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
['power_panel_id', 'rack_id'],
|
||||
['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -895,7 +802,7 @@ class ConsolePortFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type', 'speed'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -915,7 +822,7 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type', 'speed'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -935,7 +842,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -950,7 +857,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -966,7 +873,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
|
||||
['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
kind = forms.MultipleChoiceField(
|
||||
choices=InterfaceKindChoices,
|
||||
@@ -1031,7 +938,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type', 'color'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
model = FrontPort
|
||||
type = forms.MultipleChoiceField(
|
||||
@@ -1050,7 +957,7 @@ class RearPortFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'type', 'color'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=PortTypeChoices,
|
||||
@@ -1068,7 +975,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
tag = TagFilterField(model)
|
||||
|
||||
@@ -1078,7 +985,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
|
||||
['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
|
||||
]
|
||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
@@ -1105,7 +1012,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
|
||||
# Connections
|
||||
#
|
||||
|
||||
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
class ConsoleConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -1132,7 +1039,7 @@ class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
class PowerConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -1159,7 +1066,7 @@ class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
|
||||
class InterfaceConnectionFilterForm(FilterForm):
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -66,7 +66,7 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN)
|
||||
"""
|
||||
|
||||
|
||||
class RegionForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class RegionForm(CustomFieldModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
@@ -84,7 +84,7 @@ class RegionForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class SiteGroupForm(CustomFieldModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False
|
||||
@@ -102,7 +102,7 @@ class SiteGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class SiteForm(TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
@@ -173,7 +173,7 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class LocationForm(TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -221,7 +221,7 @@ class LocationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class RackRoleForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -235,7 +235,7 @@ class RackRoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class RackForm(TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -295,7 +295,7 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class RackReservationForm(TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -365,7 +365,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ManufacturerForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -379,7 +379,7 @@ class ManufacturerForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class DeviceTypeForm(CustomFieldModelForm):
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all()
|
||||
)
|
||||
@@ -418,7 +418,7 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class DeviceRoleForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -432,7 +432,7 @@ class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class PlatformForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class PlatformForm(CustomFieldModelForm):
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False
|
||||
@@ -455,7 +455,7 @@ class PlatformForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class DeviceForm(TenancyForm, CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -637,7 +637,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
self.fields['position'].widget.choices = [(position, f'U{position}')]
|
||||
|
||||
|
||||
class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class CableForm(TenancyForm, CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -660,7 +660,7 @@ class CableForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class PowerPanelForm(CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -704,7 +704,7 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class PowerFeedForm(CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -772,7 +772,7 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class VirtualChassisForm(CustomFieldModelForm):
|
||||
master = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -1005,7 +1005,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ConsolePortForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1021,7 +1021,7 @@ class ConsolePortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ConsoleServerPortForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1037,7 +1037,7 @@ class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class PowerPortForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1054,7 +1054,7 @@ class PowerPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class PowerOutletForm(CustomFieldModelForm):
|
||||
power_port = forms.ModelChoiceField(
|
||||
queryset=PowerPort.objects.all(),
|
||||
required=False
|
||||
@@ -1083,7 +1083,7 @@ class PowerOutletForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
|
||||
class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
@@ -1183,7 +1183,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
|
||||
|
||||
|
||||
class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class FrontPortForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1211,7 +1211,7 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class RearPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class RearPortForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1228,7 +1228,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class DeviceBayForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class DeviceBayForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -1264,7 +1264,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
|
||||
).exclude(pk=device_bay.device.pk)
|
||||
|
||||
|
||||
class InventoryItemForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class InventoryItemForm(CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all()
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ComponentForm(forms.Form):
|
||||
class ComponentForm(BootstrapMixin, forms.Form):
|
||||
"""
|
||||
Subclass this form when facilitating the creation of one or more device component or component templates based on
|
||||
a name pattern.
|
||||
@@ -63,7 +63,7 @@ class ComponentForm(forms.Form):
|
||||
}, code='label_pattern_mismatch')
|
||||
|
||||
|
||||
class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class VirtualChassisCreateForm(CustomFieldModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -118,12 +118,18 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
|
||||
]
|
||||
|
||||
def clean(self):
|
||||
if self.cleaned_data['members'] and self.cleaned_data['initial_position'] is None:
|
||||
raise forms.ValidationError({
|
||||
'initial_position': "A position must be specified for the first VC member."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
instance = super().save(*args, **kwargs)
|
||||
|
||||
# Assign VC members
|
||||
if instance.pk:
|
||||
initial_position = self.cleaned_data.get('initial_position') or 1
|
||||
if instance.pk and self.cleaned_data['members']:
|
||||
initial_position = self.cleaned_data.get('initial_position', 1)
|
||||
for i, member in enumerate(self.cleaned_data['members'], start=initial_position):
|
||||
member.virtual_chassis = instance
|
||||
member.vc_position = i
|
||||
@@ -136,7 +142,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
# Component templates
|
||||
#
|
||||
|
||||
class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm):
|
||||
class ComponentTemplateCreateForm(ComponentForm):
|
||||
"""
|
||||
Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
|
||||
"""
|
||||
@@ -329,7 +335,7 @@ class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
|
||||
# Device components
|
||||
#
|
||||
|
||||
class ComponentCreateForm(BootstrapMixin, CustomFieldsMixin, ComponentForm):
|
||||
class ComponentCreateForm(CustomFieldsMixin, ComponentForm):
|
||||
"""
|
||||
Base form for the creation of device components (models subclassed from ComponentModel).
|
||||
"""
|
||||
@@ -459,12 +465,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
'type': 'lag',
|
||||
}
|
||||
},
|
||||
label='LAG'
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC Address'
|
||||
)
|
||||
wwn = forms.CharField(
|
||||
required=False,
|
||||
label='WWN'
|
||||
)
|
||||
mgmt_only = forms.BooleanField(
|
||||
required=False,
|
||||
label='Management only',
|
||||
@@ -497,15 +508,17 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
label='Untagged VLAN'
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
label='Tagged VLANs'
|
||||
)
|
||||
field_order = (
|
||||
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
|
||||
'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
|
||||
'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
# Generated by Django 3.2.8 on 2021-10-19 17:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -32,14 +30,54 @@ class Migration(migrations.Migration):
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='location',
|
||||
unique_together={('site', 'parent', 'name'), ('site', 'parent', 'slug')},
|
||||
unique_together=set(),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='region',
|
||||
unique_together={('parent', 'slug'), ('parent', 'name')},
|
||||
migrations.AddConstraint(
|
||||
model_name='location',
|
||||
constraint=models.UniqueConstraint(fields=('site', 'parent', 'name'), name='dcim_location_parent_name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='sitegroup',
|
||||
unique_together={('parent', 'slug'), ('parent', 'name')},
|
||||
migrations.AddConstraint(
|
||||
model_name='location',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'name'), name='dcim_location_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='location',
|
||||
constraint=models.UniqueConstraint(fields=('site', 'parent', 'slug'), name='dcim_location_parent_slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='location',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('site', 'slug'), name='dcim_location_slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='region',
|
||||
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_region_parent_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='region',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_region_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='region',
|
||||
constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_region_parent_slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='region',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_region_slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='sitegroup',
|
||||
constraint=models.UniqueConstraint(fields=('parent', 'name'), name='dcim_sitegroup_parent_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='sitegroup',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('name',), name='dcim_sitegroup_name'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='sitegroup',
|
||||
constraint=models.UniqueConstraint(fields=('parent', 'slug'), name='dcim_sitegroup_parent_slug'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='sitegroup',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('parent', None)), fields=('slug',), name='dcim_sitegroup_slug'),
|
||||
),
|
||||
]
|
||||
|
||||
29
netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py
Normal file
29
netbox/dcim/migrations/0142_rename_128gfc_qsfp28.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
OLD_VALUE = '128gfc-sfp28'
|
||||
NEW_VALUE = '128gfc-qsfp28'
|
||||
|
||||
|
||||
def correct_type(apps, schema_editor):
|
||||
"""
|
||||
Correct TYPE_128GFC_QSFP28 interface type.
|
||||
"""
|
||||
Interface = apps.get_model('dcim', 'Interface')
|
||||
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
|
||||
|
||||
for model in (Interface, InterfaceTemplate):
|
||||
model.objects.filter(type=OLD_VALUE).update(type=NEW_VALUE)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0141_asn_model'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=correct_type,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0053_asn_model'),
|
||||
('dcim', '0142_rename_128gfc_qsfp28'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='primary_ip4',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='primary_ip6',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='ipam.ipaddress'),
|
||||
),
|
||||
]
|
||||
@@ -14,7 +14,6 @@ from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_ob
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import BigIDModel, PrimaryModel
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import to_meters
|
||||
from .devices import Device
|
||||
from .device_components import FrontPort, RearPort
|
||||
@@ -116,8 +115,6 @@ class Cable(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['pk']
|
||||
unique_together = (
|
||||
|
||||
@@ -7,7 +7,6 @@ from dcim.constants import *
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.ordering import naturalize_interface
|
||||
from .device_components import (
|
||||
ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
|
||||
@@ -50,8 +49,6 @@ class ComponentTemplateModel(ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ from netbox.models import PrimaryModel
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from wireless.choices import *
|
||||
from wireless.utils import get_channel_attr
|
||||
@@ -65,8 +64,6 @@ class ComponentModel(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -189,15 +186,23 @@ class PathEndpoint(models.Model):
|
||||
abstract = True
|
||||
|
||||
def trace(self):
|
||||
if self._path is None:
|
||||
return []
|
||||
origin = self
|
||||
path = []
|
||||
|
||||
# Construct the complete path
|
||||
path = [self, *self._path.get_path()]
|
||||
while (len(path) + 1) % 3:
|
||||
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
|
||||
path.append(None)
|
||||
path.append(self._path.destination)
|
||||
while origin is not None:
|
||||
|
||||
if origin._path is None:
|
||||
break
|
||||
|
||||
path.extend([origin, *origin._path.get_path()])
|
||||
while (len(path) + 1) % 3:
|
||||
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort)
|
||||
path.append(None)
|
||||
path.append(origin._path.destination)
|
||||
|
||||
# Check for bridge interface to continue the trace
|
||||
origin = getattr(origin._path.destination, 'bridge', None)
|
||||
|
||||
# Return the path as a list of three-tuples (A termination, cable, B termination)
|
||||
return list(zip(*[iter(path)] * 3))
|
||||
|
||||
@@ -18,7 +18,6 @@ from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from .device_components import *
|
||||
|
||||
|
||||
@@ -59,8 +58,6 @@ class Manufacturer(OrganizationalModel):
|
||||
to='tenancy.ContactAssignment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -137,8 +134,6 @@ class DeviceType(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow',
|
||||
]
|
||||
@@ -379,8 +374,6 @@ class DeviceRole(OrganizationalModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -431,8 +424,6 @@ class Platform(OrganizationalModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -549,7 +540,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
primary_ip4 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='primary_ip4_for',
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv4'
|
||||
@@ -557,7 +548,7 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
primary_ip6 = models.OneToOneField(
|
||||
to='ipam.IPAddress',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='primary_ip6_for',
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='Primary IPv6'
|
||||
@@ -613,7 +604,9 @@ class Device(PrimaryModel, ConfigContextModel):
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
if self.name:
|
||||
if self.name and self.asset_tag:
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
@@ -896,8 +889,6 @@ class VirtualChassis(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
|
||||
@@ -8,7 +8,6 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import PrimaryModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import LinkTermination, PathEndpoint
|
||||
|
||||
@@ -49,8 +48,6 @@ class PowerPanel(PrimaryModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = ['site', 'name']
|
||||
@@ -131,8 +128,6 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'available_power',
|
||||
|
||||
@@ -18,7 +18,6 @@ from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import array_to_string
|
||||
from .device_components import PowerOutlet, PowerPort
|
||||
from .devices import Device
|
||||
@@ -56,8 +55,6 @@ class RackRole(OrganizationalModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
@@ -190,8 +187,6 @@ class Rack(PrimaryModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
|
||||
'outer_depth', 'outer_unit',
|
||||
@@ -471,8 +466,6 @@ class RackReservation(PrimaryModel):
|
||||
max_length=200
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['created', 'pk']
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from dcim.fields import ASNField
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import NestedGroupModel, PrimaryModel
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'Location',
|
||||
@@ -63,11 +62,41 @@ class Region(NestedGroupModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
('parent', 'name'),
|
||||
('parent', 'slug'),
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='dcim_region_parent_name'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('name',),
|
||||
name='dcim_region_name',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='dcim_region_parent_slug'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('slug',),
|
||||
name='dcim_region_slug',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
if self.parent is None:
|
||||
regions = Region.objects.exclude(pk=self.pk)
|
||||
if regions.filter(name=self.name, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
'name': 'A region with this name already exists.'
|
||||
})
|
||||
if regions.filter(slug=self.slug, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
'name': 'A region with this slug already exists.'
|
||||
})
|
||||
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:region', args=[self.pk])
|
||||
|
||||
@@ -120,11 +149,41 @@ class SiteGroup(NestedGroupModel):
|
||||
)
|
||||
|
||||
class Meta:
|
||||
unique_together = (
|
||||
('parent', 'name'),
|
||||
('parent', 'slug'),
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='dcim_sitegroup_parent_name'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('name',),
|
||||
name='dcim_sitegroup_name',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='dcim_sitegroup_parent_slug'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('slug',),
|
||||
name='dcim_sitegroup_slug',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
if self.parent is None:
|
||||
site_groups = SiteGroup.objects.exclude(pk=self.pk)
|
||||
if site_groups.filter(name=self.name, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
'name': 'A site group with this name already exists.'
|
||||
})
|
||||
if site_groups.filter(slug=self.slug, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
'name': 'A site group with this slug already exists.'
|
||||
})
|
||||
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:sitegroup', args=[self.pk])
|
||||
|
||||
@@ -259,8 +318,6 @@ class Site(PrimaryModel):
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email',
|
||||
@@ -338,10 +395,40 @@ class Location(NestedGroupModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = ([
|
||||
('site', 'parent', 'name'),
|
||||
('site', 'parent', 'slug'),
|
||||
])
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('site', 'parent', 'name'),
|
||||
name='dcim_location_parent_name'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('site', 'name'),
|
||||
name='dcim_location_name',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('site', 'parent', 'slug'),
|
||||
name='dcim_location_parent_slug'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('site', 'slug'),
|
||||
name='dcim_location_slug',
|
||||
condition=Q(parent=None)
|
||||
),
|
||||
)
|
||||
|
||||
def validate_unique(self, exclude=None):
|
||||
if self.parent is None:
|
||||
locations = Location.objects.exclude(pk=self.pk)
|
||||
if locations.filter(name=self.name, site=self.site, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
"name": f"A location with this name in site {self.site} already exists."
|
||||
})
|
||||
if locations.filter(slug=self.slug, site=self.site, parent__isnull=True).exists():
|
||||
raise ValidationError({
|
||||
"name": f"A location with this slug in site {self.site} already exists."
|
||||
})
|
||||
|
||||
super().validate_unique(exclude=exclude)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:location', args=[self.pk])
|
||||
|
||||
@@ -478,15 +478,16 @@ class CableTraceSVG:
|
||||
parent_objects.append(parent_object)
|
||||
|
||||
# Near end termination
|
||||
termination = self._draw_box(
|
||||
width=self.width * .8,
|
||||
color=self._get_color(near_end),
|
||||
url=near_end.get_absolute_url(),
|
||||
labels=self._get_labels(near_end),
|
||||
y_indent=PADDING,
|
||||
radius=5
|
||||
)
|
||||
terminations.append(termination)
|
||||
if near_end is not None:
|
||||
termination = self._draw_box(
|
||||
width=self.width * .8,
|
||||
color=self._get_color(near_end),
|
||||
url=near_end.get_absolute_url(),
|
||||
labels=self._get_labels(near_end),
|
||||
y_indent=PADDING,
|
||||
radius=5
|
||||
)
|
||||
terminations.append(termination)
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if connector is not None:
|
||||
|
||||
@@ -49,6 +49,14 @@ def get_cabletermination_row_class(record):
|
||||
return ''
|
||||
|
||||
|
||||
def get_interface_row_class(record):
|
||||
if not record.enabled:
|
||||
return 'danger'
|
||||
elif record.is_virtual:
|
||||
return 'primary'
|
||||
return get_cabletermination_row_class(record)
|
||||
|
||||
|
||||
def get_interface_state_attribute(record):
|
||||
"""
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
@@ -467,6 +475,12 @@ class BaseInterfaceTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
fhrp_groups = tables.TemplateColumn(
|
||||
accessor=Accessor('fhrp_group_assignments'),
|
||||
template_code=INTERFACE_FHRPGROUPS,
|
||||
orderable=False,
|
||||
verbose_name='FHRP Groups'
|
||||
)
|
||||
untagged_vlan = tables.Column(linkify=True)
|
||||
tagged_vlans = TemplateColumn(
|
||||
template_code=INTERFACE_TAGGED_VLANS,
|
||||
@@ -501,14 +515,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
class DeviceInterfaceTable(InterfaceTable):
|
||||
name = tables.TemplateColumn(
|
||||
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}drag-horizontal-variant'
|
||||
template_code='<i class="mdi mdi-{% if record.mgmt_only %}wrench{% elif record.is_lag %}reorder-horizontal'
|
||||
'{% elif record.is_virtual %}circle{% elif record.is_wireless %}wifi{% else %}ethernet'
|
||||
'{% endif %}"></i> <a href="{{ record.get_absolute_url }}">{{ value }}</a>',
|
||||
order_by=Accessor('_name'),
|
||||
@@ -534,9 +548,9 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode',
|
||||
'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
|
||||
'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
)
|
||||
order_by = ('name',)
|
||||
default_columns = (
|
||||
@@ -544,7 +558,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'cable', 'connection', 'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class,
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
}
|
||||
@@ -663,7 +677,8 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
}
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=DEVICEBAY_STATUS
|
||||
template_code=DEVICEBAY_STATUS,
|
||||
order_by=Accessor('installed_device__status')
|
||||
)
|
||||
installed_device = tables.Column(
|
||||
linkify=True
|
||||
|
||||
@@ -75,12 +75,20 @@ class RackTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rack_list'
|
||||
)
|
||||
outer_width = tables.TemplateColumn(
|
||||
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
|
||||
verbose_name='Outer Width'
|
||||
)
|
||||
outer_depth = tables.TemplateColumn(
|
||||
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
|
||||
verbose_name='Outer Depth'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
|
||||
@@ -40,17 +40,21 @@ DEVICEBAY_STATUS = """
|
||||
|
||||
INTERFACE_IPADDRESSES = """
|
||||
<div class="table-badge-group">
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
<a
|
||||
class="table-badge{% if ip.status != 'active' %} badge bg-{{ ip.get_status_class }}{% elif ip.role %} badge bg-{{ ip.get_role_class }}{% endif %}"
|
||||
href="{{ ip.get_absolute_url }}"
|
||||
{% if ip.status != 'active'%}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}"
|
||||
{% elif ip.role %}data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_role_display }}"
|
||||
{% endif %}
|
||||
>
|
||||
{{ ip }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% for ip in record.ip_addresses.all %}
|
||||
{% if ip.status != 'active' %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge badge bg-{{ ip.get_status_class }}" data-bs-toggle="tooltip" data-bs-placement="left" title="{{ ip.get_status_display }}">{{ ip }}</a>
|
||||
{% else %}
|
||||
<a href="{{ ip.get_absolute_url }}" class="table-badge">{{ ip }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
INTERFACE_FHRPGROUPS = """
|
||||
<div class="table-badge-group">
|
||||
{% for assignment in value.all %}
|
||||
<a href="{{ assignment.group.get_absolute_url }}">{{ assignment.group.get_protocol_display }}: {{ assignment.group.group_id }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
@@ -595,6 +595,12 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
|
||||
power_port_templates = (
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 1'),
|
||||
PowerPortTemplate(device_type=devicetype, name='Power Port Template 2'),
|
||||
)
|
||||
PowerPortTemplate.objects.bulk_create(power_port_templates)
|
||||
|
||||
power_outlet_templates = (
|
||||
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 1'),
|
||||
PowerOutletTemplate(device_type=devicetype, name='Power Outlet Template 2'),
|
||||
@@ -606,14 +612,17 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Power Outlet Template 4',
|
||||
'power_port': power_port_templates[0].pk,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Power Outlet Template 5',
|
||||
'power_port': power_port_templates[1].pk,
|
||||
},
|
||||
{
|
||||
'device_type': devicetype.pk,
|
||||
'name': 'Power Outlet Template 6',
|
||||
'power_port': None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1044,14 +1053,17 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Port 4',
|
||||
'speed': 9600,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Port 5',
|
||||
'speed': 115200,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Port 6',
|
||||
'speed': None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1083,14 +1095,17 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Server Port 4',
|
||||
'speed': 9600,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Server Port 5',
|
||||
'speed': 115200,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Console Server Port 6',
|
||||
'speed': None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1150,6 +1165,12 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
|
||||
device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
|
||||
|
||||
power_ports = (
|
||||
PowerPort(device=device, name='Power Port 1'),
|
||||
PowerPort(device=device, name='Power Port 2'),
|
||||
)
|
||||
PowerPort.objects.bulk_create(power_ports)
|
||||
|
||||
power_outlets = (
|
||||
PowerOutlet(device=device, name='Power Outlet 1'),
|
||||
PowerOutlet(device=device, name='Power Outlet 2'),
|
||||
@@ -1161,14 +1182,17 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Power Outlet 4',
|
||||
'power_port': power_ports[0].pk,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Power Outlet 5',
|
||||
'power_port': power_ports[1].pk,
|
||||
},
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Power Outlet 6',
|
||||
'power_port': None,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1548,7 +1572,7 @@ class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualChassis
|
||||
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
|
||||
brief_fields = ['display', 'id', 'master', 'member_count', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -1420,10 +1420,10 @@ class DeviceTestCase(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_has_primary_ip(self):
|
||||
params = {'has_primary_ip': 'true'}
|
||||
@@ -2073,6 +2073,11 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
# VirtualChassis assignment for filtering
|
||||
virtual_chassis = VirtualChassis.objects.create(master=devices[0])
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||
|
||||
interfaces = (
|
||||
Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'),
|
||||
Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'),
|
||||
@@ -2197,6 +2202,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'location': [locations[0].slug, locations[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_virtual_chassis_id(self):
|
||||
params = {'virtual_chassis_id': [VirtualChassis.objects.first().pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_device(self):
|
||||
devices = Device.objects.all()[:2]
|
||||
params = {'device_id': [devices[0].pk, devices[1].pk]}
|
||||
|
||||
@@ -157,6 +157,7 @@ class RegionView(generic.ObjectView):
|
||||
parent__in=instance.get_descendants(include_self=True)
|
||||
)
|
||||
child_regions_table = tables.RegionTable(child_regions)
|
||||
child_regions_table.columns.hide('actions')
|
||||
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
region=instance
|
||||
@@ -241,6 +242,7 @@ class SiteGroupView(generic.ObjectView):
|
||||
parent__in=instance.get_descendants(include_self=True)
|
||||
)
|
||||
child_groups_table = tables.SiteGroupTable(child_groups)
|
||||
child_groups_table.columns.hide('actions')
|
||||
|
||||
sites = Site.objects.restrict(request.user, 'view').filter(
|
||||
group=instance
|
||||
@@ -310,6 +312,7 @@ class SiteView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
stats = {
|
||||
'location_count': Location.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'rack_count': Rack.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'device_count': Device.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
'prefix_count': Prefix.objects.restrict(request.user, 'view').filter(site=instance).count(),
|
||||
|
||||
@@ -27,11 +27,14 @@ class ConfigRevisionAdmin(admin.ModelAdmin):
|
||||
('Pagination', {
|
||||
'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'),
|
||||
}),
|
||||
('Validation', {
|
||||
'fields': ('CUSTOM_VALIDATORS',),
|
||||
}),
|
||||
('NAPALM', {
|
||||
'fields': ('NAPALM_USERNAME', 'NAPALM_PASSWORD', 'NAPALM_TIMEOUT', 'NAPALM_ARGS'),
|
||||
}),
|
||||
('Miscellaneous', {
|
||||
'fields': ('MAINTENANCE_MODE', 'MAPS_URL'),
|
||||
'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'MAPS_URL'),
|
||||
}),
|
||||
('Config Revision', {
|
||||
'fields': ('comment',),
|
||||
|
||||
@@ -150,7 +150,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
model = ImageAttachment
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height',
|
||||
'image_width', 'created',
|
||||
'image_width', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
@@ -2,8 +2,9 @@ from contextlib import contextmanager
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||
|
||||
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
|
||||
from utilities.utils import curry
|
||||
from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
|
||||
from netbox import thread_locals
|
||||
from netbox.request_context import set_request
|
||||
from .webhooks import flush_webhooks
|
||||
|
||||
|
||||
@@ -15,12 +16,8 @@ def change_logging(request):
|
||||
|
||||
:param request: WSGIRequest object with a unique `id` set
|
||||
"""
|
||||
webhook_queue = []
|
||||
|
||||
# Curry signals receivers to pass the current request
|
||||
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
|
||||
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
|
||||
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
|
||||
set_request(request)
|
||||
thread_locals.webhook_queue = []
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
@@ -38,5 +35,8 @@ def change_logging(request):
|
||||
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_webhooks(webhook_queue)
|
||||
del webhook_queue
|
||||
flush_webhooks(thread_locals.webhook_queue)
|
||||
del thread_locals.webhook_queue
|
||||
|
||||
# Clear the request from thread-local storage
|
||||
set_request(None)
|
||||
|
||||
@@ -28,6 +28,10 @@ __all__ = (
|
||||
|
||||
|
||||
class WebhookFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
http_method = django_filters.MultipleChoiceFilter(
|
||||
choices=WebhookHttpMethodChoices
|
||||
@@ -40,30 +44,81 @@ class WebhookFilterSet(BaseFilterSet):
|
||||
'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(payload_url__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
content_types = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(label__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(link_text__icontains=value) |
|
||||
Q(link_url__icontains=value) |
|
||||
Q(group_name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ExportTemplateFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
created = django_filters.DateTimeFilter()
|
||||
content_type = ContentTypeFilter()
|
||||
|
||||
@@ -71,6 +126,11 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
model = ImageAttachment
|
||||
fields = ['id', 'content_type_id', 'object_id', 'name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(name__icontains=value)
|
||||
|
||||
|
||||
class JournalEntryFilterSet(ChangeLoggedModelFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -4,9 +4,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from utilities.forms import (
|
||||
BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
|
||||
)
|
||||
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
|
||||
|
||||
__all__ = (
|
||||
'ConfigContextBulkEditForm',
|
||||
@@ -19,7 +17,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class CustomFieldBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=CustomField.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -39,7 +37,7 @@ class CustomFieldBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class CustomLinkBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=CustomLink.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -66,7 +64,7 @@ class CustomLinkBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class ExportTemplateBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ExportTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -97,7 +95,7 @@ class ExportTemplateBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ['description', 'mime_type', 'file_extension']
|
||||
|
||||
|
||||
class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class WebhookBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Webhook.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -140,7 +138,7 @@ class WebhookBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ['secret', 'conditions', 'ca_file_path']
|
||||
|
||||
|
||||
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class TagBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -157,7 +155,7 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class ConfigContextBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ConfigContext.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -181,7 +179,7 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
]
|
||||
|
||||
|
||||
class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
class JournalEntryBulkEditForm(BulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=JournalEntry.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.db.models import Q
|
||||
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.forms import BulkEditForm, CSVModelForm
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldModelCSVForm',
|
||||
@@ -52,7 +52,7 @@ class CustomFieldsMixin:
|
||||
self.custom_fields.append(field_name)
|
||||
|
||||
|
||||
class CustomFieldModelForm(CustomFieldsMixin, forms.ModelForm):
|
||||
class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm):
|
||||
"""
|
||||
Extend ModelForm to include custom field support.
|
||||
"""
|
||||
@@ -105,7 +105,7 @@ class CustomFieldModelBulkEditForm(BulkEditForm):
|
||||
self.custom_fields.append(cf.name)
|
||||
|
||||
|
||||
class CustomFieldModelFilterForm(forms.Form):
|
||||
class CustomFieldModelFilterForm(FilterForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
|
||||
@@ -9,9 +9,8 @@ from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelectMultiple, BootstrapMixin, ContentTypeChoiceField,
|
||||
ContentTypeMultipleChoiceField, DateTimePicker, DynamicModelMultipleChoiceField, StaticSelect,
|
||||
StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
add_blank_choice, APISelectMultiple, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DateTimePicker,
|
||||
DynamicModelMultipleChoiceField, FilterForm, StaticSelect, StaticSelectMultiple, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
|
||||
@@ -28,17 +27,12 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldFilterForm(BootstrapMixin, forms.Form):
|
||||
class CustomFieldFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['type', 'content_types'],
|
||||
['weight', 'required'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
@@ -61,16 +55,11 @@ class CustomFieldFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class CustomLinkFilterForm(BootstrapMixin, forms.Form):
|
||||
class CustomLinkFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['content_type', 'weight', 'new_window'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
@@ -87,16 +76,11 @@ class CustomLinkFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
|
||||
class ExportTemplateFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['content_type', 'mime_type', 'file_extension', 'as_attachment'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
@@ -117,17 +101,12 @@ class ExportTemplateFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class WebhookFilterForm(BootstrapMixin, forms.Form):
|
||||
class WebhookFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['content_types', 'http_method', 'enabled'],
|
||||
['type_create', 'type_update', 'type_delete'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
content_types = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
@@ -165,12 +144,8 @@ class WebhookFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class TagFilterForm(BootstrapMixin, forms.Form):
|
||||
class TagFilterForm(FilterForm):
|
||||
model = Tag
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label=_('Search')
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
|
||||
required=False,
|
||||
@@ -178,7 +153,7 @@ class TagFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
||||
class ConfigContextFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
@@ -186,11 +161,6 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
||||
['cluster_group_id', 'cluster_id'],
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -270,18 +240,13 @@ class LocalConfigContextFilterForm(forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
class JournalEntryFilterForm(FilterForm):
|
||||
model = JournalEntry
|
||||
field_groups = [
|
||||
['q'],
|
||||
['created_before', 'created_after', 'created_by_id'],
|
||||
['assigned_object_type_id', 'kind']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
created_after = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('After'),
|
||||
@@ -317,18 +282,13 @@ class JournalEntryFilterForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
class ObjectChangeFilterForm(FilterForm):
|
||||
model = ObjectChange
|
||||
field_groups = [
|
||||
['q'],
|
||||
['time_before', 'time_after', 'action'],
|
||||
['user_id', 'changed_object_type_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
time_after = forms.DateTimeField(
|
||||
required=False,
|
||||
label=_('After'),
|
||||
|
||||
@@ -70,7 +70,7 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_links')
|
||||
limit_choices_to=FeatureQuery('export_templates')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -10,12 +10,14 @@ from django.utils import timezone
|
||||
from packaging import version
|
||||
|
||||
from extras.models import ObjectChange
|
||||
from netbox.config import Config
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
config = Config()
|
||||
|
||||
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
|
||||
if options['verbosity']:
|
||||
@@ -37,10 +39,10 @@ class Command(BaseCommand):
|
||||
# Delete expired ObjectRecords
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if settings.CHANGELOG_RETENTION:
|
||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||
if config.CHANGELOG_RETENTION:
|
||||
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tRetention period: {config.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tCut-off time: {cutoff}")
|
||||
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
|
||||
if expired_records:
|
||||
@@ -58,7 +60,7 @@ class Command(BaseCommand):
|
||||
self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
|
||||
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {config.CHANGELOG_RETENTION})"
|
||||
)
|
||||
|
||||
# Check for new releases (if enabled)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0064_configrevision'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='imageattachment',
|
||||
name='last_updated',
|
||||
field=models.DateTimeField(auto_now=True, null=True),
|
||||
),
|
||||
]
|
||||
18
netbox/extras/migrations/0066_customfield_name_validation.py
Normal file
18
netbox/extras/migrations/0066_customfield_name_validation.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import re
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0065_imageattachment_change_logging'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='name',
|
||||
field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]),
|
||||
),
|
||||
]
|
||||
@@ -22,6 +22,12 @@ from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CustomField',
|
||||
'CustomFieldManager',
|
||||
)
|
||||
|
||||
|
||||
class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
use_in_migrations = True
|
||||
|
||||
@@ -33,7 +39,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomField(ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
@@ -49,7 +55,14 @@ class CustomField(ChangeLoggedModel):
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
help_text='Internal field name'
|
||||
help_text='Internal field name',
|
||||
validators=(
|
||||
RegexValidator(
|
||||
regex=r'^[a-z0-9_]+$',
|
||||
message="Only alphanumeric characters and underscores are allowed.",
|
||||
flags=re.IGNORECASE
|
||||
),
|
||||
)
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=50,
|
||||
|
||||
@@ -35,7 +35,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Webhook(ChangeLoggedModel):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
@@ -125,8 +125,6 @@ class Webhook(ChangeLoggedModel):
|
||||
'Leave blank to use the system defaults.'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
|
||||
@@ -179,7 +177,7 @@ class Webhook(ChangeLoggedModel):
|
||||
return json.dumps(context, cls=JSONEncoder)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomLink(ChangeLoggedModel):
|
||||
"""
|
||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||
@@ -222,8 +220,6 @@ class CustomLink(ChangeLoggedModel):
|
||||
help_text="Force link to open in a new window"
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
|
||||
@@ -234,7 +230,7 @@ class CustomLink(ChangeLoggedModel):
|
||||
return reverse('extras:customlink', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class ExportTemplate(ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
@@ -268,8 +264,6 @@ class ExportTemplate(ChangeLoggedModel):
|
||||
help_text="Download file as attachment"
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['content_type', 'name']
|
||||
unique_together = [
|
||||
@@ -323,7 +317,8 @@ class ExportTemplate(ChangeLoggedModel):
|
||||
return response
|
||||
|
||||
|
||||
class ImageAttachment(BigIDModel):
|
||||
@extras_features('webhooks')
|
||||
class ImageAttachment(ChangeLoggedModel):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
"""
|
||||
@@ -347,12 +342,15 @@ class ImageAttachment(BigIDModel):
|
||||
max_length=50,
|
||||
blank=True
|
||||
)
|
||||
# ChangeLoggingMixin.created is a DateField
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = ('content_type', 'object_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # name may be non-unique
|
||||
|
||||
@@ -394,6 +392,9 @@ class ImageAttachment(BigIDModel):
|
||||
except tuple(expected_exceptions):
|
||||
return None
|
||||
|
||||
def to_objectchange(self, action):
|
||||
return super().to_objectchange(action, related_object=self.parent)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class JournalEntry(ChangeLoggedModel):
|
||||
@@ -427,8 +428,6 @@ class JournalEntry(ChangeLoggedModel):
|
||||
)
|
||||
comments = models.TextField()
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('-created',)
|
||||
verbose_name_plural = 'journal entries'
|
||||
|
||||
@@ -7,14 +7,13 @@ from extras.utils import extras_features
|
||||
from netbox.models import BigIDModel, ChangeLoggedModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Tag(ChangeLoggedModel, TagBase):
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
@@ -24,8 +23,6 @@ class Tag(ChangeLoggedModel, TagBase):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import sys
|
||||
import traceback
|
||||
from collections import OrderedDict
|
||||
|
||||
@@ -477,6 +478,10 @@ def get_scripts(use_names=False):
|
||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
# Remove cached module to ensure consistency with filesystem
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
module = importer.find_module(module_name).load_module(module_name)
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import importlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import receiver, Signal
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.validators import CustomValidator
|
||||
from netbox import thread_locals
|
||||
from netbox.config import get_config
|
||||
from netbox.request_context import get_request
|
||||
from netbox.signals import post_clean
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ConfigRevision, CustomField, ObjectChange
|
||||
from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
|
||||
|
||||
#
|
||||
# Change logging/webhooks
|
||||
#
|
||||
@@ -20,10 +23,16 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
clear_webhooks = Signal()
|
||||
|
||||
|
||||
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
def handle_changed_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
"""
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
request = get_request()
|
||||
m2m_changed = False
|
||||
|
||||
def is_same_object(instance, webhook_data):
|
||||
return (
|
||||
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
|
||||
@@ -31,11 +40,6 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
request.id == webhook_data['request_id']
|
||||
)
|
||||
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
m2m_changed = False
|
||||
|
||||
# Determine the type of change being made
|
||||
if kwargs.get('created'):
|
||||
action = ObjectChangeActionChoices.ACTION_CREATE
|
||||
@@ -65,6 +69,7 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
objectchange.save()
|
||||
|
||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
|
||||
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
|
||||
webhook_queue[-1]['data'] = serialize_for_webhook(instance)
|
||||
@@ -79,13 +84,15 @@ def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
model_updates.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
def handle_deleted_object(sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is deleted.
|
||||
"""
|
||||
if not hasattr(instance, 'to_objectchange'):
|
||||
return
|
||||
|
||||
request = get_request()
|
||||
|
||||
# Record an ObjectChange if applicable
|
||||
if hasattr(instance, 'to_objectchange'):
|
||||
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
|
||||
@@ -94,19 +101,21 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
|
||||
def clear_webhook_queue(sender, **kwargs):
|
||||
"""
|
||||
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
|
||||
"""
|
||||
logger = logging.getLogger('webhooks')
|
||||
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
|
||||
webhook_queue = thread_locals.webhook_queue
|
||||
|
||||
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
|
||||
webhook_queue.clear()
|
||||
|
||||
|
||||
@@ -157,9 +166,21 @@ m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_type
|
||||
|
||||
@receiver(post_clean)
|
||||
def run_custom_validators(sender, instance, **kwargs):
|
||||
config = get_config()
|
||||
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
|
||||
validators = settings.CUSTOM_VALIDATORS.get(model_name, [])
|
||||
validators = config.CUSTOM_VALIDATORS.get(model_name, [])
|
||||
|
||||
for validator in validators:
|
||||
|
||||
# Loading a validator class by dotted path
|
||||
if type(validator) is str:
|
||||
module, cls = validator.rsplit('.', 1)
|
||||
validator = getattr(importlib.import_module(module), cls)()
|
||||
|
||||
# Constructing a new instance on the fly from a ruleset
|
||||
elif type(validator) is dict:
|
||||
validator = CustomValidator(validator)
|
||||
|
||||
validator(instance)
|
||||
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
tags = create_tags('Tag 1', 'Tag 2', 'Tag 3')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
site.tags.set(['Tag 1', 'Tag 2'])
|
||||
|
||||
form_data = {
|
||||
'name': 'Site X',
|
||||
@@ -117,7 +117,7 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
)
|
||||
site.save()
|
||||
create_tags('Tag 1', 'Tag 2')
|
||||
site.tags.set('Tag 1', 'Tag 2')
|
||||
site.tags.set(['Tag 1', 'Tag 2'])
|
||||
|
||||
request = {
|
||||
'path': self._get_url('delete', instance=site),
|
||||
@@ -310,7 +310,7 @@ class ChangeLogAPITest(APITestCase):
|
||||
}
|
||||
)
|
||||
site.save()
|
||||
site.tags.set(*Tag.objects.all()[:2])
|
||||
site.tags.set(Tag.objects.all()[:2])
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
self.add_permissions('dcim.delete_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
|
||||
@@ -119,3 +119,38 @@ class CustomValidatorTest(TestCase):
|
||||
@override_settings(CUSTOM_VALIDATORS={'dcim.site': [custom_validator]})
|
||||
def test_custom_valid(self):
|
||||
Site(name='foo', slug='foo').clean()
|
||||
|
||||
|
||||
class CustomValidatorConfigTest(TestCase):
|
||||
|
||||
@override_settings(
|
||||
CUSTOM_VALIDATORS={
|
||||
'dcim.site': [
|
||||
{'name': {'min_length': 5}}
|
||||
]
|
||||
}
|
||||
)
|
||||
def test_plain_data(self):
|
||||
"""
|
||||
Test custom validator configuration using plain data (as opposed to a CustomValidator
|
||||
class)
|
||||
"""
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='abcd', slug='abcd').clean()
|
||||
Site(name='abcde', slug='abcde').clean()
|
||||
|
||||
@override_settings(
|
||||
CUSTOM_VALIDATORS={
|
||||
'dcim.site': (
|
||||
'extras.tests.test_customvalidator.MyValidator',
|
||||
)
|
||||
}
|
||||
)
|
||||
def test_dotted_path(self):
|
||||
"""
|
||||
Test custom validator configuration using a dotted path (string) reference to a
|
||||
CustomValidator class.
|
||||
"""
|
||||
Site(name='foo', slug='foo').clean()
|
||||
with self.assertRaises(ValidationError):
|
||||
Site(name='bar', slug='bar').clean()
|
||||
|
||||
@@ -542,8 +542,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
provider = Provider.objects.create(name='Provider 1', slug='provider-1')
|
||||
|
||||
site.tags.set(tags[0])
|
||||
provider.tags.set(tags[1])
|
||||
site.tags.set([tags[0]])
|
||||
provider.tags.set([tags[1]])
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Tag 1', 'Tag 2']}
|
||||
|
||||
@@ -123,7 +123,7 @@ class WebhookTest(APITestCase):
|
||||
|
||||
def test_enqueue_webhook_update(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
# Update an object via the REST API
|
||||
data = {
|
||||
@@ -159,7 +159,7 @@ class WebhookTest(APITestCase):
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
for site in sites:
|
||||
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
# Update three objects via the REST API
|
||||
data = [
|
||||
@@ -205,7 +205,7 @@ class WebhookTest(APITestCase):
|
||||
|
||||
def test_enqueue_webhook_delete(self):
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
# Delete an object via the REST API
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
@@ -231,7 +231,7 @@ class WebhookTest(APITestCase):
|
||||
)
|
||||
Site.objects.bulk_create(sites)
|
||||
for site in sites:
|
||||
site.tags.set(*Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
site.tags.set(Tag.objects.filter(name__in=['Foo', 'Bar']))
|
||||
|
||||
# Delete three objects via the REST API
|
||||
data = [
|
||||
|
||||
@@ -11,7 +11,7 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.utils import copy_safe_request, count_related, shallow_compare_dict
|
||||
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
@@ -475,11 +475,7 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
def alter_obj(self, instance, request, args, kwargs):
|
||||
if not instance.pk:
|
||||
# Assign the parent object based on URL kwargs
|
||||
try:
|
||||
app_label, model = request.GET.get('content_type').split('.')
|
||||
except (AttributeError, ValueError):
|
||||
raise Http404("Content type not specified")
|
||||
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
|
||||
content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type'))
|
||||
instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
|
||||
return instance
|
||||
|
||||
@@ -758,7 +754,7 @@ class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
|
||||
def get(self, request, module, name):
|
||||
script = self._get_script(name, module)
|
||||
form = script.as_form(initial=request.GET)
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
# Look for a pending JobResult (use the latest one by creation timestamp)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django_pglocks import advisory_lock
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
|
||||
from ipam.models import *
|
||||
from netbox.config import get_config
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from . import serializers
|
||||
|
||||
|
||||
class AvailablePrefixesMixin:
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.PrefixSerializer(many=False)})
|
||||
@action(detail=True, url_path='available-prefixes', methods=['get', 'post'])
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def available_prefixes(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for returning available child prefixes within a parent.
|
||||
|
||||
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
|
||||
invoked in parallel, which results in a race condition where multiple insertions can occur.
|
||||
"""
|
||||
prefix = get_object_or_404(self.queryset, pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
if request.method == 'POST':
|
||||
|
||||
# Validate Requested Prefixes' length
|
||||
serializer = serializers.PrefixLengthSerializer(
|
||||
data=request.data if isinstance(request.data, list) else [request.data],
|
||||
many=True,
|
||||
context={
|
||||
'request': request,
|
||||
'prefix': prefix,
|
||||
}
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
requested_prefixes = serializer.validated_data
|
||||
# Allocate prefixes to the requested objects based on availability within the parent
|
||||
for i, requested_prefix in enumerate(requested_prefixes):
|
||||
|
||||
# Find the first available prefix equal to or larger than the requested size
|
||||
for available_prefix in available_prefixes.iter_cidrs():
|
||||
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
||||
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
||||
requested_prefix['prefix'] = allocated_prefix
|
||||
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
break
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
||||
},
|
||||
status=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
|
||||
# Remove the allocated prefix from the list of available prefixes
|
||||
available_prefixes.remove(allocated_prefix)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
context = {'request': request}
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
|
||||
else:
|
||||
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
else:
|
||||
|
||||
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
||||
'request': request,
|
||||
'vrf': prefix.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class AvailableIPsMixin:
|
||||
parent_model = Prefix
|
||||
|
||||
@swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||
@swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)},
|
||||
request_body=serializers.AvailableIPSerializer(many=True))
|
||||
@action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all())
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def available_ips(self, request, pk=None):
|
||||
"""
|
||||
A convenience method for returning available IP addresses within a Prefix or IPRange. By default, the number of
|
||||
IPs returned will be equivalent to PAGINATE_COUNT. An arbitrary limit (up to MAX_PAGE_SIZE, if set) may be
|
||||
passed, however results will not be paginated.
|
||||
|
||||
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
|
||||
invoked in parallel, which results in a race condition where multiple insertions can occur.
|
||||
"""
|
||||
parent = get_object_or_404(self.parent_model.objects.restrict(request.user), pk=pk)
|
||||
|
||||
# Create the next available IP
|
||||
if request.method == 'POST':
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Determine if the requested number of IPs is available
|
||||
available_ips = parent.get_available_ips()
|
||||
if available_ips.size < len(requested_ips):
|
||||
return Response(
|
||||
{
|
||||
"detail": f"An insufficient number of IP addresses are available within {parent} "
|
||||
f"({len(requested_ips)} requested, {len(available_ips)} available)"
|
||||
},
|
||||
status=status.HTTP_204_NO_CONTENT
|
||||
)
|
||||
|
||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
|
||||
available_ips = iter(available_ips)
|
||||
for requested_ip in requested_ips:
|
||||
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
|
||||
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
context = {'request': request}
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
|
||||
else:
|
||||
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Determine the maximum number of IPs to return
|
||||
else:
|
||||
config = get_config()
|
||||
PAGINATE_COUNT = config.PAGINATE_COUNT
|
||||
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
|
||||
except ValueError:
|
||||
limit = PAGINATE_COUNT
|
||||
if MAX_PAGE_SIZE:
|
||||
limit = min(limit, MAX_PAGE_SIZE)
|
||||
|
||||
# Calculate available IPs within the parent
|
||||
ip_list = []
|
||||
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
||||
ip_list.append(ip)
|
||||
if index == limit:
|
||||
break
|
||||
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
|
||||
'request': request,
|
||||
'parent': parent,
|
||||
'vrf': parent.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
@@ -1,4 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from netbox.api import OrderedDefaultRouter
|
||||
from ipam.models import IPRange, Prefix
|
||||
from . import views
|
||||
|
||||
|
||||
@@ -42,4 +45,23 @@ router.register('vlans', views.VLANViewSet)
|
||||
router.register('services', views.ServiceViewSet)
|
||||
|
||||
app_name = 'ipam-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
'ip-ranges/<int:pk>/available-ips/',
|
||||
views.IPRangeAvailableIPAddressesView.as_view(),
|
||||
name='iprange-available-ips'
|
||||
),
|
||||
path(
|
||||
'prefixes/<int:pk>/available-prefixes/',
|
||||
views.AvailablePrefixesView.as_view(),
|
||||
name='prefix-available-prefixes'
|
||||
),
|
||||
path(
|
||||
'prefixes/<int:pk>/available-ips/',
|
||||
views.PrefixAvailableIPAddressesView.as_view(),
|
||||
name='prefix-available-ips'
|
||||
),
|
||||
]
|
||||
|
||||
urlpatterns += router.urls
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django_pglocks import advisory_lock
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg.utils import swagger_auto_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.routers import APIRootView
|
||||
from rest_framework.views import APIView
|
||||
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from ipam import filtersets
|
||||
from ipam.models import *
|
||||
from netbox.api.views import ModelViewSet
|
||||
from netbox.api.views import ModelViewSet, ObjectValidationMixin
|
||||
from netbox.config import get_config
|
||||
from utilities.constants import ADVISORY_LOCK_KEYS
|
||||
from utilities.utils import count_related
|
||||
from . import mixins, serializers
|
||||
from . import serializers
|
||||
|
||||
|
||||
class IPAMRootView(APIRootView):
|
||||
@@ -18,7 +29,7 @@ class IPAMRootView(APIRootView):
|
||||
|
||||
|
||||
#
|
||||
# ASNs
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ASNViewSet(CustomFieldModelViewSet):
|
||||
@@ -27,10 +38,6 @@ class ASNViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.ASNFilterSet
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related(
|
||||
'import_targets', 'export_targets', 'tags'
|
||||
@@ -42,20 +49,12 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VRFFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Route targets
|
||||
#
|
||||
|
||||
class RouteTargetViewSet(CustomFieldModelViewSet):
|
||||
queryset = RouteTarget.objects.prefetch_related('tenant').prefetch_related('tags')
|
||||
serializer_class = serializers.RouteTargetSerializer
|
||||
filterset_class = filtersets.RouteTargetFilterSet
|
||||
|
||||
|
||||
#
|
||||
# RIRs
|
||||
#
|
||||
|
||||
class RIRViewSet(CustomFieldModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=count_related(Aggregate, 'rir')
|
||||
@@ -64,20 +63,12 @@ class RIRViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.RIRFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateViewSet(CustomFieldModelViewSet):
|
||||
queryset = Aggregate.objects.prefetch_related('rir').prefetch_related('tags')
|
||||
serializer_class = serializers.AggregateSerializer
|
||||
filterset_class = filtersets.AggregateFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Roles
|
||||
#
|
||||
|
||||
class RoleViewSet(CustomFieldModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=count_related(Prefix, 'role'),
|
||||
@@ -87,11 +78,7 @@ class RoleViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.RoleFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, CustomFieldModelViewSet):
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.prefetch_related(
|
||||
'site', 'vrf__tenant', 'tenant', 'vlan', 'role', 'tags'
|
||||
)
|
||||
@@ -106,11 +93,7 @@ class PrefixViewSet(mixins.AvailableIPsMixin, mixins.AvailablePrefixesMixin, Cus
|
||||
return super().get_serializer_class()
|
||||
|
||||
|
||||
#
|
||||
# IP ranges
|
||||
#
|
||||
|
||||
class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
|
||||
class IPRangeViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPRange.objects.prefetch_related('vrf', 'role', 'tenant', 'tags')
|
||||
serializer_class = serializers.IPRangeSerializer
|
||||
filterset_class = filtersets.IPRangeFilterSet
|
||||
@@ -118,10 +101,6 @@ class IPRangeViewSet(mixins.AvailableIPsMixin, CustomFieldModelViewSet):
|
||||
parent_model = IPRange # AvailableIPsMixin
|
||||
|
||||
|
||||
#
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
|
||||
@@ -130,14 +109,11 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.IPAddressFilterSet
|
||||
|
||||
|
||||
#
|
||||
# FHRP groups
|
||||
#
|
||||
|
||||
class FHRPGroupViewSet(CustomFieldModelViewSet):
|
||||
queryset = FHRPGroup.objects.prefetch_related('ip_addresses', 'tags')
|
||||
serializer_class = serializers.FHRPGroupSerializer
|
||||
filterset_class = filtersets.FHRPGroupFilterSet
|
||||
brief_prefetch_fields = ('ip_addresses',)
|
||||
|
||||
|
||||
class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
|
||||
@@ -146,10 +122,6 @@ class FHRPGroupAssignmentViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||
|
||||
|
||||
#
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLANGroup.objects.annotate(
|
||||
vlan_count=count_related(VLAN, 'group')
|
||||
@@ -158,10 +130,6 @@ class VLANGroupViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VLANGroupFilterSet
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.prefetch_related(
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
@@ -172,13 +140,190 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VLANFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
queryset = Service.objects.prefetch_related(
|
||||
'device', 'virtual_machine', 'tags', 'ipaddresses'
|
||||
)
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
filterset_class = filtersets.ServiceFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Views
|
||||
#
|
||||
|
||||
class AvailablePrefixesView(ObjectValidationMixin, APIView):
|
||||
queryset = Prefix.objects.all()
|
||||
|
||||
@swagger_auto_schema(responses={200: serializers.AvailablePrefixSerializer(many=True)})
|
||||
def get(self, request, pk):
|
||||
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={
|
||||
'request': request,
|
||||
'vrf': prefix.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=serializers.PrefixLengthSerializer,
|
||||
responses={201: serializers.PrefixSerializer(many=True)}
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
# Validate Requested Prefixes' length
|
||||
serializer = serializers.PrefixLengthSerializer(
|
||||
data=request.data if isinstance(request.data, list) else [request.data],
|
||||
many=True,
|
||||
context={
|
||||
'request': request,
|
||||
'prefix': prefix,
|
||||
}
|
||||
)
|
||||
if not serializer.is_valid():
|
||||
return Response(
|
||||
serializer.errors,
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
requested_prefixes = serializer.validated_data
|
||||
# Allocate prefixes to the requested objects based on availability within the parent
|
||||
for i, requested_prefix in enumerate(requested_prefixes):
|
||||
|
||||
# Find the first available prefix equal to or larger than the requested size
|
||||
for available_prefix in available_prefixes.iter_cidrs():
|
||||
if requested_prefix['prefix_length'] >= available_prefix.prefixlen:
|
||||
allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length'])
|
||||
requested_prefix['prefix'] = allocated_prefix
|
||||
requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None
|
||||
break
|
||||
else:
|
||||
return Response(
|
||||
{
|
||||
"detail": "Insufficient space is available to accommodate the requested prefix size(s)"
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Remove the allocated prefix from the list of available prefixes
|
||||
available_prefixes.remove(allocated_prefix)
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
context = {'request': request}
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
|
||||
else:
|
||||
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
|
||||
|
||||
# Create the new Prefix(es)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class AvailableIPAddressesView(ObjectValidationMixin, APIView):
|
||||
queryset = IPAddress.objects.all()
|
||||
|
||||
def get_parent(self, request, pk):
|
||||
raise NotImplemented()
|
||||
|
||||
@swagger_auto_schema(responses={200: serializers.AvailableIPSerializer(many=True)})
|
||||
def get(self, request, pk):
|
||||
parent = self.get_parent(request, pk)
|
||||
config = get_config()
|
||||
PAGINATE_COUNT = config.PAGINATE_COUNT
|
||||
MAX_PAGE_SIZE = config.MAX_PAGE_SIZE
|
||||
|
||||
try:
|
||||
limit = int(request.query_params.get('limit', PAGINATE_COUNT))
|
||||
except ValueError:
|
||||
limit = PAGINATE_COUNT
|
||||
if MAX_PAGE_SIZE:
|
||||
limit = min(limit, MAX_PAGE_SIZE)
|
||||
|
||||
# Calculate available IPs within the parent
|
||||
ip_list = []
|
||||
for index, ip in enumerate(parent.get_available_ips(), start=1):
|
||||
ip_list.append(ip)
|
||||
if index == limit:
|
||||
break
|
||||
serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={
|
||||
'request': request,
|
||||
'parent': parent,
|
||||
'vrf': parent.vrf,
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@swagger_auto_schema(
|
||||
request_body=serializers.AvailableIPSerializer,
|
||||
responses={201: serializers.IPAddressSerializer(many=True)}
|
||||
)
|
||||
@advisory_lock(ADVISORY_LOCK_KEYS['available-ips'])
|
||||
def post(self, request, pk):
|
||||
self.queryset = self.queryset.restrict(request.user, 'add')
|
||||
parent = self.get_parent(request, pk)
|
||||
|
||||
# Normalize to a list of objects
|
||||
requested_ips = request.data if isinstance(request.data, list) else [request.data]
|
||||
|
||||
# Determine if the requested number of IPs is available
|
||||
available_ips = parent.get_available_ips()
|
||||
if available_ips.size < len(requested_ips):
|
||||
return Response(
|
||||
{
|
||||
"detail": f"An insufficient number of IP addresses are available within {parent} "
|
||||
f"({len(requested_ips)} requested, {len(available_ips)} available)"
|
||||
},
|
||||
status=status.HTTP_409_CONFLICT
|
||||
)
|
||||
|
||||
# Assign addresses from the list of available IPs and copy VRF assignment from the parent
|
||||
available_ips = iter(available_ips)
|
||||
for requested_ip in requested_ips:
|
||||
requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}'
|
||||
requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None
|
||||
|
||||
# Initialize the serializer with a list or a single object depending on what was requested
|
||||
context = {'request': request}
|
||||
if isinstance(request.data, list):
|
||||
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
|
||||
else:
|
||||
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
|
||||
|
||||
# Create the new IP address(es)
|
||||
if serializer.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
raise PermissionDenied()
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PrefixAvailableIPAddressesView(AvailableIPAddressesView):
|
||||
|
||||
def get_parent(self, request, pk):
|
||||
return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||
|
||||
|
||||
class IPRangeAvailableIPAddressesView(AvailableIPAddressesView):
|
||||
|
||||
def get_parent(self, request, pk):
|
||||
return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk)
|
||||
|
||||
@@ -8,8 +8,8 @@ from ipam.models import *
|
||||
from ipam.models import ASN
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField,
|
||||
StaticSelect, DynamicModelMultipleChoiceField,
|
||||
add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect,
|
||||
DynamicModelMultipleChoiceField,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -29,7 +29,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class VRFBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -54,7 +54,7 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
|
||||
]
|
||||
|
||||
|
||||
class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RouteTargetBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -74,7 +74,7 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldMode
|
||||
]
|
||||
|
||||
|
||||
class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RIRBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -92,7 +92,7 @@ class RIRBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
|
||||
nullable_fields = ['is_private', 'description']
|
||||
|
||||
|
||||
class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ASNBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=ASN.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -124,7 +124,7 @@ class ASNBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEdi
|
||||
}
|
||||
|
||||
|
||||
class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class AggregateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Aggregate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -155,7 +155,7 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
}
|
||||
|
||||
|
||||
class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class RoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -172,7 +172,7 @@ class RoleBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
||||
nullable_fields = ['description']
|
||||
|
||||
|
||||
class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class PrefixBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Prefix.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -237,7 +237,7 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulk
|
||||
]
|
||||
|
||||
|
||||
class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class IPRangeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=IPRange.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -271,7 +271,7 @@ class IPRangeBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBul
|
||||
]
|
||||
|
||||
|
||||
class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class IPAddressBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -315,7 +315,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
]
|
||||
|
||||
|
||||
class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class FHRPGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -350,7 +350,7 @@ class FHRPGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
nullable_fields = ['auth_type', 'auth_key', 'description']
|
||||
|
||||
|
||||
class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class VLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
widget=forms.MultipleHiddenInput
|
||||
@@ -368,7 +368,7 @@ class VLANGroupBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelB
|
||||
nullable_fields = ['site', 'description']
|
||||
|
||||
|
||||
class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
@@ -420,7 +420,7 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEd
|
||||
]
|
||||
|
||||
|
||||
class ServiceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Service.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
|
||||
@@ -8,10 +8,9 @@ from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from ipam.models import ASN
|
||||
from tenancy.forms import TenancyFilterForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect,
|
||||
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple,
|
||||
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@@ -39,18 +38,13 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
|
||||
])
|
||||
|
||||
|
||||
class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = VRF
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['import_target_id', 'export_target_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
import_target_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False,
|
||||
@@ -66,18 +60,13 @@ class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = RouteTarget
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['importing_vrf_id', 'exporting_vrf_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
importing_vrf_id = DynamicModelMultipleChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -93,13 +82,8 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelF
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class RIRFilterForm(CustomFieldModelFilterForm):
|
||||
model = RIR
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
is_private = forms.NullBooleanField(
|
||||
required=False,
|
||||
label=_('Private'),
|
||||
@@ -110,18 +94,13 @@ class RIRFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Aggregate
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['family', 'rir_id'],
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||
@@ -137,7 +116,7 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = ASN
|
||||
field_groups = [
|
||||
['q'],
|
||||
@@ -145,11 +124,6 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
['site_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
rir_id = DynamicModelMultipleChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
required=False,
|
||||
@@ -164,17 +138,12 @@ class ASNFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFor
|
||||
)
|
||||
|
||||
|
||||
class RoleFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class RoleFilterForm(CustomFieldModelFilterForm):
|
||||
model = Role
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Prefix
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
@@ -183,11 +152,6 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['tenant_group_id', 'tenant_id']
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
mask_length__lte = forms.IntegerField(
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
@@ -276,18 +240,13 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = IPRange
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['family', 'vrf_id', 'status', 'role_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
family = forms.ChoiceField(
|
||||
required=False,
|
||||
choices=add_blank_choice(IPAddressFamilyChoices),
|
||||
@@ -316,23 +275,14 @@ class IPRangeFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilte
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = IPAddress
|
||||
field_order = [
|
||||
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role',
|
||||
'assigned_to_interface', 'tenant_group_id', 'tenant_id',
|
||||
]
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['parent', 'family', 'status', 'role', 'mask_length', 'assigned_to_interface'],
|
||||
['vrf_id', 'present_in_vrf_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
parent = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
@@ -387,18 +337,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFil
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class FHRPGroupFilterForm(CustomFieldModelFilterForm):
|
||||
model = FHRPGroup
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('protocol', 'group_id'),
|
||||
('auth_type', 'auth_key'),
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
protocol = forms.MultipleChoiceField(
|
||||
choices=FHRPGroupProtocolChoices,
|
||||
required=False,
|
||||
@@ -422,17 +367,12 @@ class FHRPGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class VLANGroupFilterForm(CustomFieldModelFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region', 'sitegroup', 'site', 'location', 'rack']
|
||||
]
|
||||
model = VLANGroup
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -466,19 +406,14 @@ class VLANGroupFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = VLAN
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id'],
|
||||
['group_id', 'status', 'role_id'],
|
||||
['group_id', 'status', 'role_id', 'vid'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
@@ -523,20 +458,19 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterFo
|
||||
label=_('Role'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
vid = forms.IntegerField(
|
||||
required=False,
|
||||
label='VLAN ID'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceFilterForm(BootstrapMixin, CustomFieldModelFilterForm):
|
||||
class ServiceFilterForm(CustomFieldModelFilterForm):
|
||||
model = Service
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('protocol', 'port'),
|
||||
)
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': _('All Fields')}),
|
||||
label=_('Search')
|
||||
)
|
||||
protocol = forms.ChoiceField(
|
||||
choices=add_blank_choice(ServiceProtocolChoices),
|
||||
required=False,
|
||||
|
||||
@@ -37,7 +37,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class VRFForm(TenancyForm, CustomFieldModelForm):
|
||||
import_targets = DynamicModelMultipleChoiceField(
|
||||
queryset=RouteTarget.objects.all(),
|
||||
required=False
|
||||
@@ -70,7 +70,7 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class RouteTargetForm(TenancyForm, CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -87,7 +87,7 @@ class RouteTargetForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
)
|
||||
|
||||
|
||||
class RIRForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class RIRForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -101,7 +101,7 @@ class RIRForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class AggregateForm(TenancyForm, CustomFieldModelForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
label='RIR'
|
||||
@@ -129,7 +129,7 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class ASNForm(TenancyForm, CustomFieldModelForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all(),
|
||||
label='RIR',
|
||||
@@ -173,7 +173,7 @@ class ASNForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
return instance
|
||||
|
||||
|
||||
class RoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class RoleForm(CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -187,7 +187,7 @@ class RoleForm(BootstrapMixin, CustomFieldModelForm):
|
||||
]
|
||||
|
||||
|
||||
class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class PrefixForm(TenancyForm, CustomFieldModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -262,7 +262,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class IPRangeForm(TenancyForm, CustomFieldModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -291,7 +291,7 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class IPAddressForm(TenancyForm, CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -321,6 +321,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'virtual_machine_id': '$virtual_machine'
|
||||
}
|
||||
)
|
||||
fhrpgroup = DynamicModelChoiceField(
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
required=False,
|
||||
label='FHRP Group'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -428,6 +433,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
initial['interface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VMInterface:
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is FHRPGroup:
|
||||
initial['fhrpgroup'] = instance.assigned_object
|
||||
if instance.nat_inside:
|
||||
nat_inside_parent = instance.nat_inside.assigned_object
|
||||
if type(nat_inside_parent) is Interface:
|
||||
@@ -444,8 +451,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
|
||||
# Initialize primary_for_parent if IP address is already assigned
|
||||
if self.instance.pk and self.instance.assigned_object:
|
||||
parent = self.instance.assigned_object.parent_object
|
||||
if (
|
||||
parent = getattr(self.instance.assigned_object, 'parent_object', None)
|
||||
if parent and (
|
||||
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
|
||||
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
|
||||
):
|
||||
@@ -454,10 +461,13 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
# Handle object assignment
|
||||
if self.cleaned_data['interface']:
|
||||
self.instance.assigned_object = self.cleaned_data['interface']
|
||||
elif self.cleaned_data['vminterface']:
|
||||
self.instance.assigned_object = self.cleaned_data['vminterface']
|
||||
elif self.cleaned_data['fhrpgroup']:
|
||||
self.instance.assigned_object = self.cleaned_data['fhrpgroup']
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
@@ -471,7 +481,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
interface = self.instance.assigned_object
|
||||
if interface:
|
||||
if type(interface) in (Interface, VMInterface):
|
||||
parent = interface.parent_object
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
if ipaddress.address.version == 4:
|
||||
@@ -489,7 +499,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
return ipaddress
|
||||
|
||||
|
||||
class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class IPAddressBulkAddForm(TenancyForm, CustomFieldModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -523,7 +533,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
|
||||
)
|
||||
|
||||
|
||||
class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class FHRPGroupForm(CustomFieldModelForm):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
@@ -565,9 +575,9 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
vrf=self.cleaned_data['ip_vrf'],
|
||||
address=self.cleaned_data['ip_address'],
|
||||
status=self.cleaned_data['ip_status'],
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.role = FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']]
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
@@ -576,6 +586,22 @@ class FHRPGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
return instance
|
||||
|
||||
def clean(self):
|
||||
ip_vrf = self.cleaned_data.get('ip_vrf')
|
||||
ip_address = self.cleaned_data.get('ip_address')
|
||||
ip_status = self.cleaned_data.get('ip_status')
|
||||
|
||||
if ip_address:
|
||||
ip_form = IPAddressForm({
|
||||
'address': ip_address,
|
||||
'vrf': ip_vrf,
|
||||
'status': ip_status,
|
||||
})
|
||||
if not ip_form.is_valid():
|
||||
self.errors.update({
|
||||
f'ip_{field}': error for field, error in ip_form.errors.items()
|
||||
})
|
||||
|
||||
|
||||
class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
group = DynamicModelChoiceField(
|
||||
@@ -594,7 +620,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
self.fields['group'].widget.add_query_param('related_ip', ipaddress.pk)
|
||||
|
||||
|
||||
class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class VLANGroupForm(CustomFieldModelForm):
|
||||
scope_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
@@ -701,7 +727,7 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
self.instance.scope_id = None
|
||||
|
||||
|
||||
class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
class VLANForm(TenancyForm, CustomFieldModelForm):
|
||||
# VLANGroup assignment fields
|
||||
scope_type = forms.ChoiceField(
|
||||
choices=(
|
||||
@@ -782,7 +808,7 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ServiceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class ServiceForm(CustomFieldModelForm):
|
||||
ports = NumericArrayField(
|
||||
base_field=forms.IntegerField(
|
||||
min_value=SERVICE_PORT_MIN,
|
||||
|
||||
@@ -8,7 +8,6 @@ from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
__all__ = (
|
||||
'FHRPGroup',
|
||||
@@ -47,21 +46,27 @@ class FHRPGroup(PrimaryModel):
|
||||
to='ipam.IPAddress',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='fhrp_group'
|
||||
related_query_name='fhrpgroup'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'protocol', 'auth_type', 'auth_key'
|
||||
]
|
||||
clone_fields = ('protocol', 'auth_type', 'auth_key')
|
||||
|
||||
class Meta:
|
||||
ordering = ['protocol', 'group_id', 'pk']
|
||||
verbose_name = 'FHRP group'
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.get_protocol_display()} group {self.group_id}'
|
||||
name = f'{self.get_protocol_display()}: {self.group_id}'
|
||||
|
||||
# Append the list of assigned IP addresses to serve as an additional identifier
|
||||
if self.pk:
|
||||
ip_addresses = [
|
||||
str(ip.address) for ip in self.ip_addresses.all()
|
||||
]
|
||||
if ip_addresses:
|
||||
return f"{name} ({', '.join(ip_addresses)})"
|
||||
|
||||
return name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:fhrpgroup', args=[self.pk])
|
||||
@@ -89,7 +94,7 @@ class FHRPGroupAssignment(ChangeLoggedModel):
|
||||
)
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
clone_fields = ('interface_type', 'interface_id')
|
||||
|
||||
class Meta:
|
||||
ordering = ('-priority', 'pk')
|
||||
@@ -98,3 +103,9 @@ class FHRPGroupAssignment(ChangeLoggedModel):
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.interface}: {self.group} ({self.priority})'
|
||||
|
||||
def get_absolute_url(self):
|
||||
# Used primarily for redirection after creating a new assignment
|
||||
if self.interface:
|
||||
return self.interface.get_absolute_url()
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,6 @@ from ipam.managers import IPAddressManager
|
||||
from ipam.querysets import PrefixQuerySet
|
||||
from ipam.validators import DNSValidator
|
||||
from netbox.config import get_config
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
|
||||
@@ -57,8 +56,6 @@ class RIR(OrganizationalModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
verbose_name = 'RIR'
|
||||
@@ -100,8 +97,6 @@ class ASN(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['asn']
|
||||
verbose_name = 'ASN'
|
||||
@@ -143,8 +138,6 @@ class Aggregate(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'rir', 'tenant', 'date_added', 'description',
|
||||
]
|
||||
@@ -235,8 +228,6 @@ class Role(OrganizationalModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['weight', 'name']
|
||||
|
||||
@@ -592,8 +583,6 @@ class IPRange(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'vrf', 'tenant', 'status', 'role', 'description',
|
||||
]
|
||||
|
||||
@@ -8,7 +8,6 @@ from extras.utils import extras_features
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from netbox.models import PrimaryModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import array_to_string
|
||||
|
||||
|
||||
@@ -65,8 +64,6 @@ class Service(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.querysets import VLANQuerySet
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
|
||||
@@ -52,8 +51,6 @@ class VLANGroup(OrganizationalModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('name', 'pk') # Name may be non-unique
|
||||
unique_together = [
|
||||
|
||||
@@ -4,7 +4,6 @@ from django.urls import reverse
|
||||
from extras.utils import extras_features
|
||||
from ipam.constants import *
|
||||
from netbox.models import PrimaryModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -58,8 +57,6 @@ class VRF(PrimaryModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'tenant', 'enforce_unique', 'description',
|
||||
]
|
||||
@@ -100,8 +97,6 @@ class RouteTarget(PrimaryModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
@@ -235,6 +235,11 @@ class PrefixTable(BaseTable):
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
vlan_group = tables.Column(
|
||||
accessor='vlan__group',
|
||||
linkify=True,
|
||||
verbose_name='VLAN Group'
|
||||
)
|
||||
vlan = tables.Column(
|
||||
linkify=True,
|
||||
verbose_name='VLAN'
|
||||
@@ -259,8 +264,8 @@ class PrefixTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role',
|
||||
'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
|
||||
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||
@@ -347,7 +352,7 @@ class IPAddressTable(BaseTable):
|
||||
verbose_name='NAT (Inside)'
|
||||
)
|
||||
assigned = BooleanColumn(
|
||||
accessor='assigned_object',
|
||||
accessor='assigned_object_id',
|
||||
linkify=True,
|
||||
verbose_name='Assigned'
|
||||
)
|
||||
|
||||
@@ -96,7 +96,10 @@ class VLANTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
vid = tables.TemplateColumn(
|
||||
template_code=VLAN_LINK,
|
||||
verbose_name='ID'
|
||||
verbose_name='VID'
|
||||
)
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
|
||||
@@ -289,7 +289,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
vrf = VRF.objects.create(name='VRF 1')
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
|
||||
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
|
||||
self.add_permissions('ipam.add_prefix')
|
||||
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
|
||||
|
||||
# Create four available prefixes with individual requests
|
||||
prefixes_to_be_created = [
|
||||
@@ -311,7 +311,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
# Try to create one more prefix
|
||||
response = self.client.post(url, {'prefix_length': 30}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Try to create invalid prefix type
|
||||
@@ -337,7 +337,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
{'prefix_length': 30, 'description': 'Prefix 5'},
|
||||
]
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Verify that no prefixes were created (the entire /28 is still available)
|
||||
@@ -391,7 +391,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
# Try to create one more IP
|
||||
response = self.client.post(url, {}, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_ips(self):
|
||||
@@ -406,7 +406,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase):
|
||||
# Try to create nine IPs (only eight are available)
|
||||
data = [{'description': f'Test IP {i}'} for i in range(1, 10)] # 9 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Create all eight available IPs in a single request
|
||||
@@ -488,7 +488,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
# Try to create one more IP
|
||||
response = self.client.post(url, {}, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
def test_create_multiple_available_ips(self):
|
||||
@@ -505,7 +505,7 @@ class IPRangeTest(APIViewTestCases.APIViewTestCase):
|
||||
# Try to create nine IPs (only eight are available)
|
||||
data = [{'description': f'Test IP #{i}'} for i in range(1, 10)] # 9 IPs
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertHttpStatus(response, status.HTTP_409_CONFLICT)
|
||||
self.assertIn('detail', response.data)
|
||||
|
||||
# Create all eight available IPs in a single request
|
||||
|
||||
@@ -523,9 +523,7 @@ class PrefixIPAddressesView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Find all IPAddresses belonging to this Prefix
|
||||
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'primary_ip4_for', 'primary_ip6_for'
|
||||
)
|
||||
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
|
||||
|
||||
# Add available IP addresses to the table if requested
|
||||
if request.GET.get('show_available', 'true') == 'true':
|
||||
@@ -604,9 +602,7 @@ class IPRangeIPAddressesView(generic.ObjectView):
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Find all IPAddresses within this range
|
||||
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'primary_ip4_for', 'primary_ip6_for'
|
||||
)
|
||||
ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
|
||||
|
||||
# Add available IP addresses to the table if requested
|
||||
# if request.GET.get('show_available', 'true') == 'true':
|
||||
@@ -739,6 +735,12 @@ class IPAddressEditView(generic.ObjectEditView):
|
||||
except (ValueError, VMInterface.DoesNotExist):
|
||||
pass
|
||||
|
||||
elif 'fhrpgroup' in request.GET:
|
||||
try:
|
||||
obj.assigned_object = FHRPGroup.objects.get(pk=request.GET['fhrpgroup'])
|
||||
except (ValueError, FHRPGroup.DoesNotExist):
|
||||
pass
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
@@ -976,11 +978,7 @@ class FHRPGroupAssignmentEditView(generic.ObjectEditView):
|
||||
def alter_obj(self, instance, request, args, kwargs):
|
||||
if not instance.pk:
|
||||
# Assign the interface based on URL kwargs
|
||||
try:
|
||||
app_label, model = request.GET.get('interface_type').split('.')
|
||||
except (AttributeError, ValueError):
|
||||
raise Http404("Content type not specified")
|
||||
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
|
||||
content_type = get_object_or_404(ContentType, pk=request.GET.get('interface_type'))
|
||||
instance.interface = get_object_or_404(content_type.model_class(), pk=request.GET.get('interface_id'))
|
||||
return instance
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import threading
|
||||
|
||||
thread_locals = threading.local()
|
||||
|
||||
@@ -29,10 +29,13 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
||||
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
|
||||
from netbox.authentication import LDAPBackend
|
||||
ldap_backend = LDAPBackend()
|
||||
user = ldap_backend.populate_user(token.user.username)
|
||||
# If the user is found in the LDAP directory use it, if not fallback to the local user
|
||||
if user:
|
||||
return user, token
|
||||
|
||||
# Load from LDAP if FIND_GROUP_PERMS is active
|
||||
if ldap_backend.settings.FIND_GROUP_PERMS:
|
||||
user = ldap_backend.populate_user(token.user.username)
|
||||
# If the user is found in the LDAP directory use it, if not fallback to the local user
|
||||
if user:
|
||||
return user, token
|
||||
|
||||
return token.user, token
|
||||
|
||||
|
||||
@@ -175,7 +175,7 @@ class PrimaryModelSerializer(CustomFieldModelSerializer):
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set(*[t.name for t in tags])
|
||||
instance.tags.set([t.name for t in tags])
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
|
||||
@@ -123,11 +123,28 @@ class BulkDestroyModelMixin:
|
||||
self.perform_destroy(obj)
|
||||
|
||||
|
||||
class ObjectValidationMixin:
|
||||
|
||||
def _validate_objects(self, instance):
|
||||
"""
|
||||
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
|
||||
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
|
||||
"""
|
||||
if type(instance) is list:
|
||||
# Check that all instances are still included in the view's queryset
|
||||
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
|
||||
if conforming_count != len(instance):
|
||||
raise ObjectDoesNotExist
|
||||
else:
|
||||
# Check that the instance is matched by the view's queryset
|
||||
self.queryset.get(pk=instance.pk)
|
||||
|
||||
|
||||
#
|
||||
# Viewsets
|
||||
#
|
||||
|
||||
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectValidationMixin, ModelViewSet_):
|
||||
"""
|
||||
Extend DRF's ModelViewSet to support bulk update and delete functions.
|
||||
"""
|
||||
@@ -211,20 +228,6 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
**kwargs
|
||||
)
|
||||
|
||||
def _validate_objects(self, instance):
|
||||
"""
|
||||
Check that the provided instance or list of instances are matched by the current queryset. This confirms that
|
||||
any newly created or modified objects abide by the attributes granted by any applicable ObjectPermissions.
|
||||
"""
|
||||
if type(instance) is list:
|
||||
# Check that all instances are still included in the view's queryset
|
||||
conforming_count = self.queryset.filter(pk__in=[obj.pk for obj in instance]).count()
|
||||
if conforming_count != len(instance):
|
||||
raise ObjectDoesNotExist
|
||||
else:
|
||||
# Check that the instance is matched by the view's queryset
|
||||
self.queryset.get(pk=instance.pk)
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
|
||||
@@ -34,7 +34,7 @@ class ObjectPermissionMixin():
|
||||
object_permissions = ObjectPermission.objects.filter(
|
||||
self.get_permission_filter(user_obj),
|
||||
enabled=True
|
||||
).prefetch_related('object_types')
|
||||
).order_by('id').distinct('id').prefetch_related('object_types')
|
||||
|
||||
# Create a dictionary mapping permissions to their constraints
|
||||
perms = defaultdict(list)
|
||||
|
||||
@@ -94,6 +94,15 @@ PARAMS = (
|
||||
field=forms.IntegerField
|
||||
),
|
||||
|
||||
# Validation
|
||||
ConfigParam(
|
||||
name='CUSTOM_VALIDATORS',
|
||||
label='Custom validators',
|
||||
default={},
|
||||
description="Custom validation rules (JSON)",
|
||||
field=forms.JSONField
|
||||
),
|
||||
|
||||
# NAPALM
|
||||
ConfigParam(
|
||||
name='NAPALM_USERNAME',
|
||||
@@ -130,6 +139,20 @@ PARAMS = (
|
||||
description="Enable maintenance mode",
|
||||
field=forms.BooleanField
|
||||
),
|
||||
ConfigParam(
|
||||
name='GRAPHQL_ENABLED',
|
||||
label='GraphQL enabled',
|
||||
default=True,
|
||||
description="Enable the GraphQL API",
|
||||
field=forms.BooleanField
|
||||
),
|
||||
ConfigParam(
|
||||
name='CHANGELOG_RETENTION',
|
||||
label='Changelog retention',
|
||||
default=90,
|
||||
description="Days to retain changelog history (set to zero for unlimited)",
|
||||
field=forms.IntegerField
|
||||
),
|
||||
ConfigParam(
|
||||
name='MAPS_URL',
|
||||
label='Maps URL',
|
||||
|
||||
@@ -76,9 +76,6 @@ ADMINS = [
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
|
||||
CHANGELOG_RETENTION = 90
|
||||
|
||||
# API Cross-Origin Resource Sharing (CORS) settings. If CORS_ORIGIN_ALLOW_ALL is set to True, all origins will be
|
||||
# allowed. Otherwise, define a list of allowed origins using either CORS_ORIGIN_WHITELIST or
|
||||
# CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers
|
||||
@@ -90,20 +87,6 @@ CORS_ORIGIN_REGEX_WHITELIST = [
|
||||
# r'^(https?://)?(\w+\.)?example\.com$',
|
||||
]
|
||||
|
||||
# Specify any custom validators here, as a mapping of model to a list of validators classes. Validators should be
|
||||
# instances of or inherit from CustomValidator.
|
||||
# from extras.validators import CustomValidator
|
||||
CUSTOM_VALIDATORS = {
|
||||
# 'dcim.site': [
|
||||
# CustomValidator({
|
||||
# 'name': {
|
||||
# 'min_length': 10,
|
||||
# 'regex': r'\d{3}$',
|
||||
# }
|
||||
# })
|
||||
# ],
|
||||
}
|
||||
|
||||
# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
|
||||
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
|
||||
# on a production system.
|
||||
@@ -129,9 +112,6 @@ EXEMPT_VIEW_PERMISSIONS = [
|
||||
# 'ipam.prefix',
|
||||
]
|
||||
|
||||
# Enable the GraphQL API
|
||||
GRAPHQL_ENABLED = True
|
||||
|
||||
# HTTP proxies NetBox should use when sending outbound HTTP requests (e.g. for webhooks).
|
||||
# HTTP_PROXIES = {
|
||||
# 'http': 'http://10.10.1.10:3128',
|
||||
|
||||
@@ -6,6 +6,7 @@ from graphene_django.views import GraphQLView as GraphQLView_
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from netbox.api.authentication import TokenAuthentication
|
||||
from netbox.config import get_config
|
||||
|
||||
|
||||
class GraphQLView(GraphQLView_):
|
||||
@@ -15,9 +16,10 @@ class GraphQLView(GraphQLView_):
|
||||
graphiql_template = 'graphiql.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
config = get_config()
|
||||
|
||||
# Enforce GRAPHQL_ENABLED
|
||||
if not settings.GRAPHQL_ENABLED:
|
||||
if not config.GRAPHQL_ENABLED:
|
||||
return HttpResponseNotFound("The GraphQL API is not enabled.")
|
||||
|
||||
# Attempt to authenticate the user using a DRF token, if provided
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import logging
|
||||
import uuid
|
||||
from urllib import parse
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
|
||||
from django.contrib import auth
|
||||
from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import ProgrammingError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
@@ -11,6 +11,7 @@ from taggit.managers import TaggableManager
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from netbox.signals import post_clean
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
__all__ = (
|
||||
@@ -169,6 +170,8 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
|
||||
"""
|
||||
Base model for all objects which support change logging.
|
||||
"""
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -183,6 +186,8 @@ class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin,
|
||||
content_type_field='assigned_object_type'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
@@ -251,6 +256,8 @@ class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidatio
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('name',)
|
||||
|
||||
9
netbox/netbox/request_context.py
Normal file
9
netbox/netbox/request_context.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from netbox import thread_locals
|
||||
|
||||
|
||||
def set_request(request):
|
||||
thread_locals.request = request
|
||||
|
||||
|
||||
def get_request():
|
||||
return getattr(thread_locals, 'request', None)
|
||||
@@ -19,7 +19,7 @@ from netbox.config import PARAMS
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.1-beta1'
|
||||
VERSION = '3.1.1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -80,11 +80,9 @@ ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
CUSTOM_VALIDATORS = getattr(configuration, 'CUSTOM_VALIDATORS', {})
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
@@ -92,7 +90,6 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False)
|
||||
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
GRAPHQL_ENABLED = getattr(configuration, 'GRAPHQL_ENABLED', True)
|
||||
HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None)
|
||||
INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1'))
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
@@ -427,7 +424,7 @@ EXEMPT_PATHS = (
|
||||
f'/{BASE_PATH}graphql/',
|
||||
f'/{BASE_PATH}login/',
|
||||
f'/{BASE_PATH}oauth/',
|
||||
f'/{BASE_PATH}metrics/',
|
||||
f'/{BASE_PATH}metrics',
|
||||
)
|
||||
|
||||
|
||||
@@ -462,7 +459,7 @@ FILTERS_NULL_CHOICE_VALUE = 'null'
|
||||
# Django REST framework (API)
|
||||
#
|
||||
|
||||
REST_FRAMEWORK_VERSION = VERSION.rsplit('.', 1)[0] # Use major.minor as API version
|
||||
REST_FRAMEWORK_VERSION = '.'.join(VERSION.split('-')[0].split('.')[:2]) # Use major.minor as API version
|
||||
REST_FRAMEWORK = {
|
||||
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
|
||||
'COERCE_DECIMAL_TO_STRING': False,
|
||||
|
||||
@@ -93,6 +93,13 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
|
||||
def get_table(self, request, permissions):
|
||||
table = self.table(self.queryset, user=request.user)
|
||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||
table.columns.show('pk')
|
||||
|
||||
return table
|
||||
|
||||
def export_yaml(self):
|
||||
"""
|
||||
Export the queryset of objects as concatenated YAML documents.
|
||||
@@ -123,8 +130,20 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
|
||||
)
|
||||
|
||||
def get(self, request):
|
||||
def export_template(self, template, request):
|
||||
"""
|
||||
Render an ExportTemplate using the current queryset.
|
||||
|
||||
:param template: ExportTemplate instance
|
||||
:param request: The current request
|
||||
"""
|
||||
try:
|
||||
return template.render_to_response(self.queryset)
|
||||
except Exception as e:
|
||||
messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}")
|
||||
return redirect(request.path)
|
||||
|
||||
def get(self, request):
|
||||
model = self.queryset.model
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
|
||||
@@ -137,42 +156,33 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
|
||||
perm_name = get_permission_for_model(model, action)
|
||||
permissions[action] = request.user.has_perm(perm_name)
|
||||
|
||||
# Export template/YAML rendering
|
||||
if 'export' in request.GET and request.GET['export'] != 'table':
|
||||
if 'export' in request.GET:
|
||||
|
||||
# An export template has been specified
|
||||
if request.GET['export']:
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
try:
|
||||
return et.render_to_response(self.queryset)
|
||||
except Exception as e:
|
||||
messages.error(
|
||||
request,
|
||||
"There was an error rendering the selected export template ({}): {}".format(
|
||||
et.name, e
|
||||
)
|
||||
)
|
||||
# Export the current table view
|
||||
if request.GET['export'] == 'table':
|
||||
table = self.get_table(request, permissions)
|
||||
columns = [name for name, _ in table.selected_columns]
|
||||
return self.export_table(table, columns)
|
||||
|
||||
# Check for YAML export support
|
||||
# Render an ExportTemplate
|
||||
elif request.GET['export']:
|
||||
template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
return self.export_template(template, request)
|
||||
|
||||
# Check for YAML export support on the model
|
||||
elif hasattr(model, 'to_yaml'):
|
||||
response = HttpResponse(self.export_yaml(), content_type='text/yaml')
|
||||
filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural)
|
||||
response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename)
|
||||
return response
|
||||
|
||||
# Construct the objects table
|
||||
table = self.table(self.queryset, user=request.user)
|
||||
if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
|
||||
table.columns.show('pk')
|
||||
# Fall back to default table/YAML export
|
||||
else:
|
||||
table = self.get_table(request, permissions)
|
||||
return self.export_table(table)
|
||||
|
||||
# Handle table-based exports (current view or static CSV-based)
|
||||
if request.GET.get('export') == 'table':
|
||||
columns = [name for name, _ in table.selected_columns]
|
||||
return self.export_table(table, columns)
|
||||
elif 'export' in request.GET:
|
||||
return self.export_table(table)
|
||||
|
||||
# Paginate the objects table
|
||||
# Render the objects table
|
||||
table = self.get_table(request, permissions)
|
||||
paginate_table(table, request)
|
||||
|
||||
context = {
|
||||
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user