mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-07 01:19:32 +01:00
Compare commits
421 Commits
v2.8.9
...
v2.9-beta1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe20c30a81 | ||
|
|
7eb688bdcd | ||
|
|
22ee6703ad | ||
|
|
b734c72be7 | ||
|
|
9bcfefa31e | ||
|
|
37706f1c87 | ||
|
|
78bb2e12fe | ||
|
|
84d4b2db77 | ||
|
|
b47a9f251d | ||
|
|
46ebeba28f | ||
|
|
cb36f9fdb3 | ||
|
|
d23f97abc8 | ||
|
|
380d30e612 | ||
|
|
4f54ffa9aa | ||
|
|
7cdb0cf560 | ||
|
|
798810b3dd | ||
|
|
1fcefc486c | ||
|
|
79f1248119 | ||
|
|
2cc4f032b0 | ||
|
|
64a3bd37e7 | ||
|
|
b4cf85149b | ||
|
|
788f8c9a1c | ||
|
|
e9199d6ca5 | ||
|
|
1ac215bf87 | ||
|
|
3e6b257fa0 | ||
|
|
a3d1ee474c | ||
|
|
1714902f88 | ||
|
|
c6fd6ab329 | ||
|
|
0ef016db07 | ||
|
|
08a5e82afc | ||
|
|
8514a5427c | ||
|
|
de6202c160 | ||
|
|
0f679e1f03 | ||
|
|
39dc1f882a | ||
|
|
5b43fa0e12 | ||
|
|
ba50bfa939 | ||
|
|
16f44305e4 | ||
|
|
1dbf776279 | ||
|
|
bdf41451eb | ||
|
|
82cd24a7de | ||
|
|
21a750e8ec | ||
|
|
68ecddccdb | ||
|
|
8dd41b771e | ||
|
|
a4829198ff | ||
|
|
5cfc4ec3a0 | ||
|
|
19d0d6ff10 | ||
|
|
7461e76606 | ||
|
|
d8b0a11a49 | ||
|
|
1291fc4187 | ||
|
|
04d8ab3792 | ||
|
|
cf0e31ac0f | ||
|
|
4458ce69df | ||
|
|
81ed03575d | ||
|
|
49c6bee6d7 | ||
|
|
d47ea04ec4 | ||
|
|
d5a5a4a8d1 | ||
|
|
9f7ed25e74 | ||
|
|
013a2a35e0 | ||
|
|
9cece39ee9 | ||
|
|
05aa008ce1 | ||
|
|
e53839ca2a | ||
|
|
f83ec7256f | ||
|
|
96b3de7916 | ||
|
|
b1686c2db9 | ||
|
|
c8418fe550 | ||
|
|
9f025747a7 | ||
|
|
59091efa86 | ||
|
|
8d7001fe56 | ||
|
|
c3a7939a77 | ||
|
|
4f00b5af4a | ||
|
|
26e81546eb | ||
|
|
1692fbf5d8 | ||
|
|
15525392a2 | ||
|
|
b535608519 | ||
|
|
0a44ed1355 | ||
|
|
ccdbf820ba | ||
|
|
56c0b48302 | ||
|
|
4c2fdf3b1c | ||
|
|
4eddec4b1e | ||
|
|
39248f9e2f | ||
|
|
fac0da224a | ||
|
|
6e50ed084d | ||
|
|
bf7bd68b6a | ||
|
|
5fd5dbab7b | ||
|
|
85b284be54 | ||
|
|
02a6e2190f | ||
|
|
847fbfd71a | ||
|
|
327a93136a | ||
|
|
353d88f0a6 | ||
|
|
25e1319864 | ||
|
|
88033c0801 | ||
|
|
f6d05f3906 | ||
|
|
2fbe138c71 | ||
|
|
a2d957ba0d | ||
|
|
15f5719f44 | ||
|
|
924f319343 | ||
|
|
9a075130f1 | ||
|
|
6d0281adc8 | ||
|
|
592ad18317 | ||
|
|
1f905e72d9 | ||
|
|
e02936a44a | ||
|
|
4ea4112490 | ||
|
|
6547a2bc50 | ||
|
|
4a74927fa2 | ||
|
|
41f92ef8e6 | ||
|
|
f092c107b5 | ||
|
|
f4c14d4854 | ||
|
|
1ed152cd49 | ||
|
|
e635dc1fb3 | ||
|
|
7b33fac71d | ||
|
|
a1e5a8b86a | ||
|
|
92c889ef9e | ||
|
|
eb2da300b0 | ||
|
|
6abb7e8f4d | ||
|
|
1f9cdc71d4 | ||
|
|
d03d302eef | ||
|
|
c5362f5931 | ||
|
|
f28bde179e | ||
|
|
f98fa364c0 | ||
|
|
8d7377ba04 | ||
|
|
fa0c7a76cb | ||
|
|
06ae424b80 | ||
|
|
e4b5045ec7 | ||
|
|
7e3e18faea | ||
|
|
225b6c6958 | ||
|
|
57b73c485f | ||
|
|
c484fa99e2 | ||
|
|
8959d2e0a7 | ||
|
|
4613b69c28 | ||
|
|
7fab929194 | ||
|
|
89ea34015d | ||
|
|
88e3ac30b6 | ||
|
|
52a13b1960 | ||
|
|
1d922a1848 | ||
|
|
af778f8fca | ||
|
|
15f32bdd73 | ||
|
|
36498c9dd2 | ||
|
|
66703d8963 | ||
|
|
71812d1bd5 | ||
|
|
5ed6136915 | ||
|
|
f48a079ae6 | ||
|
|
a47a100cb7 | ||
|
|
9ea4f82eaa | ||
|
|
617e20af0b | ||
|
|
89ff59d048 | ||
|
|
6ecbf45974 | ||
|
|
eb45ad600e | ||
|
|
10e6b6ca66 | ||
|
|
5732466e56 | ||
|
|
ce55d0c791 | ||
|
|
6ab4640cdc | ||
|
|
a6b03b8884 | ||
|
|
0dbe248df8 | ||
|
|
1681dbfa39 | ||
|
|
3777fbccc3 | ||
|
|
86d1370512 | ||
|
|
8c0adc9c61 | ||
|
|
a452e78fa6 | ||
|
|
04571ce920 | ||
|
|
40c416618a | ||
|
|
9a1531442a | ||
|
|
6128ef4b37 | ||
|
|
84db1adfaf | ||
|
|
2c354c7f86 | ||
|
|
edc65a6a34 | ||
|
|
8412f9481c | ||
|
|
95965d65c9 | ||
|
|
9777f25b9f | ||
|
|
6e3a32567c | ||
|
|
319799b5ce | ||
|
|
128327b8a3 | ||
|
|
6f8f3f98b4 | ||
|
|
2e272132b0 | ||
|
|
1dbae5b64c | ||
|
|
6d23d9ebb7 | ||
|
|
ec9b33ac97 | ||
|
|
5aa2a6eefe | ||
|
|
103a44991a | ||
|
|
0fcdd63941 | ||
|
|
8695714c65 | ||
|
|
2001cfe864 | ||
|
|
3badfd756c | ||
|
|
b08d9a5a8e | ||
|
|
ecf40e1525 | ||
|
|
909ddd653c | ||
|
|
2f19350ff5 | ||
|
|
68ef5177f0 | ||
|
|
ba138de53b | ||
|
|
2303034c92 | ||
|
|
2ceed475d5 | ||
|
|
e2398c8c0e | ||
|
|
36cf40f25c | ||
|
|
59c1e34024 | ||
|
|
2ac53afd96 | ||
|
|
d60a2d3723 | ||
|
|
4d2c75a824 | ||
|
|
99c72c78c1 | ||
|
|
052555c3f7 | ||
|
|
9a0bc16c86 | ||
|
|
6663844a86 | ||
|
|
d6386f739e | ||
|
|
afda46d587 | ||
|
|
603c804535 | ||
|
|
fce19a363d | ||
|
|
e3820e93b7 | ||
|
|
459e485555 | ||
|
|
548127cc88 | ||
|
|
a1b816b403 | ||
|
|
5ad5994b9d | ||
|
|
25d6bbf659 | ||
|
|
75354a8a78 | ||
|
|
d1bd010e05 | ||
|
|
bb6be8e3d3 | ||
|
|
fc2d08c407 | ||
|
|
40938f0c8a | ||
|
|
490dee1fa0 | ||
|
|
27796bbd08 | ||
|
|
b5d53fa850 | ||
|
|
7b24984280 | ||
|
|
37564d630a | ||
|
|
380a5cf8a7 | ||
|
|
f2b26282b8 | ||
|
|
31bb70d9a2 | ||
|
|
2608b3f9f3 | ||
|
|
e76b1f1daa | ||
|
|
6cb31a274f | ||
|
|
181bcd70ad | ||
|
|
eb8c0539c5 | ||
|
|
4f3fde8055 | ||
|
|
c832e3c2c7 | ||
|
|
88bf183af5 | ||
|
|
11a247edc2 | ||
|
|
328d639886 | ||
|
|
fd18395f78 | ||
|
|
360c56ec34 | ||
|
|
3890d17c61 | ||
|
|
2d4694e72d | ||
|
|
54ece346bc | ||
|
|
5e71bad5cf | ||
|
|
d1adc5ea9b | ||
|
|
bb755daf8b | ||
|
|
ef978b2ebf | ||
|
|
d0f0aef2ef | ||
|
|
448dc1442c | ||
|
|
4ae05dddeb | ||
|
|
b318bde76c | ||
|
|
c7aa0a2321 | ||
|
|
58f4e3756c | ||
|
|
067e89f6a0 | ||
|
|
efed2bc262 | ||
|
|
adf0255bdf | ||
|
|
1e259f3043 | ||
|
|
ffa3a229b5 | ||
|
|
0f8df8c985 | ||
|
|
ed0b38c7a7 | ||
|
|
fa0ff8be39 | ||
|
|
5d724f6b84 | ||
|
|
ffb43a8534 | ||
|
|
ce5fd7955f | ||
|
|
e917535380 | ||
|
|
e905a36fb2 | ||
|
|
7dc4f8d5cc | ||
|
|
da906f48d9 | ||
|
|
057a022205 | ||
|
|
7a54bd9f2a | ||
|
|
9b48a26aef | ||
|
|
a37d06064a | ||
|
|
c1eea166c9 | ||
|
|
25cbab2ea4 | ||
|
|
cf81a8979f | ||
|
|
e3a8638471 | ||
|
|
d26fcc9918 | ||
|
|
8e9dc9f20e | ||
|
|
81d08ac50b | ||
|
|
e13320f58d | ||
|
|
1f727f565f | ||
|
|
4078d9b669 | ||
|
|
3b54d6f8e5 | ||
|
|
58b4f6abca | ||
|
|
f041c762ac | ||
|
|
88ae522c9a | ||
|
|
5cdaaed311 | ||
|
|
e0037c7f70 | ||
|
|
4301c06d17 | ||
|
|
90bc1cd951 | ||
|
|
67784c0568 | ||
|
|
892c0e3d8b | ||
|
|
54dd20cdb4 | ||
|
|
2f53411efc | ||
|
|
7a858cea23 | ||
|
|
987414ed7b | ||
|
|
047286f9c0 | ||
|
|
a2955196af | ||
|
|
62224857f0 | ||
|
|
c1a37db871 | ||
|
|
a8145fe4c2 | ||
|
|
3b44e7c1c4 | ||
|
|
830fd5f83a | ||
|
|
f83e435a90 | ||
|
|
0ebd87bcb9 | ||
|
|
286a3e6ca2 | ||
|
|
d65cead212 | ||
|
|
e21cbf2a06 | ||
|
|
1fae9aff0c | ||
|
|
a06d74472d | ||
|
|
f8851121ab | ||
|
|
e9f8640ee6 | ||
|
|
cde1db4436 | ||
|
|
dc161d9f2f | ||
|
|
040fadb0c3 | ||
|
|
bb1484a444 | ||
|
|
b31cc89478 | ||
|
|
05c851301e | ||
|
|
dbf6c0a075 | ||
|
|
3084d58da1 | ||
|
|
19b57aa1ea | ||
|
|
d157818d7e | ||
|
|
ddcd172af1 | ||
|
|
19407ba3bc | ||
|
|
3502398d1d | ||
|
|
205acd2c4d | ||
|
|
e463430d51 | ||
|
|
cae412d280 | ||
|
|
a62b98ac50 | ||
|
|
7a7634de2d | ||
|
|
c6e85970d4 | ||
|
|
110bad7041 | ||
|
|
85e932bfc1 | ||
|
|
7b01ba9776 | ||
|
|
32620dd556 | ||
|
|
76f74f479b | ||
|
|
e9831442cd | ||
|
|
5d4cc5bf3d | ||
|
|
26d7c21314 | ||
|
|
a4af270ea8 | ||
|
|
b6c38ceb73 | ||
|
|
3a9512f086 | ||
|
|
9679557747 | ||
|
|
3c334a0238 | ||
|
|
5574aaa8cb | ||
|
|
e23b2c4c4f | ||
|
|
5b6a6fb63e | ||
|
|
58989b85c8 | ||
|
|
8786bb25c5 | ||
|
|
670139492d | ||
|
|
5d3cf8074b | ||
|
|
85c54703ec | ||
|
|
02687453f2 | ||
|
|
90828cedae | ||
|
|
f65b2278f0 | ||
|
|
bdfc0364d5 | ||
|
|
65bd3fbddb | ||
|
|
f8e29ea66a | ||
|
|
a8ed04c4d2 | ||
|
|
73b7eb0c7f | ||
|
|
486f1a74ab | ||
|
|
5d36d81ae1 | ||
|
|
dc56e49410 | ||
|
|
ca199cdefe | ||
|
|
b2ba9d68c9 | ||
|
|
00ce3588d3 | ||
|
|
814aff78b5 | ||
|
|
a261d10bfd | ||
|
|
ce46512c74 | ||
|
|
fb7446487e | ||
|
|
a6a88a0d2e | ||
|
|
4cee506710 | ||
|
|
5dddf6846b | ||
|
|
03da9348e5 | ||
|
|
28a14cf5ae | ||
|
|
635fefcb5c | ||
|
|
77a49fa40e | ||
|
|
5273b9d0ee | ||
|
|
ae7445ee8e | ||
|
|
3ef4287d57 | ||
|
|
581dc4e070 | ||
|
|
1bce148be2 | ||
|
|
eb9147a575 | ||
|
|
781334b615 | ||
|
|
5282ae2250 | ||
|
|
bae050e689 | ||
|
|
ab60a5d73d | ||
|
|
71d4b5c5df | ||
|
|
7e64d3e653 | ||
|
|
e7fde2795f | ||
|
|
f36c797e98 | ||
|
|
49b780358e | ||
|
|
af8e1a6472 | ||
|
|
91362b0f82 | ||
|
|
e61fc1f709 | ||
|
|
8fd860a413 | ||
|
|
82c247f3cf | ||
|
|
5e5038d780 | ||
|
|
2b32430a10 | ||
|
|
5381c4e0ae | ||
|
|
406b076b95 | ||
|
|
993ee8c900 | ||
|
|
cc6e74dfd5 | ||
|
|
40c590f445 | ||
|
|
5486cff441 | ||
|
|
a928d337d9 | ||
|
|
fa8407371b | ||
|
|
8c40148ca7 | ||
|
|
8eb4d0a36b | ||
|
|
64f60228ec | ||
|
|
f3b22acc9a | ||
|
|
aeb32104a4 | ||
|
|
73895b1c88 | ||
|
|
f54fb67efc | ||
|
|
be5962fb3a | ||
|
|
94d0ebbd7d | ||
|
|
a275a30dca | ||
|
|
c90f680284 | ||
|
|
daa2c6ff21 | ||
|
|
63f842c7db | ||
|
|
06aca2e1d5 | ||
|
|
3abb52a085 | ||
|
|
87fa6bc252 | ||
|
|
4b5d64939d | ||
|
|
6624fc6076 | ||
|
|
43ad9aa2b1 |
@@ -42,10 +42,6 @@ django-tables2
|
||||
# https://github.com/alex/django-taggit
|
||||
django-taggit
|
||||
|
||||
# A Django REST Framework serializer which represents tags
|
||||
# https://github.com/glemmaPaul/django-taggit-serializer
|
||||
django-taggit-serializer
|
||||
|
||||
# A Django field for representing time zones
|
||||
# https://github.com/mfogel/django-timezone-field/
|
||||
django-timezone-field
|
||||
|
||||
43
docs/administration/permissions.md
Normal file
43
docs/administration/permissions.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Permissions
|
||||
|
||||
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
|
||||
|
||||
{!docs/models/users/objectpermission.md!}
|
||||
|
||||
### Example Constraint Definitions
|
||||
|
||||
| Query Filter | Permission Constraints |
|
||||
| ------------ | --------------------- |
|
||||
| `filter(status='active')` | `{"status": "active"}` |
|
||||
| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` |
|
||||
| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` |
|
||||
| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` |
|
||||
| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` |
|
||||
|
||||
## Permissions Enforcement
|
||||
|
||||
### Viewing Objects
|
||||
|
||||
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
|
||||
|
||||
If the permission has been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
|
||||
|
||||
```json
|
||||
[
|
||||
{"site__name__in": ["NYC1", "NYC2"]},
|
||||
{"status": "offline", "tenant__isnull": true}
|
||||
]
|
||||
```
|
||||
|
||||
This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in the following ORM query:
|
||||
|
||||
```no-highlight
|
||||
Site.objects.filter(
|
||||
Q(site__name__in=['NYC1', 'NYC2']),
|
||||
Q(status='active', tenant__isnull=True)
|
||||
)
|
||||
```
|
||||
|
||||
### Creating and Modifying Objects
|
||||
|
||||
The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then aborted, and the database is left in its original state.
|
||||
@@ -172,6 +172,9 @@ To exempt _all_ models from view permission enforcement, set the following. (Not
|
||||
EXEMPT_VIEW_PERMISSIONS = ['*']
|
||||
```
|
||||
|
||||
!!! note
|
||||
Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually.
|
||||
|
||||
---
|
||||
|
||||
## ENFORCE_GLOBAL_UNIQUE
|
||||
@@ -408,9 +411,12 @@ NetBox can be configured to support remote user authentication by inferring user
|
||||
|
||||
## REMOTE_AUTH_BACKEND
|
||||
|
||||
Default: `'utilities.auth_backends.RemoteUserBackend'`
|
||||
Default: `'netbox.authentication.RemoteUserBackend'`
|
||||
|
||||
Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though backends may also be provided via other packages.
|
||||
|
||||
* `netbox.authentication.RemoteUserBackend`
|
||||
* `netbox.authentication.LDAPBackend`
|
||||
|
||||
---
|
||||
|
||||
@@ -440,9 +446,9 @@ The list of groups to assign a new user account when created using remote authen
|
||||
|
||||
## REMOTE_AUTH_DEFAULT_PERMISSIONS
|
||||
|
||||
Default: `[]` (Empty list)
|
||||
Default: `{}` (Empty dictionary)
|
||||
|
||||
The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
---
|
||||
|
||||
{!docs/models/virtualization/virtualmachine.md!}
|
||||
{!docs/models/virtualization/vminterface.md!}
|
||||
|
||||
@@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
|
||||
|
||||
## Individual Views
|
||||
|
||||
### ObjectView
|
||||
|
||||
Retrieve and display a single object.
|
||||
|
||||
### ObjectListView
|
||||
|
||||
Generates a paginated table of objects from a given queryset, which may optionally be filtered.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
|
||||
|
||||
!!! warning
|
||||
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
|
||||
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
|
||||
|
||||
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
|
||||
|
||||
@@ -20,7 +20,7 @@ If a recent enough version of PostgreSQL is not available through your distribut
|
||||
|
||||
#### CentOS
|
||||
|
||||
CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
|
||||
CentOS 7 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
|
||||
|
||||
```no-highlight
|
||||
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
|
||||
@@ -47,11 +47,11 @@ Then, start the service and enable it to run at boot:
|
||||
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
|
||||
|
||||
!!! danger
|
||||
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
|
||||
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
|
||||
|
||||
```no-highlight
|
||||
# sudo -u postgres psql
|
||||
psql (10.10)
|
||||
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
|
||||
Type "help" for help.
|
||||
|
||||
postgres=# CREATE DATABASE netbox;
|
||||
@@ -68,7 +68,13 @@ postgres=# \q
|
||||
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
|
||||
|
||||
```no-highlight
|
||||
# psql -U netbox -W -h localhost netbox
|
||||
# psql --username netbox --password --host localhost netbox
|
||||
Password for user netbox:
|
||||
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
|
||||
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
|
||||
Type "help" for help.
|
||||
|
||||
netbox=> \q
|
||||
```
|
||||
|
||||
If successful, you will enter a `netbox` prompt. Type `\q` to exit.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
|
||||
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
|
||||
|
||||
!!! note
|
||||
NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
```no-highlight
|
||||
|
||||
@@ -4,7 +4,10 @@ This section of the documentation discusses installing and configuring the NetBo
|
||||
|
||||
## Install System Packages
|
||||
|
||||
Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required.
|
||||
Begin by installing all system packages required by NetBox and its dependencies.
|
||||
|
||||
!!! note
|
||||
NetBox v2.8.0 and later require Python 3.6 or 3.7. This documentation assumes Python 3.6.
|
||||
|
||||
### Ubuntu
|
||||
|
||||
@@ -19,22 +22,32 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
# easy_install-3.6 pip
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
|
||||
```no-highlight
|
||||
# pip install --upgrade pip
|
||||
```
|
||||
|
||||
## Download NetBox
|
||||
|
||||
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
|
||||
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and decompressing the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
|
||||
|
||||
### Option A: Download a Release
|
||||
### Option A: Download a Release Archive
|
||||
|
||||
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
|
||||
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
|
||||
|
||||
```no-highlight
|
||||
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
# tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
# cd /opt/
|
||||
# ln -s netbox-X.Y.Z/ netbox
|
||||
# cd /opt/netbox/
|
||||
# ln -s /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# ls -l /opt | grep netbox
|
||||
lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
|
||||
drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
|
||||
```
|
||||
|
||||
!!! note
|
||||
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v2.9.0 would be installed into `/opt/netbox-2.9.0`, and a symlink from `/opt/netbox/` would point to this location. This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
|
||||
|
||||
### Option B: Clone the Git Repository
|
||||
|
||||
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
|
||||
@@ -57,7 +70,7 @@ If `git` is not already installed, install it:
|
||||
# yum install -y git
|
||||
```
|
||||
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
|
||||
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
|
||||
|
||||
```no-highlight
|
||||
# git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
@@ -70,72 +83,38 @@ Resolving deltas: 100% (1495/1495), done.
|
||||
Checking connectivity... done.
|
||||
```
|
||||
|
||||
## Create the NetBox User
|
||||
|
||||
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
|
||||
|
||||
!!! note
|
||||
CentOS users may need to create the `netbox` group first.
|
||||
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
|
||||
|
||||
## Create the NetBox System User
|
||||
|
||||
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
|
||||
|
||||
#### Ubuntu
|
||||
|
||||
```
|
||||
# groupadd --system netbox
|
||||
# adduser --system --gid netbox netbox
|
||||
# adduser --system --group netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
## Set Up Python Environment
|
||||
#### CentOS
|
||||
|
||||
We'll use a Python [virtual environment](https://docs.python.org/3.6/tutorial/venv.html) to ensure NetBox's required packages don't conflict with anything in the base system. This will create a directory named `venv` in our NetBox root.
|
||||
|
||||
```no-highlight
|
||||
# python3 -m venv /opt/netbox/venv
|
||||
```
|
||||
|
||||
Next, activate the virtual environment and install the required Python packages. You should see your console prompt change to indicate the active environment. (Activating the virtual environment updates your command shell to use the local copy of Python that we just installed for NetBox instead of the system's Python interpreter.)
|
||||
|
||||
```no-highlight
|
||||
# source venv/bin/activate
|
||||
(venv) # pip3 install -r requirements.txt
|
||||
```
|
||||
|
||||
### NAPALM Automation (Optional)
|
||||
|
||||
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package:
|
||||
|
||||
```no-highlight
|
||||
(venv) # pip3 install napalm
|
||||
```
|
||||
|
||||
To ensure NAPALM is automatically re-installed during future upgrades, create a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`) and list the `napalm` package:
|
||||
|
||||
```no-highlight
|
||||
# echo napalm >> local_requirements.txt
|
||||
```
|
||||
|
||||
### Remote File Storage (Optional)
|
||||
|
||||
By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
(venv) # pip3 install django-storages
|
||||
```
|
||||
|
||||
Don't forget to add the `django-storages` package to `local_requirements.txt` to ensure it gets re-installed during future upgrades:
|
||||
|
||||
```no-highlight
|
||||
# echo django-storages >> local_requirements.txt
|
||||
# groupadd --system netbox
|
||||
# adduser --system -g netbox netbox
|
||||
# chown --recursive netbox /opt/netbox/netbox/media/
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters.
|
||||
|
||||
```no-highlight
|
||||
(venv) # cd netbox/netbox/
|
||||
(venv) # cp configuration.example.py configuration.py
|
||||
# cd /opt/netbox/netbox/netbox/
|
||||
# cp configuration.example.py configuration.py
|
||||
```
|
||||
|
||||
Open `configuration.py` with your preferred editor and set the following variables:
|
||||
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
|
||||
|
||||
* `ALLOWED_HOSTS`
|
||||
* `DATABASE`
|
||||
@@ -144,19 +123,21 @@ Open `configuration.py` with your preferred editor and set the following variabl
|
||||
|
||||
### ALLOWED_HOSTS
|
||||
|
||||
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
|
||||
|
||||
Example:
|
||||
This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting).)
|
||||
|
||||
```python
|
||||
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
|
||||
```
|
||||
|
||||
If you are not yet sure what the domain name and/or IP address of the NetBox installation will be, you can set this to a wildcard (asterisk) to allow all host values:
|
||||
|
||||
```python
|
||||
ALLOWED_HOSTS = ['*']
|
||||
```
|
||||
|
||||
### DATABASE
|
||||
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../../configuration/required-settings/#database) for more detail on individual parameters.
|
||||
|
||||
Example:
|
||||
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters.
|
||||
|
||||
```python
|
||||
DATABASE = {
|
||||
@@ -165,29 +146,31 @@ DATABASE = {
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
'HOST': 'localhost', # Database server
|
||||
'PORT': '', # Database port (leave blank for default)
|
||||
'CONN_MAX_AGE': 300, # Max database connection age
|
||||
'CONN_MAX_AGE': 300, # Max database connection age (seconds)
|
||||
}
|
||||
```
|
||||
|
||||
### REDIS
|
||||
|
||||
Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../../configuration/required-settings/#redis) for more detail on individual parameters.
|
||||
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
|
||||
|
||||
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique database ID.
|
||||
|
||||
```python
|
||||
REDIS = {
|
||||
'tasks': {
|
||||
'HOST': 'redis.example.com',
|
||||
'PORT': 1234,
|
||||
'PASSWORD': 'foobar',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
'HOST': 'localhost', # Redis server
|
||||
'PORT': 6379, # Redis port
|
||||
'PASSWORD': '', # Redis password (optional)
|
||||
'DATABASE': 0, # Database ID
|
||||
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
|
||||
'SSL': False, # Use SSL (optional)
|
||||
},
|
||||
'caching': {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DATABASE': 1, # Unique ID for second database
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
@@ -196,37 +179,69 @@ REDIS = {
|
||||
|
||||
### SECRET_KEY
|
||||
|
||||
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
|
||||
This parameter must be assigned a randomly-generated key employed as a salt for hashing and related cryptographic functions. (Note, however, that it is _never_ directly used in the encryption of secret data.) This key must be unique to this installation and is recommended to be at least 50 characters long. It should not be shared outside the local system.
|
||||
|
||||
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
|
||||
|
||||
!!! note
|
||||
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
|
||||
|
||||
## Run Database Migrations
|
||||
|
||||
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
|
||||
A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key:
|
||||
|
||||
```no-highlight
|
||||
(venv) # cd /opt/netbox/netbox/
|
||||
(venv) # python3 manage.py migrate
|
||||
Operations to perform:
|
||||
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
|
||||
Running migrations:
|
||||
Rendering model states... DONE
|
||||
Applying contenttypes.0001_initial... OK
|
||||
Applying auth.0001_initial... OK
|
||||
Applying admin.0001_initial... OK
|
||||
...
|
||||
# python3 ../generate_secret_key.py
|
||||
```
|
||||
|
||||
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
|
||||
!!! warning
|
||||
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
|
||||
|
||||
When you have finished modifying the configuration, remember to save the file.
|
||||
|
||||
## Optional Requirements
|
||||
|
||||
All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
|
||||
|
||||
### NAPALM
|
||||
|
||||
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
|
||||
|
||||
```no-highlight
|
||||
# echo napalm >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
### Remote File Storage
|
||||
|
||||
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
|
||||
|
||||
```no-highlight
|
||||
# echo django-storages >> /opt/netbox/local_requirements.txt
|
||||
```
|
||||
|
||||
## Run the Upgrade Script
|
||||
|
||||
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
|
||||
|
||||
* Create a Python virtual environment
|
||||
* Install all required Python packages
|
||||
* Run database schema migrations
|
||||
* Aggregate static resource files on disk
|
||||
|
||||
```no-highlight
|
||||
# /opt/netbox/upgrade.sh
|
||||
```
|
||||
|
||||
!!! note
|
||||
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
|
||||
|
||||
## Create a Super User
|
||||
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
|
||||
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
|
||||
|
||||
```no-highlight
|
||||
# source /opt/netbox/venv/bin/activate
|
||||
```
|
||||
|
||||
Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt.
|
||||
|
||||
Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
|
||||
|
||||
```no-highlight
|
||||
(venv) # cd /opt/netbox/netbox
|
||||
(venv) # python3 manage.py createsuperuser
|
||||
Username: admin
|
||||
Email address: admin@example.com
|
||||
@@ -235,17 +250,9 @@ Password (again):
|
||||
Superuser created successfully.
|
||||
```
|
||||
|
||||
## Collect Static Files
|
||||
|
||||
```no-highlight
|
||||
(venv) # python3 manage.py collectstatic --no-input
|
||||
|
||||
959 static files copied to '/opt/netbox/netbox/static'.
|
||||
```
|
||||
|
||||
## Test the Application
|
||||
|
||||
At this point, NetBox should be able to run. We can verify this by starting a development instance:
|
||||
At this point, we should be able to run NetBox. We can check by starting a development instance:
|
||||
|
||||
```no-highlight
|
||||
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure
|
||||
@@ -267,6 +274,6 @@ Note that the initial UI will be locked down for non-authenticated users.
|
||||
|
||||

|
||||
|
||||
After logging in as the superuser you created earlier, all areas of the UI will be available.
|
||||
Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI:
|
||||
|
||||

|
||||
|
||||
49
docs/installation/4-gunicorn.md
Normal file
49
docs/installation/4-gunicorn.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# Gunicorn
|
||||
|
||||
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) for this role, however other WSGIs are available and should work similarly well.
|
||||
|
||||
## Configuration
|
||||
|
||||
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any changes to it do not get overwritten by a future upgrade.)
|
||||
|
||||
```no-highlight
|
||||
# cd /opt/netbox
|
||||
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
|
||||
```
|
||||
|
||||
While this default configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
|
||||
|
||||
## systemd Setup
|
||||
|
||||
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
|
||||
|
||||
```no-highlight
|
||||
# cp contrib/*.service /etc/systemd/system/
|
||||
# systemctl daemon-reload
|
||||
```
|
||||
|
||||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
# systemctl start netbox netbox-rq
|
||||
# systemctl enable netbox netbox-rq
|
||||
```
|
||||
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
|
||||
```no-highlight
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
...
|
||||
```
|
||||
|
||||
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
|
||||
@@ -1,9 +1,9 @@
|
||||
# HTTP Server Setup
|
||||
|
||||
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll use systemd to enable service persistence.
|
||||
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible.
|
||||
|
||||
!!! info
|
||||
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
|
||||
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
|
||||
|
||||
## Obtain an SSL Certificate
|
||||
|
||||
@@ -17,17 +17,19 @@ The command below can be used to generate a self-signed certificate for testing
|
||||
-out /etc/ssl/certs/netbox.crt
|
||||
```
|
||||
|
||||
## HTTP Daemon Installation
|
||||
The above command will prompt you for additional details of the certificate; all of these are optional.
|
||||
|
||||
## HTTP Server Installation
|
||||
|
||||
### Option A: nginx
|
||||
|
||||
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
|
||||
Begin by installing nginx:
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y nginx
|
||||
```
|
||||
|
||||
Once nginx is installed, copy the default nginx configuration file to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
|
||||
Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
|
||||
|
||||
```no-highlight
|
||||
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
|
||||
@@ -69,67 +71,25 @@ Finally, ensure that the required Apache modules are enabled, enable the `netbox
|
||||
# service apache2 restart
|
||||
```
|
||||
|
||||
!!! note
|
||||
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
|
||||
|
||||
## Gunicorn Configuration
|
||||
|
||||
Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.)
|
||||
|
||||
```no-highlight
|
||||
# cd /opt/netbox
|
||||
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
|
||||
```
|
||||
|
||||
You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
|
||||
|
||||
## systemd Configuration
|
||||
|
||||
We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
|
||||
|
||||
```no-highlight
|
||||
# cp contrib/*.service /etc/systemd/system/
|
||||
```
|
||||
|
||||
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
|
||||
|
||||
```no-highlight
|
||||
# systemctl daemon-reload
|
||||
# systemctl start netbox netbox-rq
|
||||
# systemctl enable netbox netbox-rq
|
||||
```
|
||||
|
||||
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
|
||||
|
||||
```no-highlight
|
||||
# systemctl status netbox.service
|
||||
● netbox.service - NetBox WSGI Service
|
||||
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
|
||||
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
|
||||
Docs: https://netbox.readthedocs.io/en/stable/
|
||||
Main PID: 11993 (gunicorn)
|
||||
Tasks: 6 (limit: 2362)
|
||||
CGroup: /system.slice/netbox.service
|
||||
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
|
||||
...
|
||||
```
|
||||
## Confirm Connectivity
|
||||
|
||||
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
|
||||
|
||||
!!! info
|
||||
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
|
||||
|
||||
!!! warning
|
||||
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
If you are unable to connect to the HTTP server, check that:
|
||||
|
||||
* Nginx/Apache is running and configured to listen on the correct port.
|
||||
* Access is not being blocked by a firewall. (Try connecting locally from the server itself.)
|
||||
* Access is not being blocked by a firewall somewhere along the path. (Try connecting locally from the server itself.)
|
||||
|
||||
If you are able to connect but receive a 502 (bad gateway) error, check the following:
|
||||
|
||||
* The NetBox system process (gunicorn) is running: `systemctl status netbox`
|
||||
* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
|
||||
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
|
||||
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`
|
||||
@@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i
|
||||
|
||||
## Configuration
|
||||
|
||||
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.)
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
|
||||
```
|
||||
|
||||
Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
|
||||
|
||||
### General Server Configuration
|
||||
|
||||
@@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
|
||||
my_logger = logging.getLogger('django_auth_ldap')
|
||||
my_logger.setLevel(logging.DEBUG)
|
||||
handler = logging.handlers.RotatingFileHandler(
|
||||
logfile, maxBytes=1024 * 500, backupCount=5)
|
||||
logfile, maxBytes=1024 * 500, backupCount=5
|
||||
)
|
||||
my_logger.addHandler(handler)
|
||||
```
|
||||
|
||||
@@ -5,8 +5,9 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
1. [PostgreSQL database](1-postgresql.md)
|
||||
1. [Redis](2-redis.md)
|
||||
3. [NetBox components](3-netbox.md)
|
||||
4. [HTTP daemon](4-http-daemon.md)
|
||||
5. [LDAP authentication](5-ldap.md) (optional)
|
||||
4. [Gunicorn](4-gunicorn.md)
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
Below is a simplified overview of the NetBox application stack for reference:
|
||||
|
||||
@@ -16,4 +17,5 @@ Below is a simplified overview of the NetBox application stack for reference:
|
||||
|
||||
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
|
||||
|
||||
Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.
|
||||
!!! note
|
||||
Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.
|
||||
|
||||
36
docs/models/users/objectpermission.md
Normal file
36
docs/models/users/objectpermission.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Object Permissions
|
||||
|
||||
Assigning a permission in NetBox entails defining a relationship among several components:
|
||||
|
||||
* Object type(s) - One or more types of object in NetBox
|
||||
* User(s) - One or more users or groups of users
|
||||
* Actions - The actions that can be performed (view, add, change, and/or delete)
|
||||
* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
|
||||
|
||||
At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
|
||||
|
||||
## Actions
|
||||
|
||||
There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
|
||||
|
||||
* View - Retrieve an object from the database
|
||||
* Add - Create a new object
|
||||
* Change - Modify an existing object
|
||||
* Delete - Delete an existing object
|
||||
|
||||
Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
|
||||
|
||||
## Constraints
|
||||
|
||||
Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
|
||||
|
||||
All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints.
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "active",
|
||||
"region__name": "Americas"
|
||||
}
|
||||
```
|
||||
|
||||
The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.
|
||||
3
docs/models/virtualization/vminterface.md
Normal file
3
docs/models/virtualization/vminterface.md
Normal file
@@ -0,0 +1,3 @@
|
||||
## Interfaces
|
||||
|
||||
Virtual machine interfaces are similar to device interfaces, but lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. However, they can have IP address and VLANs (both tagged and untagged) associated with them, just as device interfaces do.
|
||||
@@ -1 +1 @@
|
||||
version-2.8.md
|
||||
version-2.9.md
|
||||
94
docs/release-notes/version-2.9.md
Normal file
94
docs/release-notes/version-2.9.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9-beta1 (2020-07-23)
|
||||
|
||||
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
|
||||
|
||||
### New Features
|
||||
|
||||
#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
|
||||
|
||||
NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group permission to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would allow the associated users/groups to perform an action only on devices assigned to a tenant belonging to the "Customers" group.
|
||||
|
||||
#### Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006))
|
||||
|
||||
When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model
|
||||
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
|
||||
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates
|
||||
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
|
||||
* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
|
||||
* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
|
||||
* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates
|
||||
* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports
|
||||
* [#4806](https://github.com/netbox-community/netbox/issues/4806) - Add a `url` field to all API serializers
|
||||
* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates
|
||||
* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters
|
||||
* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions)
|
||||
* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts
|
||||
* [#4877](https://github.com/netbox-community/netbox/issues/4877) - Add REST API endpoints for users and groups
|
||||
|
||||
### Configuration Changes
|
||||
|
||||
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
|
||||
### REST API Changes
|
||||
|
||||
* Added new endpoints for users, groups, and permissions under `/api/users/`.
|
||||
* A `url` field is now included on all object representations, identifying the unique REST API URL for each object.
|
||||
* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
|
||||
* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example:
|
||||
|
||||
```json
|
||||
"tags": [
|
||||
{"name": "First Tag"},
|
||||
{"name": "Second Tag"}
|
||||
]
|
||||
```
|
||||
|
||||
* Legacy numeric values for choice fields are no longer conveyed or accepted.
|
||||
* dcim.Cable: Added `tags` field
|
||||
* dcim.ConsolePort: Added `label` field
|
||||
* dcim.ConsolePortTemplate: Added `description` and `label` fields
|
||||
* dcim.ConsoleServerPort: Added `label` field
|
||||
* dcim.ConsoleServerPortTemplate: Added `description` and `label` fields
|
||||
* dcim.DeviceBay: Added `label` field
|
||||
* dcim.DeviceBayTemplate: Added `description` and `label` fields
|
||||
* dcim.FrontPort: Added `label` field
|
||||
* dcim.FrontPortTemplate: Added `description` and `label` fields
|
||||
* dcim.Interface: Added `label` field
|
||||
* dcim.InterfaceTemplate: Added `description` and `label` fields
|
||||
* dcim.PowerPanel: Added `tags` field
|
||||
* dcim.PowerPort: Added ``label` field
|
||||
* dcim.PowerPortTemplate: Added `description` and `label` fields
|
||||
* dcim.PowerOutlet: Added `label` field
|
||||
* dcim.PowerOutletTemplate: Added `description` and `label` fields
|
||||
* dcim.RackGroup: Added a `_depth` attribute indicating an object's position in the tree.
|
||||
* dcim.RackReservation: Added `tags` field
|
||||
* dcim.RearPort: Added `label` field
|
||||
* dcim.RearPortTemplate: Added `description` and `label` fields
|
||||
* dcim.Region: Added a `_depth` attribute indicating an object's position in the tree.
|
||||
* dcim.VirtualChassis: Added `name` field (required)
|
||||
* extras.ConfigContext: Added `created` and `last_updated` fields
|
||||
* extras.JobResult: Added the `/api/extras/job-results/` endpoint
|
||||
* extras.Report: The `failed` field has been removed. The `completed` (boolean) and `status` (string) fields have been introduced to convey the status of a report's most recent execution. Additionally, the `result` field now conveys the nested representation of a JobResult.
|
||||
* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult.
|
||||
* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
|
||||
* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
|
||||
* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree.
|
||||
* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint
|
||||
* virtualization.VMInterface: Removed `type` field (VM interfaces have no type)
|
||||
|
||||
### Other Changes
|
||||
|
||||
* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly.
|
||||
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
|
||||
* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
|
||||
* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead).
|
||||
* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`).
|
||||
* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add/` rather than via the devices list.
|
||||
* A name is required when creating a virtual chassis.
|
||||
@@ -20,8 +20,9 @@ nav:
|
||||
- 1. PostgreSQL: 'installation/1-postgresql.md'
|
||||
- 2. Redis: 'installation/2-redis.md'
|
||||
- 3. NetBox: 'installation/3-netbox.md'
|
||||
- 4. HTTP Daemon: 'installation/4-http-daemon.md'
|
||||
- 5. LDAP (Optional): 'installation/5-ldap.md'
|
||||
- 4. Gunicorn: 'installation/4-gunicorn.md'
|
||||
- 5. HTTP Server: 'installation/5-http-server.md'
|
||||
- 6. LDAP (Optional): 'installation/6-ldap.md'
|
||||
- Upgrading NetBox: 'installation/upgrading.md'
|
||||
- Migrating to systemd: 'installation/migrating-to-systemd.md'
|
||||
- Configuration:
|
||||
@@ -58,6 +59,7 @@ nav:
|
||||
- Using Plugins: 'plugins/index.md'
|
||||
- Developing Plugins: 'plugins/development.md'
|
||||
- Administration:
|
||||
- Permissions: 'administration/permissions.md'
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
- API:
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from rest_framework import serializers
|
||||
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from circuits.choices import CircuitStatusChoices
|
||||
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
|
||||
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
|
||||
from dcim.api.serializers import ConnectedEndpointSerializer
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
|
||||
from .nested_serializers import *
|
||||
@@ -15,14 +15,14 @@ from .nested_serializers import *
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
tags = TagListSerializerField(required=False)
|
||||
class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
|
||||
'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
]
|
||||
|
||||
@@ -32,11 +32,12 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class CircuitTypeSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug', 'description', 'circuit_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count']
|
||||
|
||||
|
||||
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
@@ -49,24 +50,25 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
|
||||
|
||||
|
||||
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
provider = NestedProviderSerializer()
|
||||
status = ChoiceField(choices=CircuitStatusChoices, required=False)
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
termination_a = CircuitCircuitTerminationSerializer(read_only=True)
|
||||
termination_z = CircuitCircuitTerminationSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Circuit
|
||||
fields = [
|
||||
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
|
||||
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class CircuitTerminationSerializer(ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
|
||||
circuit = NestedCircuitSerializer()
|
||||
site = NestedSiteSerializer()
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
@@ -74,6 +76,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -19,7 +19,7 @@ from . import serializers
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=Count('circuits')
|
||||
)
|
||||
).order_by(*Provider._meta.ordering)
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilterSet
|
||||
|
||||
@@ -28,8 +28,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular provider.
|
||||
"""
|
||||
provider = get_object_or_404(Provider, pk=pk)
|
||||
queryset = Graph.objects.filter(type__model='provider')
|
||||
provider = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -41,7 +41,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=Count('circuits')
|
||||
)
|
||||
).order_by(*CircuitType._meta.ordering)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilterSet
|
||||
|
||||
@@ -52,7 +52,10 @@ class CircuitTypeViewSet(ModelViewSet):
|
||||
|
||||
class CircuitViewSet(CustomFieldModelViewSet):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
|
||||
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
|
||||
'site', 'connected_endpoint__device'
|
||||
)),
|
||||
'type', 'tenant', 'provider',
|
||||
).prefetch_related('tags')
|
||||
serializer_class = serializers.CircuitSerializer
|
||||
filterset_class = filters.CircuitFilterSet
|
||||
|
||||
@@ -23,15 +23,6 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONED, 'Decommissioned'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_DEPROVISIONING: 0,
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_PLANNED: 2,
|
||||
STATUS_PROVISIONING: 3,
|
||||
STATUS_OFFLINE: 4,
|
||||
STATUS_DECOMMISSIONED: 5,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CircuitTerminations
|
||||
|
||||
@@ -3,8 +3,8 @@ from django import forms
|
||||
from dcim.models import Region, Site
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
|
||||
TagField,
|
||||
)
|
||||
from extras.models import Tag
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
queryset=CircuitType.objects.all()
|
||||
)
|
||||
comments = CommentField()
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1b1 on 2020-07-16 15:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0018_standardize_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuittermination',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -6,9 +6,9 @@ from taggit.managers import TaggableManager
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import CableTermination
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import serialize_object
|
||||
from .choices import *
|
||||
from .querysets import CircuitQuerySet
|
||||
@@ -66,9 +66,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
|
||||
]
|
||||
@@ -115,6 +116,8 @@ class CircuitType(ChangeLoggedModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -272,9 +275,10 @@ class CircuitTermination(CableTermination):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
port_speed = models.PositiveIntegerField(
|
||||
verbose_name='Port speed (Kbps)'
|
||||
@@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['circuit', 'term_side']
|
||||
unique_together = ['circuit', 'term_side']
|
||||
@@ -330,6 +336,9 @@ class CircuitTermination(CableTermination):
|
||||
def get_peer_termination(self):
|
||||
peer_side = 'Z' if self.term_side == 'A' else 'A'
|
||||
try:
|
||||
return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
|
||||
return CircuitTermination.objects.prefetch_related('site').get(
|
||||
circuit=self.circuit,
|
||||
term_side=peer_side
|
||||
)
|
||||
except CircuitTermination.DoesNotExist:
|
||||
return None
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from django.db.models import OuterRef, QuerySet, Subquery
|
||||
from django.db.models import OuterRef, Subquery
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
class CircuitQuerySet(QuerySet):
|
||||
class CircuitQuerySet(RestrictedQuerySet):
|
||||
|
||||
def annotate_sites(self):
|
||||
"""
|
||||
|
||||
@@ -2,19 +2,9 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
CIRCUITTYPE_ACTIONS = """
|
||||
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.circuit.change_circuittype %}
|
||||
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}"
|
||||
class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
@@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable):
|
||||
circuit_count = tables.Column(
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=CIRCUITTYPE_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
actions = ButtonsColumn(CircuitType, pk_field='slug')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CircuitType
|
||||
@@ -76,7 +62,7 @@ class CircuitTable(BaseTable):
|
||||
)
|
||||
provider = tables.LinkColumn(
|
||||
viewname='circuits:provider',
|
||||
args=[Accessor('provider.slug')]
|
||||
args=[Accessor('provider__slug')]
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
@@ -45,6 +46,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Provider.objects.bulk_create(providers)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_provider_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Providers.
|
||||
@@ -58,6 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('circuits.view_provider')
|
||||
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Provider(name='Provider 3', slug='provider-3', asn=65003),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Provider X',
|
||||
'slug': 'provider-x',
|
||||
@@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'noc_contact': 'noc@example.com',
|
||||
'admin_contact': 'admin@example.com',
|
||||
'comments': 'Another provider',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'cid': 'Circuit X',
|
||||
'provider': providers[1].pk,
|
||||
@@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'commit_rate': 1000,
|
||||
'description': 'A new circuit',
|
||||
'comments': 'Some comments',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'commit_rate': 2000,
|
||||
'description': 'New description',
|
||||
'comments': 'New comments',
|
||||
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ urlpatterns = [
|
||||
|
||||
# Providers
|
||||
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
|
||||
path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
|
||||
path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'),
|
||||
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
|
||||
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
|
||||
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
|
||||
@@ -21,15 +21,16 @@ urlpatterns = [
|
||||
|
||||
# Circuit types
|
||||
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
|
||||
path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
|
||||
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
|
||||
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
|
||||
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
|
||||
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
|
||||
path('circuit-types/<slug:slug>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
|
||||
path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
|
||||
|
||||
# Circuits
|
||||
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
|
||||
path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
|
||||
path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'),
|
||||
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
|
||||
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
|
||||
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
|
||||
@@ -37,11 +38,10 @@ urlpatterns = [
|
||||
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
|
||||
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
|
||||
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
|
||||
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
|
||||
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
|
||||
|
||||
# Circuit terminations
|
||||
|
||||
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
|
||||
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import permission_required
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models import Count, Prefetch
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.generic import View
|
||||
from django_tables2 import RequestConfig
|
||||
|
||||
from extras.models import Graph
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
)
|
||||
from . import filters, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
@@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_provider'
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
|
||||
class ProviderListView(ObjectListView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
table = tables.ProviderTable
|
||||
|
||||
|
||||
class ProviderView(PermissionRequiredMixin, View):
|
||||
permission_required = 'circuits.view_provider'
|
||||
class ProviderView(ObjectView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
provider = get_object_or_404(Provider, slug=slug)
|
||||
circuits = Circuit.objects.filter(
|
||||
provider = get_object_or_404(self.queryset, slug=slug)
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(
|
||||
provider=provider
|
||||
).prefetch_related(
|
||||
'type', 'tenant', 'terminations__site'
|
||||
@@ -60,114 +56,98 @@ class ProviderView(PermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.add_provider'
|
||||
model = Provider
|
||||
class ProviderEditView(ObjectEditView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderForm
|
||||
template_name = 'circuits/provider_edit.html'
|
||||
default_return_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
class ProviderEditView(ProviderCreateView):
|
||||
permission_required = 'circuits.change_provider'
|
||||
class ProviderDeleteView(ObjectDeleteView):
|
||||
queryset = Provider.objects.all()
|
||||
|
||||
|
||||
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'circuits.delete_provider'
|
||||
model = Provider
|
||||
default_return_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'circuits.add_provider'
|
||||
class ProviderBulkImportView(BulkImportView):
|
||||
queryset = Provider.objects.all()
|
||||
model_form = forms.ProviderCSVForm
|
||||
table = tables.ProviderTable
|
||||
default_return_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'circuits.change_provider'
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
|
||||
class ProviderBulkEditView(BulkEditView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
form = forms.ProviderBulkEditForm
|
||||
default_return_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_provider'
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
|
||||
class ProviderBulkDeleteView(BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
|
||||
filterset = filters.ProviderFilterSet
|
||||
table = tables.ProviderTable
|
||||
default_return_url = 'circuits:provider_list'
|
||||
|
||||
|
||||
#
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_circuittype'
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
|
||||
class CircuitTypeListView(ObjectListView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
table = tables.CircuitTypeTable
|
||||
|
||||
|
||||
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.add_circuittype'
|
||||
model = CircuitType
|
||||
class CircuitTypeEditView(ObjectEditView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeForm
|
||||
default_return_url = 'circuits:circuittype_list'
|
||||
|
||||
|
||||
class CircuitTypeEditView(CircuitTypeCreateView):
|
||||
permission_required = 'circuits.change_circuittype'
|
||||
class CircuitTypeDeleteView(ObjectDeleteView):
|
||||
queryset = CircuitType.objects.all()
|
||||
|
||||
|
||||
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'circuits.add_circuittype'
|
||||
class CircuitTypeBulkImportView(BulkImportView):
|
||||
queryset = CircuitType.objects.all()
|
||||
model_form = forms.CircuitTypeCSVForm
|
||||
table = tables.CircuitTypeTable
|
||||
default_return_url = 'circuits:circuittype_list'
|
||||
|
||||
|
||||
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuittype'
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
|
||||
class CircuitTypeBulkDeleteView(BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
|
||||
table = tables.CircuitTypeTable
|
||||
default_return_url = 'circuits:circuittype_list'
|
||||
|
||||
|
||||
#
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_circuit'
|
||||
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
|
||||
class CircuitListView(ObjectListView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations__site'
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
).annotate_sites()
|
||||
filterset = filters.CircuitFilterSet
|
||||
filterset_form = forms.CircuitFilterForm
|
||||
table = tables.CircuitTable
|
||||
|
||||
|
||||
class CircuitView(PermissionRequiredMixin, View):
|
||||
permission_required = 'circuits.view_circuit'
|
||||
class CircuitView(ObjectView):
|
||||
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
|
||||
|
||||
def get(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
|
||||
termination_a = CircuitTermination.objects.prefetch_related(
|
||||
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region', 'connected_endpoint__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
termination_z = CircuitTermination.objects.prefetch_related(
|
||||
if termination_a and termination_a.connected_endpoint:
|
||||
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'site__region', 'connected_endpoint__device'
|
||||
).filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
if termination_z and termination_z.connected_endpoint:
|
||||
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
|
||||
|
||||
return render(request, 'circuits/circuit.html', {
|
||||
'circuit': circuit,
|
||||
@@ -176,67 +156,80 @@ class CircuitView(PermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.add_circuit'
|
||||
model = Circuit
|
||||
class CircuitEditView(ObjectEditView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitForm
|
||||
template_name = 'circuits/circuit_edit.html'
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
class CircuitEditView(CircuitCreateView):
|
||||
permission_required = 'circuits.change_circuit'
|
||||
class CircuitDeleteView(ObjectDeleteView):
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
|
||||
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'circuits.delete_circuit'
|
||||
model = Circuit
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'circuits.add_circuit'
|
||||
class CircuitBulkImportView(BulkImportView):
|
||||
queryset = Circuit.objects.all()
|
||||
model_form = forms.CircuitCSVForm
|
||||
table = tables.CircuitTable
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'circuits.change_circuit'
|
||||
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
|
||||
class CircuitBulkEditView(BulkEditView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
filterset = filters.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
form = forms.CircuitBulkEditForm
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'circuits.delete_circuit'
|
||||
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
|
||||
class CircuitBulkDeleteView(BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
'provider', 'type', 'tenant', 'terminations'
|
||||
)
|
||||
filterset = filters.CircuitFilterSet
|
||||
table = tables.CircuitTable
|
||||
default_return_url = 'circuits:circuit_list'
|
||||
|
||||
|
||||
@permission_required('circuits.change_circuittermination')
|
||||
def circuit_terminations_swap(request, pk):
|
||||
class CircuitSwapTerminations(ObjectEditView):
|
||||
"""
|
||||
Swap the A and Z terminations of a circuit.
|
||||
"""
|
||||
queryset = Circuit.objects.all()
|
||||
|
||||
circuit = get_object_or_404(Circuit, pk=pk)
|
||||
termination_a = CircuitTermination.objects.filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
termination_z = CircuitTermination.objects.filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
if not termination_a and not termination_z:
|
||||
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
def get(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
form = ConfirmationForm()
|
||||
|
||||
if request.method == 'POST':
|
||||
# Circuit must have at least one termination to swap
|
||||
if not circuit.termination_a and not circuit.termination_z:
|
||||
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': circuit.termination_a,
|
||||
'termination_z': circuit.termination_z,
|
||||
'form': form,
|
||||
'panel_class': 'default',
|
||||
'button_class': 'primary',
|
||||
'return_url': circuit.get_absolute_url(),
|
||||
})
|
||||
|
||||
def post(self, request, pk):
|
||||
circuit = get_object_or_404(self.queryset, pk=pk)
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
termination_a = CircuitTermination.objects.filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
|
||||
).first()
|
||||
termination_z = CircuitTermination.objects.filter(
|
||||
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
|
||||
).first()
|
||||
|
||||
if termination_a and termination_z:
|
||||
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
|
||||
print('swapping')
|
||||
with transaction.atomic():
|
||||
termination_a.term_side = '_'
|
||||
termination_a.save()
|
||||
@@ -250,30 +243,27 @@ def circuit_terminations_swap(request, pk):
|
||||
else:
|
||||
termination_z.term_side = 'A'
|
||||
termination_z.save()
|
||||
|
||||
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
else:
|
||||
form = ConfirmationForm()
|
||||
|
||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': termination_a,
|
||||
'termination_z': termination_z,
|
||||
'form': form,
|
||||
'panel_class': 'default',
|
||||
'button_class': 'primary',
|
||||
'return_url': circuit.get_absolute_url(),
|
||||
})
|
||||
return render(request, 'circuits/circuit_terminations_swap.html', {
|
||||
'circuit': circuit,
|
||||
'termination_a': circuit.termination_a,
|
||||
'termination_z': circuit.termination_z,
|
||||
'form': form,
|
||||
'panel_class': 'default',
|
||||
'button_class': 'primary',
|
||||
'return_url': circuit.get_absolute_url(),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Circuit terminations
|
||||
#
|
||||
|
||||
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'circuits.add_circuittermination'
|
||||
model = CircuitTermination
|
||||
class CircuitTerminationEditView(ObjectEditView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
model_form = forms.CircuitTerminationForm
|
||||
template_name = 'circuits/circuittermination_edit.html'
|
||||
|
||||
@@ -286,10 +276,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
return obj.circuit.get_absolute_url()
|
||||
|
||||
|
||||
class CircuitTerminationEditView(CircuitTerminationCreateView):
|
||||
permission_required = 'circuits.change_circuittermination'
|
||||
|
||||
|
||||
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'circuits.delete_circuittermination'
|
||||
model = CircuitTermination
|
||||
class CircuitTerminationDeleteView(ObjectDeleteView):
|
||||
queryset = CircuitTermination.objects.all()
|
||||
|
||||
@@ -47,10 +47,11 @@ __all__ = [
|
||||
class NestedRegionSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Region
|
||||
fields = ['id', 'url', 'name', 'slug', 'site_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
|
||||
|
||||
|
||||
class NestedSiteSerializer(WritableNestedSerializer):
|
||||
@@ -68,10 +69,11 @@ class NestedSiteSerializer(WritableNestedSerializer):
|
||||
class NestedRackGroupSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.RackGroup
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth']
|
||||
|
||||
|
||||
class NestedRackRoleSerializer(WritableNestedSerializer):
|
||||
@@ -332,7 +334,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.VirtualChassis
|
||||
fields = ['id', 'url', 'master', 'member_count']
|
||||
fields = ['id', 'name', 'url', 'master', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
@@ -15,6 +14,7 @@ from dcim.models import (
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
|
||||
from ipam.models import VLAN
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
@@ -60,20 +60,22 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
||||
parent = NestedRegionSerializer(required=False, allow_null=True)
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', '_depth']
|
||||
|
||||
|
||||
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
|
||||
status = ChoiceField(choices=SiteStatusChoices, required=False)
|
||||
region = NestedRegionSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneField(required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
@@ -84,7 +86,7 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
|
||||
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
|
||||
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
|
||||
@@ -96,24 +98,28 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class RackGroupSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
|
||||
site = NestedSiteSerializer()
|
||||
parent = NestedRackGroupSerializer(required=False, allow_null=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
_depth = serializers.IntegerField(source='level', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth']
|
||||
|
||||
|
||||
class RackRoleSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count']
|
||||
|
||||
|
||||
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
|
||||
site = NestedSiteSerializer()
|
||||
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
@@ -122,14 +128,13 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
|
||||
width = ChoiceField(choices=RackWidthChoices, required=False)
|
||||
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
|
||||
'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
@@ -162,14 +167,15 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
|
||||
|
||||
class RackReservationSerializer(ValidatedModelSerializer):
|
||||
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
|
||||
rack = NestedRackSerializer()
|
||||
user = NestedUserSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
|
||||
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
@@ -213,6 +219,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
#
|
||||
|
||||
class ManufacturerSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
platform_count = serializers.IntegerField(read_only=True)
|
||||
@@ -220,26 +227,27 @@ class ManufacturerSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
|
||||
]
|
||||
|
||||
|
||||
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
||||
'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'device_count',
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -249,10 +257,11 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePortTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -262,10 +271,11 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPortTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
|
||||
|
||||
|
||||
class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -275,10 +285,11 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -296,43 +307,47 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description']
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = RearPortTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type', 'positions']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description']
|
||||
|
||||
|
||||
class FrontPortTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = NestedRearPortTemplateSerializer()
|
||||
|
||||
class Meta:
|
||||
model = FrontPortTemplate
|
||||
fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
|
||||
|
||||
|
||||
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
|
||||
class Meta:
|
||||
model = DeviceBayTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
fields = ['id', 'url', 'device_type', 'name', 'label', 'description']
|
||||
|
||||
|
||||
#
|
||||
@@ -340,17 +355,19 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class DeviceRoleSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceRole
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
|
||||
'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
class PlatformSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
@@ -358,12 +375,13 @@ class PlatformSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
|
||||
'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
|
||||
'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
device_role = NestedDeviceRoleSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
@@ -378,15 +396,14 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
parent_device = serializers.SerializerMethodField()
|
||||
cluster = NestedClusterSerializer(required=False, allow_null=True)
|
||||
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -419,10 +436,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
|
||||
class Meta(DeviceSerializer.Meta):
|
||||
fields = [
|
||||
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
|
||||
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
|
||||
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
|
||||
'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
|
||||
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
@@ -434,7 +451,8 @@ class DeviceNAPALMSerializer(serializers.Serializer):
|
||||
method = serializers.DictField()
|
||||
|
||||
|
||||
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -442,17 +460,17 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
|
||||
'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -460,17 +478,17 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
|
||||
'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -488,19 +506,17 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
cable = NestedCableSerializer(
|
||||
read_only=True
|
||||
)
|
||||
tags = TagListSerializerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -508,17 +524,17 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
required=False
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
@@ -531,15 +547,14 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
many=True
|
||||
)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
|
||||
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
|
||||
'tagged_vlans', 'tags', 'count_ipaddresses',
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
|
||||
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
|
||||
]
|
||||
|
||||
# TODO: This validation should be handled by Interface.clean()
|
||||
@@ -563,15 +578,15 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
return super().validate(data)
|
||||
|
||||
|
||||
class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags']
|
||||
fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags']
|
||||
|
||||
|
||||
class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
@@ -582,46 +597,49 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['id', 'url', 'name']
|
||||
fields = ['id', 'url', 'name', 'label']
|
||||
|
||||
|
||||
class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=PortTypeChoices)
|
||||
rear_port = FrontPortRearPortSerializer()
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
|
||||
fields = [
|
||||
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
|
||||
'tags',
|
||||
]
|
||||
|
||||
|
||||
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
|
||||
fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
|
||||
|
||||
|
||||
#
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
|
||||
device = NestedDeviceSerializer()
|
||||
# Provide a default value to satisfy UniqueTogetherValidator
|
||||
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = [
|
||||
'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'id', 'url', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
|
||||
'description', 'tags',
|
||||
]
|
||||
|
||||
@@ -630,7 +648,8 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableSerializer(ValidatedModelSerializer):
|
||||
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
)
|
||||
@@ -645,8 +664,8 @@ class CableSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id',
|
||||
'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit',
|
||||
'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
|
||||
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
|
||||
]
|
||||
|
||||
def _get_termination(self, obj, side):
|
||||
@@ -709,21 +728,22 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
master = NestedDeviceSerializer()
|
||||
tags = TagListSerializerField(required=False)
|
||||
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer(required=False)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain', 'tags', 'member_count']
|
||||
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelSerializer(ValidatedModelSerializer):
|
||||
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
site = NestedSiteSerializer()
|
||||
rack_group = NestedRackGroupSerializer(
|
||||
required=False,
|
||||
@@ -734,10 +754,11 @@ class PowerPanelSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
|
||||
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
|
||||
|
||||
|
||||
class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
required=False,
|
||||
@@ -760,13 +781,10 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
choices=PowerFeedPhaseChoices,
|
||||
default=PowerFeedPhaseChoices.PHASE_SINGLE
|
||||
)
|
||||
tags = TagListSerializerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -45,7 +45,7 @@ class CableTraceMixin(object):
|
||||
"""
|
||||
Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination).
|
||||
"""
|
||||
obj = get_object_or_404(self.queryset.model, pk=pk)
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
# Initialize the path array
|
||||
path = []
|
||||
@@ -75,8 +75,12 @@ class CableTraceMixin(object):
|
||||
#
|
||||
|
||||
class RegionViewSet(ModelViewSet):
|
||||
queryset = Region.objects.annotate(
|
||||
site_count=Count('sites')
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
'region',
|
||||
'site_count',
|
||||
cumulative=True
|
||||
)
|
||||
serializer_class = serializers.RegionSerializer
|
||||
filterset_class = filters.RegionFilterSet
|
||||
@@ -96,7 +100,7 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
vlan_count=get_subquery(VLAN, 'site'),
|
||||
circuit_count=get_subquery(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
|
||||
)
|
||||
).order_by(*Site._meta.ordering)
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilterSet
|
||||
|
||||
@@ -105,8 +109,8 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular site.
|
||||
"""
|
||||
site = get_object_or_404(Site, pk=pk)
|
||||
queryset = Graph.objects.filter(type__model='site')
|
||||
site = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -116,9 +120,13 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class RackGroupViewSet(ModelViewSet):
|
||||
queryset = RackGroup.objects.prefetch_related('site').annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
queryset = RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
Rack,
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).prefetch_related('site')
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
filterset_class = filters.RackGroupFilterSet
|
||||
|
||||
@@ -130,7 +138,7 @@ class RackGroupViewSet(ModelViewSet):
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
).order_by(*RackRole._meta.ordering)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilterSet
|
||||
|
||||
@@ -145,7 +153,7 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack'),
|
||||
powerfeed_count=get_subquery(PowerFeed, 'rack')
|
||||
)
|
||||
).order_by(*Rack._meta.ordering)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilterSet
|
||||
|
||||
@@ -158,7 +166,7 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG.
|
||||
"""
|
||||
rack = get_object_or_404(Rack, pk=pk)
|
||||
rack = get_object_or_404(self.queryset, pk=pk)
|
||||
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
|
||||
if not serializer.is_valid():
|
||||
return Response(serializer.errors, 400)
|
||||
@@ -218,7 +226,7 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
)
|
||||
).order_by(*Manufacturer._meta.ordering)
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filters.ManufacturerFilterSet
|
||||
|
||||
@@ -228,9 +236,9 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
|
||||
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
|
||||
device_count=Count('instances')
|
||||
)
|
||||
).order_by(*DeviceType._meta.ordering)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilterSet
|
||||
|
||||
@@ -295,7 +303,7 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'role')
|
||||
)
|
||||
).order_by(*DeviceRole._meta.ordering)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilterSet
|
||||
|
||||
@@ -308,7 +316,7 @@ class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
|
||||
)
|
||||
).order_by(*Platform._meta.ordering)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filters.PlatformFilterSet
|
||||
|
||||
@@ -349,8 +357,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular Device.
|
||||
"""
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
queryset = Graph.objects.filter(type__model='device')
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
|
||||
|
||||
return Response(serializer.data)
|
||||
@@ -371,7 +379,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
"""
|
||||
Execute a NAPALM method on a Device
|
||||
"""
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
device = get_object_or_404(self.queryset, pk=pk)
|
||||
if not device.primary_ip:
|
||||
raise ServiceUnavailable("This device does not have a primary IP address configured.")
|
||||
if device.platform is None:
|
||||
raise ServiceUnavailable("No platform is configured for this device.")
|
||||
if not device.platform.napalm_driver:
|
||||
@@ -411,7 +421,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
))
|
||||
|
||||
# Verify user permission
|
||||
if not request.user.has_perm('dcim.napalm_read'):
|
||||
if not request.user.has_perm('dcim.napalm_read_device'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
napalm_methods = request.GET.getlist('method')
|
||||
@@ -511,8 +521,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
|
||||
"""
|
||||
A convenience method for rendering graphs for a particular interface.
|
||||
"""
|
||||
interface = get_object_or_404(Interface, pk=pk)
|
||||
queryset = Graph.objects.filter(type__model='interface')
|
||||
interface = get_object_or_404(self.queryset, pk=pk)
|
||||
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
|
||||
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -597,7 +607,7 @@ class CableViewSet(ModelViewSet):
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=Count('members')
|
||||
)
|
||||
).order_by(*VirtualChassis._meta.ordering)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
filterset_class = filters.VirtualChassisFilterSet
|
||||
|
||||
@@ -611,7 +621,7 @@ class PowerPanelViewSet(ModelViewSet):
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
)
|
||||
).order_by(*PowerPanel._meta.ordering)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilterSet
|
||||
|
||||
@@ -671,7 +681,11 @@ class ConnectedDeviceViewSet(ViewSet):
|
||||
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
|
||||
|
||||
# Determine local interface from peer interface's connection
|
||||
peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
|
||||
peer_interface = get_object_or_404(
|
||||
Interface.objects.all(),
|
||||
device__name=peer_device_name,
|
||||
name=peer_interface_name
|
||||
)
|
||||
local_interface = peer_interface._connected_interface
|
||||
|
||||
if local_interface is None:
|
||||
|
||||
@@ -21,12 +21,6 @@ class SiteStatusChoices(ChoiceSet):
|
||||
(STATUS_RETIRED, 'Retired'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_PLANNED: 2,
|
||||
STATUS_RETIRED: 4,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
@@ -48,14 +42,6 @@ class RackTypeChoices(ChoiceSet):
|
||||
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_2POST: 100,
|
||||
TYPE_4POST: 200,
|
||||
TYPE_CABINET: 300,
|
||||
TYPE_WALLFRAME: 1000,
|
||||
TYPE_WALLCABINET: 1100,
|
||||
}
|
||||
|
||||
|
||||
class RackWidthChoices(ChoiceSet):
|
||||
|
||||
@@ -88,14 +74,6 @@ class RackStatusChoices(ChoiceSet):
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_RESERVED: 0,
|
||||
STATUS_AVAILABLE: 1,
|
||||
STATUS_PLANNED: 2,
|
||||
STATUS_ACTIVE: 3,
|
||||
STATUS_DEPRECATED: 4,
|
||||
}
|
||||
|
||||
|
||||
class RackDimensionUnitChoices(ChoiceSet):
|
||||
|
||||
@@ -107,11 +85,6 @@ class RackDimensionUnitChoices(ChoiceSet):
|
||||
(UNIT_INCH, 'Inches'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
UNIT_MILLIMETER: 1000,
|
||||
UNIT_INCH: 2000,
|
||||
}
|
||||
|
||||
|
||||
class RackElevationDetailRenderChoices(ChoiceSet):
|
||||
|
||||
@@ -138,11 +111,6 @@ class SubdeviceRoleChoices(ChoiceSet):
|
||||
(ROLE_CHILD, 'Child'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
ROLE_PARENT: True,
|
||||
ROLE_CHILD: False,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Devices
|
||||
@@ -158,11 +126,6 @@ class DeviceFaceChoices(ChoiceSet):
|
||||
(FACE_REAR, 'Rear'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
FACE_FRONT: 0,
|
||||
FACE_REAR: 1,
|
||||
}
|
||||
|
||||
|
||||
class DeviceStatusChoices(ChoiceSet):
|
||||
|
||||
@@ -184,16 +147,6 @@ class DeviceStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_OFFLINE: 0,
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_PLANNED: 2,
|
||||
STATUS_STAGED: 3,
|
||||
STATUS_FAILED: 4,
|
||||
STATUS_INVENTORY: 5,
|
||||
STATUS_DECOMMISSIONING: 6,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ConsolePorts
|
||||
@@ -611,12 +564,6 @@ class PowerOutletFeedLegChoices(ChoiceSet):
|
||||
(FEED_LEG_C, 'C'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
FEED_LEG_A: 1,
|
||||
FEED_LEG_B: 2,
|
||||
FEED_LEG_C: 3,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
@@ -846,80 +793,6 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_VIRTUAL: 0,
|
||||
TYPE_LAG: 200,
|
||||
TYPE_100ME_FIXED: 800,
|
||||
TYPE_1GE_FIXED: 1000,
|
||||
TYPE_1GE_GBIC: 1050,
|
||||
TYPE_1GE_SFP: 1100,
|
||||
TYPE_2GE_FIXED: 1120,
|
||||
TYPE_5GE_FIXED: 1130,
|
||||
TYPE_10GE_FIXED: 1150,
|
||||
TYPE_10GE_CX4: 1170,
|
||||
TYPE_10GE_SFP_PLUS: 1200,
|
||||
TYPE_10GE_XFP: 1300,
|
||||
TYPE_10GE_XENPAK: 1310,
|
||||
TYPE_10GE_X2: 1320,
|
||||
TYPE_25GE_SFP28: 1350,
|
||||
TYPE_40GE_QSFP_PLUS: 1400,
|
||||
TYPE_50GE_QSFP28: 1420,
|
||||
TYPE_100GE_CFP: 1500,
|
||||
TYPE_100GE_CFP2: 1510,
|
||||
TYPE_100GE_CFP4: 1520,
|
||||
TYPE_100GE_CPAK: 1550,
|
||||
TYPE_100GE_QSFP28: 1600,
|
||||
TYPE_200GE_CFP2: 1650,
|
||||
TYPE_200GE_QSFP56: 1700,
|
||||
TYPE_400GE_QSFP_DD: 1750,
|
||||
TYPE_400GE_OSFP: 1800,
|
||||
TYPE_80211A: 2600,
|
||||
TYPE_80211G: 2610,
|
||||
TYPE_80211N: 2620,
|
||||
TYPE_80211AC: 2630,
|
||||
TYPE_80211AD: 2640,
|
||||
TYPE_GSM: 2810,
|
||||
TYPE_CDMA: 2820,
|
||||
TYPE_LTE: 2830,
|
||||
TYPE_SONET_OC3: 6100,
|
||||
TYPE_SONET_OC12: 6200,
|
||||
TYPE_SONET_OC48: 6300,
|
||||
TYPE_SONET_OC192: 6400,
|
||||
TYPE_SONET_OC768: 6500,
|
||||
TYPE_SONET_OC1920: 6600,
|
||||
TYPE_SONET_OC3840: 6700,
|
||||
TYPE_1GFC_SFP: 3010,
|
||||
TYPE_2GFC_SFP: 3020,
|
||||
TYPE_4GFC_SFP: 3040,
|
||||
TYPE_8GFC_SFP_PLUS: 3080,
|
||||
TYPE_16GFC_SFP_PLUS: 3160,
|
||||
TYPE_32GFC_SFP28: 3320,
|
||||
TYPE_128GFC_QSFP28: 3400,
|
||||
TYPE_INFINIBAND_SDR: 7010,
|
||||
TYPE_INFINIBAND_DDR: 7020,
|
||||
TYPE_INFINIBAND_QDR: 7030,
|
||||
TYPE_INFINIBAND_FDR10: 7040,
|
||||
TYPE_INFINIBAND_FDR: 7050,
|
||||
TYPE_INFINIBAND_EDR: 7060,
|
||||
TYPE_INFINIBAND_HDR: 7070,
|
||||
TYPE_INFINIBAND_NDR: 7080,
|
||||
TYPE_INFINIBAND_XDR: 7090,
|
||||
TYPE_T1: 4000,
|
||||
TYPE_E1: 4010,
|
||||
TYPE_T3: 4040,
|
||||
TYPE_E3: 4050,
|
||||
TYPE_STACKWISE: 5000,
|
||||
TYPE_STACKWISE_PLUS: 5050,
|
||||
TYPE_FLEXSTACK: 5100,
|
||||
TYPE_FLEXSTACK_PLUS: 5150,
|
||||
TYPE_JUNIPER_VCP: 5200,
|
||||
TYPE_SUMMITSTACK: 5300,
|
||||
TYPE_SUMMITSTACK128: 5310,
|
||||
TYPE_SUMMITSTACK256: 5320,
|
||||
TYPE_SUMMITSTACK512: 5330,
|
||||
TYPE_OTHER: 32767,
|
||||
}
|
||||
|
||||
|
||||
class InterfaceModeChoices(ChoiceSet):
|
||||
|
||||
@@ -933,12 +806,6 @@ class InterfaceModeChoices(ChoiceSet):
|
||||
(MODE_TAGGED_ALL, 'Tagged (All)'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
MODE_ACCESS: 100,
|
||||
MODE_TAGGED: 200,
|
||||
MODE_TAGGED_ALL: 300,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# FrontPorts/RearPorts
|
||||
@@ -988,22 +855,6 @@ class PortTypeChoices(ChoiceSet):
|
||||
)
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_8P8C: 1000,
|
||||
TYPE_110_PUNCH: 1100,
|
||||
TYPE_BNC: 1200,
|
||||
TYPE_ST: 2000,
|
||||
TYPE_SC: 2100,
|
||||
TYPE_SC_APC: 2110,
|
||||
TYPE_FC: 2200,
|
||||
TYPE_LC: 2300,
|
||||
TYPE_LC_APC: 2310,
|
||||
TYPE_MTRJ: 2400,
|
||||
TYPE_MPO: 2500,
|
||||
TYPE_LSH: 2600,
|
||||
TYPE_LSH_APC: 2610,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Cables
|
||||
@@ -1063,28 +914,6 @@ class CableTypeChoices(ChoiceSet):
|
||||
(TYPE_POWER, 'Power'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_CAT3: 1300,
|
||||
TYPE_CAT5: 1500,
|
||||
TYPE_CAT5E: 1510,
|
||||
TYPE_CAT6: 1600,
|
||||
TYPE_CAT6A: 1610,
|
||||
TYPE_CAT7: 1700,
|
||||
TYPE_DAC_ACTIVE: 1800,
|
||||
TYPE_DAC_PASSIVE: 1810,
|
||||
TYPE_COAXIAL: 1900,
|
||||
TYPE_MMF: 3000,
|
||||
TYPE_MMF_OM1: 3010,
|
||||
TYPE_MMF_OM2: 3020,
|
||||
TYPE_MMF_OM3: 3030,
|
||||
TYPE_MMF_OM4: 3040,
|
||||
TYPE_SMF: 3500,
|
||||
TYPE_SMF_OS1: 3510,
|
||||
TYPE_SMF_OS2: 3520,
|
||||
TYPE_AOC: 3800,
|
||||
TYPE_POWER: 5000,
|
||||
}
|
||||
|
||||
|
||||
class CableStatusChoices(ChoiceSet):
|
||||
|
||||
@@ -1098,11 +927,6 @@ class CableStatusChoices(ChoiceSet):
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_CONNECTED: True,
|
||||
STATUS_PLANNED: False,
|
||||
}
|
||||
|
||||
|
||||
class CableLengthUnitChoices(ChoiceSet):
|
||||
|
||||
@@ -1118,13 +942,6 @@ class CableLengthUnitChoices(ChoiceSet):
|
||||
(UNIT_INCH, 'Inches'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
UNIT_METER: 1200,
|
||||
UNIT_CENTIMETER: 1100,
|
||||
UNIT_FOOT: 2100,
|
||||
UNIT_INCH: 2000,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# PowerFeeds
|
||||
@@ -1144,13 +961,6 @@ class PowerFeedStatusChoices(ChoiceSet):
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_OFFLINE: 0,
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_PLANNED: 2,
|
||||
STATUS_FAILED: 4,
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedTypeChoices(ChoiceSet):
|
||||
|
||||
@@ -1162,11 +972,6 @@ class PowerFeedTypeChoices(ChoiceSet):
|
||||
(TYPE_REDUNDANT, 'Redundant'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_PRIMARY: 1,
|
||||
TYPE_REDUNDANT: 2,
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedSupplyChoices(ChoiceSet):
|
||||
|
||||
@@ -1178,11 +983,6 @@ class PowerFeedSupplyChoices(ChoiceSet):
|
||||
(SUPPLY_DC, 'DC'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
SUPPLY_AC: 1,
|
||||
SUPPLY_DC: 2,
|
||||
}
|
||||
|
||||
|
||||
class PowerFeedPhaseChoices(ChoiceSet):
|
||||
|
||||
@@ -1193,8 +993,3 @@ class PowerFeedPhaseChoices(ChoiceSet):
|
||||
(PHASE_SINGLE, 'Single phase'),
|
||||
(PHASE_3PHASE, 'Three-phase'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
PHASE_SINGLE: 1,
|
||||
PHASE_3PHASE: 3,
|
||||
}
|
||||
|
||||
@@ -298,6 +298,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
|
||||
to_field_name='username',
|
||||
label='User (name)',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
@@ -383,28 +384,28 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
|
||||
)
|
||||
|
||||
def _console_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleport_templates__isnull=value)
|
||||
return queryset.exclude(consoleporttemplates__isnull=value)
|
||||
|
||||
def _console_server_ports(self, queryset, name, value):
|
||||
return queryset.exclude(consoleserverport_templates__isnull=value)
|
||||
return queryset.exclude(consoleserverporttemplates__isnull=value)
|
||||
|
||||
def _power_ports(self, queryset, name, value):
|
||||
return queryset.exclude(powerport_templates__isnull=value)
|
||||
return queryset.exclude(powerporttemplates__isnull=value)
|
||||
|
||||
def _power_outlets(self, queryset, name, value):
|
||||
return queryset.exclude(poweroutlet_templates__isnull=value)
|
||||
return queryset.exclude(poweroutlettemplates__isnull=value)
|
||||
|
||||
def _interfaces(self, queryset, name, value):
|
||||
return queryset.exclude(interface_templates__isnull=value)
|
||||
return queryset.exclude(interfacetemplates__isnull=value)
|
||||
|
||||
def _pass_through_ports(self, queryset, name, value):
|
||||
return queryset.exclude(
|
||||
frontport_templates__isnull=value,
|
||||
rearport_templates__isnull=value
|
||||
frontporttemplates__isnull=value,
|
||||
rearporttemplates__isnull=value
|
||||
)
|
||||
|
||||
def _device_bays(self, queryset, name, value):
|
||||
return queryset.exclude(device_bay_templates__isnull=value)
|
||||
return queryset.exclude(devicebaytemplates__isnull=value)
|
||||
|
||||
|
||||
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
|
||||
@@ -655,7 +656,7 @@ class DeviceFilterSet(
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventory_items__serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
@@ -697,7 +698,7 @@ class DeviceFilterSet(
|
||||
)
|
||||
|
||||
def _device_bays(self, queryset, name, value):
|
||||
return queryset.exclude(device_bays__isnull=value)
|
||||
return queryset.exclude(devicebays__isnull=value)
|
||||
|
||||
|
||||
class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
@@ -746,6 +747,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(label__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
@@ -1066,7 +1068,8 @@ class VirtualChassisFilterSet(BaseFilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(master__name__icontains=value) |
|
||||
Q(name__icontains=value) |
|
||||
Q(members__name__icontains=value) |
|
||||
Q(domain__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
@@ -1117,6 +1120,7 @@ class CableFilterSet(BaseFilterSet):
|
||||
method='filter_device',
|
||||
field_name='device__tenant__slug'
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
@@ -1265,6 +1269,7 @@ class PowerPanelFilterSet(BaseFilterSet):
|
||||
lookup_expr='in',
|
||||
label='Rack group (ID)',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
|
||||
options={'ordering': ['name']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='platform',
|
||||
|
||||
@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
|
||||
options={'ordering': ('name', 'pk')},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='rack',
|
||||
|
||||
@@ -79,42 +79,42 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=naturalize_consoleports,
|
||||
|
||||
@@ -75,37 +75,37 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='consoleporttemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverporttemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebaytemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontporttemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerporttemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearporttemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=naturalize_consoleporttemplates,
|
||||
|
||||
@@ -30,7 +30,7 @@ class Migration(migrations.Migration):
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='device',
|
||||
options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
|
||||
options={'ordering': ('_name', 'pk')},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='rack',
|
||||
@@ -43,17 +43,17 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='device',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rack',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='site',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=naturalize_sites,
|
||||
|
||||
@@ -35,12 +35,12 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='_name',
|
||||
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
|
||||
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=naturalize_interfacetemplates,
|
||||
|
||||
96
netbox/dcim/migrations/0107_component_labels.py
Normal file
96
netbox/dcim/migrations/0107_component_labels.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0106_role_default_color'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleporttemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverporttemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebaytemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontport',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontporttemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interface',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='inventoryitem',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerporttemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearport',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearporttemplate',
|
||||
name='label',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
]
|
||||
30
netbox/dcim/migrations/0108_add_tags.py
Normal file
30
netbox/dcim/migrations/0108_add_tags.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-10 18:32
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0042_customfield_manager'),
|
||||
('dcim', '0107_component_labels'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='cable',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerpanel',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rackreservation',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
24
netbox/dcim/migrations/0109_interface_remove_vm.py
Normal file
24
netbox/dcim/migrations/0109_interface_remove_vm.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0108_add_tags'),
|
||||
('virtualization', '0016_replicate_interfaces'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='interface',
|
||||
name='virtual_machine',
|
||||
),
|
||||
# device is now a required field
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='device',
|
||||
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
46
netbox/dcim/migrations/0110_virtualchassis_name.py
Normal file
46
netbox/dcim/migrations/0110_virtualchassis_name.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def copy_master_name(apps, schema_editor):
|
||||
"""
|
||||
Copy the master device's name to the VirtualChassis.
|
||||
"""
|
||||
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
|
||||
|
||||
for vc in VirtualChassis.objects.prefetch_related('master'):
|
||||
name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}'
|
||||
VirtualChassis.objects.filter(pk=vc.pk).update(name=name)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0109_interface_remove_vm'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='virtualchassis',
|
||||
options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='virtualchassis',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualchassis',
|
||||
name='master',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=copy_master_name,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualchassis',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
# Generated by Django 3.0.6 on 2020-06-30 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0110_virtualchassis_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebaytemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='frontporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='interfacetemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='rearporttemplate',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
]
|
||||
120
netbox/dcim/migrations/0112_standardize_components.py
Normal file
120
netbox/dcim/migrations/0112_standardize_components.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0111_component_template_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Set max_length=64 for all name fields
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleporttemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverport',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverporttemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebay',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebaytemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='poweroutlet',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='name',
|
||||
field=models.CharField(max_length=64),
|
||||
),
|
||||
|
||||
# Update related_name for necessary component and component template models
|
||||
migrations.AlterField(
|
||||
model_name='consoleporttemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverporttemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebay',
|
||||
name='device',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.Device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebaytemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='frontporttemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interfacetemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='device',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.Device'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerporttemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearporttemplate',
|
||||
name='device_type',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.DeviceType'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 3.1b1 on 2020-07-16 15:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0112_standardize_components'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverport',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerfeed',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='poweroutlet',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='connection_status',
|
||||
field=models.BooleanField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
23
netbox/dcim/migrations/0114_update_jsonfield.py
Normal file
23
netbox/dcim/migrations/0114_update_jsonfield.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.1b1 on 2020-07-16 16:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0113_nullbooleanfield_to_booleanfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='local_context_data',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='platform',
|
||||
name='napalm_args',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import ArrayField, JSONField
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@@ -21,11 +21,12 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import ASNField
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_component_templates import (
|
||||
@@ -33,11 +34,12 @@ from .device_component_templates import (
|
||||
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
)
|
||||
from .device_components import (
|
||||
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
|
||||
PowerPort, RearPort,
|
||||
BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
|
||||
PowerOutlet, PowerPort, RearPort,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
'BaseInterface',
|
||||
'Cable',
|
||||
'CableTermination',
|
||||
'ConsolePort',
|
||||
@@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'parent', 'description']
|
||||
|
||||
class MPTTMeta:
|
||||
@@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
|
||||
@@ -328,6 +334,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -390,6 +398,8 @@ class RackRole(ChangeLoggedModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'color', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -528,6 +538,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
|
||||
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
|
||||
@@ -569,7 +581,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
if self.pk:
|
||||
# Validate that Rack is tall enough to house the installed Devices
|
||||
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
|
||||
top_device = Device.objects.filter(
|
||||
rack=self
|
||||
).exclude(
|
||||
position__isnull=True
|
||||
).order_by('-position').first()
|
||||
if top_device:
|
||||
min_height = top_device.position + top_device.device_type.u_height - 1
|
||||
if self.u_height < min_height:
|
||||
@@ -663,7 +679,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
'device_type__manufacturer',
|
||||
'device_role'
|
||||
).annotate(
|
||||
devicebay_count=Count('device_bays')
|
||||
devicebay_count=Count('devicebays')
|
||||
).exclude(
|
||||
pk=exclude
|
||||
).filter(
|
||||
@@ -685,7 +701,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
return [u for u in elevation.values()]
|
||||
|
||||
def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
|
||||
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
|
||||
"""
|
||||
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
|
||||
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
|
||||
@@ -695,9 +711,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
|
||||
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
|
||||
"""
|
||||
|
||||
# Gather all devices which consume U space within the rack
|
||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
|
||||
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
|
||||
if exclude is not None:
|
||||
devices = devices.exclude(pk__in=exclude)
|
||||
|
||||
# Initialize the rack unit skeleton
|
||||
units = list(range(1, self.u_height + 1))
|
||||
@@ -822,6 +839,9 @@ class RackReservation(ChangeLoggedModel):
|
||||
description = models.CharField(
|
||||
max_length=200
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
|
||||
|
||||
@@ -902,6 +922,8 @@ class Manufacturer(ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -984,9 +1006,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
clone_fields = [
|
||||
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
]
|
||||
@@ -1027,23 +1050,23 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
))
|
||||
|
||||
# Component templates
|
||||
if self.consoleport_templates.exists():
|
||||
if self.consoleporttemplates.exists():
|
||||
data['console-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
}
|
||||
for c in self.consoleport_templates.all()
|
||||
for c in self.consoleporttemplates.all()
|
||||
]
|
||||
if self.consoleserverport_templates.exists():
|
||||
if self.consoleserverporttemplates.exists():
|
||||
data['console-server-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
}
|
||||
for c in self.consoleserverport_templates.all()
|
||||
for c in self.consoleserverporttemplates.all()
|
||||
]
|
||||
if self.powerport_templates.exists():
|
||||
if self.powerporttemplates.exists():
|
||||
data['power-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
@@ -1051,9 +1074,9 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
'maximum_draw': c.maximum_draw,
|
||||
'allocated_draw': c.allocated_draw,
|
||||
}
|
||||
for c in self.powerport_templates.all()
|
||||
for c in self.powerporttemplates.all()
|
||||
]
|
||||
if self.poweroutlet_templates.exists():
|
||||
if self.poweroutlettemplates.exists():
|
||||
data['power-outlets'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
@@ -1061,18 +1084,18 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
'power_port': c.power_port.name if c.power_port else None,
|
||||
'feed_leg': c.feed_leg,
|
||||
}
|
||||
for c in self.poweroutlet_templates.all()
|
||||
for c in self.poweroutlettemplates.all()
|
||||
]
|
||||
if self.interface_templates.exists():
|
||||
if self.interfacetemplates.exists():
|
||||
data['interfaces'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'mgmt_only': c.mgmt_only,
|
||||
}
|
||||
for c in self.interface_templates.all()
|
||||
for c in self.interfacetemplates.all()
|
||||
]
|
||||
if self.frontport_templates.exists():
|
||||
if self.frontporttemplates.exists():
|
||||
data['front-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
@@ -1080,23 +1103,23 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
'rear_port': c.rear_port.name,
|
||||
'rear_port_position': c.rear_port_position,
|
||||
}
|
||||
for c in self.frontport_templates.all()
|
||||
for c in self.frontporttemplates.all()
|
||||
]
|
||||
if self.rearport_templates.exists():
|
||||
if self.rearporttemplates.exists():
|
||||
data['rear-ports'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
'type': c.type,
|
||||
'positions': c.positions,
|
||||
}
|
||||
for c in self.rearport_templates.all()
|
||||
for c in self.rearporttemplates.all()
|
||||
]
|
||||
if self.device_bay_templates.exists():
|
||||
if self.devicebaytemplates.exists():
|
||||
data['device-bays'] = [
|
||||
{
|
||||
'name': c.name,
|
||||
}
|
||||
for c in self.device_bay_templates.all()
|
||||
for c in self.devicebaytemplates.all()
|
||||
]
|
||||
|
||||
return yaml.dump(dict(data), sort_keys=False)
|
||||
@@ -1122,7 +1145,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
|
||||
elif self.pk and self._original_u_height > 0 and self.u_height == 0:
|
||||
racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count()
|
||||
racked_instance_count = Device.objects.filter(
|
||||
device_type=self,
|
||||
position__isnull=False
|
||||
).count()
|
||||
if racked_instance_count:
|
||||
url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
|
||||
raise ValidationError({
|
||||
@@ -1134,7 +1160,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
if (
|
||||
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
|
||||
) and self.device_bay_templates.count():
|
||||
) and self.devicebaytemplates.count():
|
||||
raise ValidationError({
|
||||
'subdevice_role': "Must delete all device bay templates associated with this device before "
|
||||
"declassifying it as a parent device."
|
||||
@@ -1208,6 +1234,8 @@ class DeviceRole(ChangeLoggedModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -1254,7 +1282,7 @@ class Platform(ChangeLoggedModel):
|
||||
verbose_name='NAPALM driver',
|
||||
help_text='The name of the NAPALM driver to use when interacting with devices'
|
||||
)
|
||||
napalm_args = JSONField(
|
||||
napalm_args = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name='NAPALM arguments',
|
||||
@@ -1265,6 +1293,8 @@ class Platform(ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
|
||||
|
||||
class Meta:
|
||||
@@ -1431,6 +1461,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
|
||||
@@ -1456,10 +1488,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
('rack', 'position', 'face'),
|
||||
('virtual_chassis', 'vc_position'),
|
||||
)
|
||||
permissions = (
|
||||
('napalm_read', 'Read-only access to devices via NAPALM'),
|
||||
('napalm_write', 'Read/write access to devices via NAPALM'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.display_name or super().__str__()
|
||||
@@ -1473,7 +1501,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
|
||||
# of the uniqueness constraint without manual intervention.
|
||||
if self.name and self.tenant is None:
|
||||
if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True):
|
||||
if Device.objects.exclude(pk=self.pk).filter(
|
||||
name=self.name,
|
||||
site=self.site,
|
||||
tenant__isnull=True
|
||||
):
|
||||
raise ValidationError({
|
||||
'name': 'A device with this name already exists.'
|
||||
})
|
||||
@@ -1552,9 +1584,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
raise ValidationError({
|
||||
'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
|
||||
})
|
||||
if self.primary_ip4.interface in vc_interfaces:
|
||||
if self.primary_ip4.assigned_object in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
|
||||
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
@@ -1565,9 +1597,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
raise ValidationError({
|
||||
'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
|
||||
})
|
||||
if self.primary_ip6.interface in vc_interfaces:
|
||||
if self.primary_ip6.assigned_object in vc_interfaces:
|
||||
pass
|
||||
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
|
||||
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({
|
||||
@@ -1603,28 +1635,28 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
# If this is a new Device, instantiate all of the related components per the DeviceType definition
|
||||
if is_new:
|
||||
ConsolePort.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
|
||||
)
|
||||
ConsoleServerPort.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
|
||||
)
|
||||
PowerPort.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.powerport_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
|
||||
)
|
||||
PowerOutlet.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
|
||||
)
|
||||
Interface.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.interface_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
|
||||
)
|
||||
RearPort.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.rearport_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
|
||||
)
|
||||
FrontPort.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.frontport_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
|
||||
)
|
||||
DeviceBay.objects.bulk_create(
|
||||
[x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
|
||||
[x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
|
||||
)
|
||||
|
||||
# Update Site and Rack assignment for any child Devices
|
||||
@@ -1737,23 +1769,29 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
master = models.OneToOneField(
|
||||
to='Device',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='vc_master_for'
|
||||
related_name='vc_master_for',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
domain = models.CharField(
|
||||
max_length=30,
|
||||
blank=True
|
||||
)
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['master', 'domain']
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'domain', 'master']
|
||||
|
||||
class Meta:
|
||||
ordering = ['master']
|
||||
ordering = ['name']
|
||||
verbose_name_plural = 'virtual chassis'
|
||||
|
||||
def __str__(self):
|
||||
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
|
||||
@@ -1762,9 +1800,9 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
|
||||
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
|
||||
# VirtualChassis.)
|
||||
if self.pk and self.master not in self.members.all():
|
||||
if self.pk and self.master and self.master not in self.members.all():
|
||||
raise ValidationError({
|
||||
'master': "The selected master is not assigned to this virtual chassis."
|
||||
'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
|
||||
})
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
@@ -1778,8 +1816,7 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
)
|
||||
if interfaces:
|
||||
raise ProtectedError(
|
||||
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
|
||||
"LAG".format(self),
|
||||
f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
|
||||
interfaces
|
||||
)
|
||||
|
||||
@@ -1787,8 +1824,9 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.master,
|
||||
self.name,
|
||||
self.domain,
|
||||
self.master.name if self.master else None,
|
||||
)
|
||||
|
||||
|
||||
@@ -1814,6 +1852,9 @@ class PowerPanel(ChangeLoggedModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['site', 'rack_group', 'name']
|
||||
|
||||
@@ -1866,9 +1907,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
@@ -1918,9 +1960,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
|
||||
'amperage', 'max_utilization', 'comments',
|
||||
@@ -2085,6 +2128,9 @@ class Cable(ChangeLoggedModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
|
||||
|
||||
@@ -6,6 +6,7 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ObjectChange
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.utils import serialize_object
|
||||
from .device_components import (
|
||||
@@ -26,10 +27,39 @@ __all__ = (
|
||||
|
||||
|
||||
class ComponentTemplateModel(models.Model):
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='%(class)ss'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
help_text="Physical label"
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
if self.label:
|
||||
return f"{self.name} ({self.label})"
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
"""
|
||||
Instantiate a new component on the specified Device.
|
||||
@@ -56,19 +86,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a ConsolePort to be created for a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='consoleport_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -79,9 +96,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return ConsolePort(
|
||||
device=device,
|
||||
@@ -94,19 +108,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a ConsoleServerPort to be created for a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='consoleserverport_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -117,9 +118,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return ConsoleServerPort(
|
||||
device=device,
|
||||
@@ -132,19 +130,6 @@ class PowerPortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a PowerPort to be created for a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='powerport_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -167,9 +152,6 @@ class PowerPortTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return PowerPort(
|
||||
device=device,
|
||||
@@ -184,19 +166,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a PowerOutlet to be created for a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='poweroutlet_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -220,9 +189,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate power port assignment
|
||||
@@ -249,14 +215,7 @@ class InterfaceTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a physical data interface on a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='interface_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
# Override ComponentTemplateModel._name to specify naturalize_interface function
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
naturalize_function=naturalize_interface,
|
||||
@@ -276,9 +235,6 @@ class InterfaceTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return Interface(
|
||||
device=device,
|
||||
@@ -292,19 +248,6 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
Template for a pass-through port on the front of a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='frontport_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PortTypeChoices
|
||||
@@ -326,9 +269,6 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
('rear_port', 'rear_port_position'),
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate rear port assignment
|
||||
@@ -363,19 +303,6 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
Template for a pass-through port on the rear of a new Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rearport_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PortTypeChoices
|
||||
@@ -389,9 +316,6 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return RearPort(
|
||||
device=device,
|
||||
@@ -405,27 +329,10 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a DeviceBay to be created for a new parent Device.
|
||||
"""
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='device_bay_templates'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('device_type', '_name')
|
||||
unique_together = ('device_type', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def instantiate(self, device):
|
||||
return DeviceBay(
|
||||
device=device,
|
||||
|
||||
@@ -16,9 +16,9 @@ from extras.models import ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
from utilities.fields import NaturalOrderingField
|
||||
from utilities.ordering import naturalize_interface
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.query_functions import CollateAsChar
|
||||
from utilities.utils import serialize_object
|
||||
from virtualization.choices import VMInterfaceTypeChoices
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -36,30 +36,46 @@ __all__ = (
|
||||
|
||||
|
||||
class ComponentModel(models.Model):
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='%(class)ss'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
label = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
help_text="Physical label"
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return getattr(self, 'name')
|
||||
if self.label:
|
||||
return f"{self.name} ({self.label})"
|
||||
return self.name
|
||||
|
||||
def to_objectchange(self, action):
|
||||
# Annotate the parent Device/VM
|
||||
try:
|
||||
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
|
||||
except ObjectDoesNotExist:
|
||||
# The parent device/VM has already been deleted
|
||||
parent = None
|
||||
|
||||
# Annotate the parent Device
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
related_object=parent,
|
||||
related_object=self.device,
|
||||
object_data=serialize_object(self)
|
||||
)
|
||||
|
||||
@@ -235,19 +251,6 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='consoleports'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ConsolePortTypeChoices,
|
||||
@@ -261,25 +264,27 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.type,
|
||||
self.description,
|
||||
)
|
||||
@@ -294,44 +299,33 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='consoleserverports'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=ConsolePortTypeChoices,
|
||||
blank=True,
|
||||
help_text='Physical port type'
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.type,
|
||||
self.description,
|
||||
)
|
||||
@@ -346,19 +340,6 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='powerports'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerPortTypeChoices,
|
||||
@@ -391,25 +372,27 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
return reverse('dcim:powerport', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.get_type_display(),
|
||||
self.maximum_draw,
|
||||
self.allocated_draw,
|
||||
@@ -506,19 +489,6 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='poweroutlets'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PowerOutletTypeChoices,
|
||||
@@ -538,25 +508,27 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.get_type_display(),
|
||||
self.power_port.name if self.power_port else None,
|
||||
self.get_feed_leg_display(),
|
||||
@@ -576,29 +548,40 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class BaseInterface(models.Model):
|
||||
"""
|
||||
Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
|
||||
"""
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
mac_address = MACAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='MAC Address'
|
||||
)
|
||||
mtu = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65536)],
|
||||
verbose_name='MTU'
|
||||
)
|
||||
mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfaceModeChoices,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
@extras_features('graphs', 'export_templates', 'webhooks')
|
||||
class Interface(CableTermination, ComponentModel):
|
||||
class Interface(CableTermination, ComponentModel, BaseInterface):
|
||||
"""
|
||||
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
|
||||
Interface.
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='interfaces',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
virtual_machine = models.ForeignKey(
|
||||
to='virtualization.VirtualMachine',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='interfaces',
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
# Override ComponentModel._name to specify naturalize_interface function
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
naturalize_function=naturalize_interface,
|
||||
@@ -619,9 +602,10 @@ class Interface(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
connection_status = models.BooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
lag = models.ForeignKey(
|
||||
to='self',
|
||||
@@ -635,30 +619,11 @@ class Interface(CableTermination, ComponentModel):
|
||||
max_length=50,
|
||||
choices=InterfaceTypeChoices
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
mac_address = MACAddressField(
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name='MAC Address'
|
||||
)
|
||||
mtu = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65536)],
|
||||
verbose_name='MTU'
|
||||
)
|
||||
mgmt_only = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='OOB Management',
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
mode = models.CharField(
|
||||
max_length=50,
|
||||
choices=InterfaceModeChoices,
|
||||
blank=True
|
||||
)
|
||||
untagged_vlan = models.ForeignKey(
|
||||
to='ipam.VLAN',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -673,15 +638,19 @@ class Interface(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
verbose_name='Tagged VLANs'
|
||||
)
|
||||
ip_addresses = GenericRelation(
|
||||
to='ipam.IPAddress',
|
||||
content_type_field='assigned_object_type',
|
||||
object_id_field='assigned_object_id',
|
||||
related_query_name='interface'
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
|
||||
'description', 'mode',
|
||||
'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
# TODO: ordering and unique_together should include virtual_machine
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
@@ -691,8 +660,8 @@ class Interface(CableTermination, ComponentModel):
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier if self.device else None,
|
||||
self.virtual_machine.name if self.virtual_machine else None,
|
||||
self.name,
|
||||
self.label,
|
||||
self.lag.name if self.lag else None,
|
||||
self.get_type_display(),
|
||||
self.enabled,
|
||||
@@ -705,18 +674,6 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# An Interface must belong to a Device *or* to a VirtualMachine
|
||||
if self.device and self.virtual_machine:
|
||||
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
|
||||
if not self.device and not self.virtual_machine:
|
||||
raise ValidationError("An interface must belong to either a device or a virtual machine.")
|
||||
|
||||
# VM interfaces must be virtual
|
||||
if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
|
||||
raise ValidationError({
|
||||
'type': "Invalid interface type for a virtual machine: {}".format(self.type)
|
||||
})
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and (
|
||||
self.cable or getattr(self, 'circuit_termination', False)
|
||||
@@ -752,7 +709,7 @@ class Interface(CableTermination, ComponentModel):
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
|
||||
raise ValidationError({
|
||||
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
|
||||
"device/VM, or it must be global".format(self.untagged_vlan)
|
||||
"device, or it must be global".format(self.untagged_vlan)
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
@@ -767,21 +724,6 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def to_objectchange(self, action):
|
||||
# Annotate the parent Device/VM
|
||||
try:
|
||||
parent_obj = self.device or self.virtual_machine
|
||||
except ObjectDoesNotExist:
|
||||
parent_obj = None
|
||||
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
related_object=parent_obj,
|
||||
object_data=serialize_object(self)
|
||||
)
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
"""
|
||||
@@ -820,7 +762,7 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
@property
|
||||
def parent(self):
|
||||
return self.device or self.virtual_machine
|
||||
return self.device
|
||||
|
||||
@property
|
||||
def is_connectable(self):
|
||||
@@ -852,19 +794,6 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='frontports'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PortTypeChoices
|
||||
@@ -880,7 +809,7 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -889,10 +818,14 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
('rear_port', 'rear_port_position'),
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:frontport', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.get_type_display(),
|
||||
self.rear_port.name,
|
||||
self.rear_port_position,
|
||||
@@ -921,19 +854,6 @@ class RearPort(CableTermination, ComponentModel):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='rearports'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
type = models.CharField(
|
||||
max_length=50,
|
||||
choices=PortTypeChoices
|
||||
@@ -944,16 +864,20 @@ class RearPort(CableTermination, ComponentModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'positions', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:rearport', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.get_type_display(),
|
||||
self.positions,
|
||||
self.description,
|
||||
@@ -969,20 +893,6 @@ class DeviceBay(ComponentModel):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='device_bays'
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
verbose_name='Name'
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
installed_device = models.OneToOneField(
|
||||
to='dcim.Device',
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -992,19 +902,20 @@ class DeviceBay(ComponentModel):
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'installed_device', 'description']
|
||||
csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
unique_together = ('device', 'name')
|
||||
|
||||
def get_absolute_url(self):
|
||||
return self.device.get_absolute_url()
|
||||
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.label,
|
||||
self.installed_device.identifier if self.installed_device else None,
|
||||
self.description,
|
||||
)
|
||||
@@ -1042,11 +953,6 @@ class InventoryItem(ComponentModel):
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
InventoryItems are used only for inventory purposes.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='inventory_items'
|
||||
)
|
||||
parent = models.ForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
@@ -1054,15 +960,6 @@ class InventoryItem(ComponentModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50,
|
||||
verbose_name='Name'
|
||||
)
|
||||
_name = NaturalOrderingField(
|
||||
target_field='name',
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
manufacturer = models.ForeignKey(
|
||||
to='dcim.Manufacturer',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -1097,23 +994,21 @@ class InventoryItem(ComponentModel):
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ('device__id', 'parent__id', '_name')
|
||||
unique_together = ('device', 'parent', 'name')
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
|
||||
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.name or '{{{}}}'.format(self.device.pk),
|
||||
self.name,
|
||||
self.label,
|
||||
self.manufacturer.name if self.manufacturer else None,
|
||||
self.part_id,
|
||||
self.serial,
|
||||
|
||||
@@ -10,14 +10,13 @@ from .models import Cable, CableTermination, Device, FrontPort, RearPort, Virtua
|
||||
@receiver(post_save, sender=VirtualChassis)
|
||||
def assign_virtualchassis_master(instance, created, **kwargs):
|
||||
"""
|
||||
When a VirtualChassis is created, automatically assign its master device to the VC.
|
||||
When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
|
||||
"""
|
||||
if created:
|
||||
devices = Device.objects.filter(pk=instance.master.pk)
|
||||
for device in devices:
|
||||
device.virtual_chassis = instance
|
||||
device.vc_position = None
|
||||
device.save()
|
||||
if created and instance.master:
|
||||
master = Device.objects.get(pk=instance.master.pk)
|
||||
master.virtual_chassis = instance
|
||||
master.vc_position = 1
|
||||
master.save()
|
||||
|
||||
|
||||
@receiver(pre_delete, sender=VirtualChassis)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@@ -28,9 +29,46 @@ class AppTest(APITestCase):
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class Mixins:
|
||||
|
||||
class ComponentTraceMixin(APITestCase):
|
||||
peer_termination_type = None
|
||||
|
||||
def test_trace(self):
|
||||
"""
|
||||
Test tracing a device component's attached cable.
|
||||
"""
|
||||
obj = self.model.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
if self.peer_termination_type is None:
|
||||
raise NotImplementedError("Test case must set peer_termination_type")
|
||||
peer_obj = self.peer_termination_type.objects.create(
|
||||
device=peer_device,
|
||||
name='Peer Termination'
|
||||
)
|
||||
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
|
||||
url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], obj.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], peer_obj.name)
|
||||
|
||||
|
||||
class RegionTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Region
|
||||
brief_fields = ['id', 'name', 'site_count', 'slug', 'url']
|
||||
brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Region 4',
|
||||
@@ -94,6 +132,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_site_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Sites.
|
||||
@@ -106,6 +145,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
@@ -115,7 +155,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class RackGroupTest(APIViewTestCases.APIViewTestCase):
|
||||
model = RackGroup
|
||||
brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
|
||||
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -241,48 +281,35 @@ class RackTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
# TODO: Document this test
|
||||
def test_get_elevation_rack_units(self):
|
||||
rack = Rack.objects.first()
|
||||
|
||||
url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 13)
|
||||
|
||||
url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 11)
|
||||
|
||||
url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
def test_get_rack_elevation(self):
|
||||
"""
|
||||
GET a single rack elevation.
|
||||
"""
|
||||
rack = Rack.objects.first()
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
# Retrieve all units
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['count'], 42)
|
||||
|
||||
# Search for specific units
|
||||
response = self.client.get(f'{url}?q=3', **self.header)
|
||||
self.assertEqual(response.data['count'], 13)
|
||||
response = self.client.get(f'{url}?q=U3', **self.header)
|
||||
self.assertEqual(response.data['count'], 11)
|
||||
response = self.client.get(f'{url}?q=U10', **self.header)
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
def test_get_rack_elevation_svg(self):
|
||||
"""
|
||||
GET a single rack elevation in SVG format.
|
||||
"""
|
||||
rack = Rack.objects.first()
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
|
||||
|
||||
@@ -293,9 +320,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
user = User.objects.create(username='user1', is_active=True)
|
||||
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
|
||||
cls.racks = (
|
||||
@@ -877,6 +902,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_device_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Devices.
|
||||
@@ -889,6 +915,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
@@ -899,6 +926,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Check that config context data is included by default in the devices list.
|
||||
"""
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
@@ -908,6 +936,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
"""
|
||||
Check that config context data can be excluded by passing ?exclude=config_context.
|
||||
"""
|
||||
self.add_permissions('dcim.view_device')
|
||||
url = reverse('dcim-api:device-list') + '?exclude=config_context'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
@@ -925,15 +954,17 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
||||
'name': device.name,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_device')
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class ConsolePortTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsolePort
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = ConsoleServerPort
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -965,38 +996,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_consoleport(self):
|
||||
"""
|
||||
Test tracing a ConsolePort cable.
|
||||
"""
|
||||
consoleport = ConsolePort.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
consoleserverport = ConsoleServerPort.objects.create(
|
||||
device=peer_device,
|
||||
name='Console Server Port 1'
|
||||
)
|
||||
cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], consoleport.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], consoleserverport.name)
|
||||
|
||||
|
||||
class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = ConsoleServerPort
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = ConsolePort
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1028,38 +1032,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_consoleserverport(self):
|
||||
"""
|
||||
Test tracing a ConsoleServerPort cable.
|
||||
"""
|
||||
consoleserverport = ConsoleServerPort.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
consoleport = ConsolePort.objects.create(
|
||||
device=peer_device,
|
||||
name='Console Port 1'
|
||||
)
|
||||
cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], consoleserverport.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], consoleport.name)
|
||||
|
||||
|
||||
class PowerPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerPort
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = PowerOutlet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1091,38 +1068,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_powerport(self):
|
||||
"""
|
||||
Test tracing a PowerPort cable.
|
||||
"""
|
||||
powerport = PowerPort.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
poweroutlet = PowerOutlet.objects.create(
|
||||
device=peer_device,
|
||||
name='Power Outlet 1'
|
||||
)
|
||||
cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], powerport.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], poweroutlet.name)
|
||||
|
||||
|
||||
class PowerOutletTest(APIViewTestCases.APIViewTestCase):
|
||||
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = PowerOutlet
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = PowerPort
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1154,38 +1104,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_poweroutlet(self):
|
||||
"""
|
||||
Test tracing a PowerOutlet cable.
|
||||
"""
|
||||
poweroutlet = PowerOutlet.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
powerport = PowerPort.objects.create(
|
||||
device=peer_device,
|
||||
name='Power Port 1'
|
||||
)
|
||||
cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], poweroutlet.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], powerport.name)
|
||||
|
||||
|
||||
class InterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = Interface
|
||||
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1236,6 +1159,7 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_get_interface_graphs(self):
|
||||
"""
|
||||
Test retrieval of Graphs assigned to Devices.
|
||||
@@ -1248,44 +1172,18 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Graph.objects.bulk_create(graphs)
|
||||
|
||||
self.add_permissions('dcim.view_interface')
|
||||
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 3)
|
||||
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
|
||||
|
||||
def test_trace_interface(self):
|
||||
"""
|
||||
Test tracing an Interface cable.
|
||||
"""
|
||||
interface_a = Interface.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
interface_b = Interface.objects.create(
|
||||
device=peer_device,
|
||||
name='Interface X'
|
||||
)
|
||||
cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], interface_a.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], interface_b.name)
|
||||
|
||||
|
||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1336,38 +1234,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_frontport(self):
|
||||
"""
|
||||
Test tracing a FrontPort cable.
|
||||
"""
|
||||
frontport = FrontPort.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
interface = Interface.objects.create(
|
||||
device=peer_device,
|
||||
name='Interface X'
|
||||
)
|
||||
cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], frontport.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], interface.name)
|
||||
|
||||
|
||||
class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
|
||||
model = RearPort
|
||||
brief_fields = ['cable', 'device', 'id', 'name', 'url']
|
||||
peer_termination_type = Interface
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -1402,34 +1273,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
|
||||
},
|
||||
]
|
||||
|
||||
def test_trace_rearport(self):
|
||||
"""
|
||||
Test tracing a RearPort cable.
|
||||
"""
|
||||
rearport = RearPort.objects.first()
|
||||
peer_device = Device.objects.create(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
device_role=DeviceRole.objects.first(),
|
||||
name='Peer Device'
|
||||
)
|
||||
interface = Interface.objects.create(
|
||||
device=peer_device,
|
||||
name='Interface X'
|
||||
)
|
||||
cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
|
||||
cable.save()
|
||||
|
||||
url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
segment1 = response.data[0]
|
||||
self.assertEqual(segment1[0]['name'], rearport.name)
|
||||
self.assertEqual(segment1[1]['label'], cable.label)
|
||||
self.assertEqual(segment1[2]['name'], interface.name)
|
||||
|
||||
|
||||
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
|
||||
model = DeviceBay
|
||||
@@ -1635,6 +1478,7 @@ class ConnectionTest(APITestCase):
|
||||
'termination_b_id': consoleserverport1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
@@ -1673,6 +1517,7 @@ class ConnectionTest(APITestCase):
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Console port to panel1 front
|
||||
@@ -1728,6 +1573,7 @@ class ConnectionTest(APITestCase):
|
||||
'termination_b_id': poweroutlet1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
@@ -1763,6 +1609,7 @@ class ConnectionTest(APITestCase):
|
||||
'termination_b_id': interface2.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
@@ -1801,6 +1648,7 @@ class ConnectionTest(APITestCase):
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Interface1 to panel1 front
|
||||
@@ -1865,6 +1713,7 @@ class ConnectionTest(APITestCase):
|
||||
'termination_b_id': circuittermination1.pk,
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
@@ -1912,6 +1761,7 @@ class ConnectionTest(APITestCase):
|
||||
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
|
||||
)
|
||||
|
||||
self.add_permissions('dcim.add_cable')
|
||||
url = reverse('dcim-api:cable-list')
|
||||
cables = [
|
||||
# Interface to panel1 front
|
||||
@@ -1996,7 +1846,7 @@ class ConnectedDeviceTest(APITestCase):
|
||||
|
||||
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
model = VirtualChassis
|
||||
brief_fields = ['id', 'master', 'member_count', 'url']
|
||||
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
@@ -2015,6 +1865,9 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
Device(name='Device 7', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 8', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 9', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 10', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 11', device_type=devicetype, device_role=devicerole, site=site),
|
||||
Device(name='Device 12', device_type=devicetype, device_role=devicerole, site=site),
|
||||
)
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
@@ -2028,35 +1881,39 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
|
||||
)
|
||||
Interface.objects.bulk_create(interfaces)
|
||||
|
||||
# Create two VirtualChassis with three members each
|
||||
# Create three VirtualChassis with three members each
|
||||
virtual_chassis = (
|
||||
VirtualChassis(master=devices[0], domain='domain-1'),
|
||||
VirtualChassis(master=devices[3], domain='domain-2'),
|
||||
VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'),
|
||||
VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'),
|
||||
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
|
||||
)
|
||||
VirtualChassis.objects.bulk_create(virtual_chassis)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
|
||||
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
|
||||
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
|
||||
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
|
||||
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
|
||||
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
|
||||
|
||||
cls.update_data = {
|
||||
'master': devices[1].pk,
|
||||
'name': 'Virtual Chassis X',
|
||||
'domain': 'domain-x',
|
||||
'master': devices[1].pk,
|
||||
}
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'master': devices[6].pk,
|
||||
'domain': 'domain-3',
|
||||
},
|
||||
{
|
||||
'master': devices[7].pk,
|
||||
'name': 'Virtual Chassis 4',
|
||||
'domain': 'domain-4',
|
||||
},
|
||||
{
|
||||
'master': devices[8].pk,
|
||||
'name': 'Virtual Chassis 5',
|
||||
'domain': 'domain-5',
|
||||
},
|
||||
{
|
||||
'name': 'Virtual Chassis 6',
|
||||
'domain': 'domain-6',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
# Assign primary IPs for filtering
|
||||
ipaddresses = (
|
||||
IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
|
||||
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
|
||||
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ipaddresses)
|
||||
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])
|
||||
|
||||
@@ -116,3 +116,45 @@ class DeviceTestCase(TestCase):
|
||||
|
||||
# Check that the initial value for the cluster group is set automatically when assigning the cluster
|
||||
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
|
||||
|
||||
|
||||
class LabelTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Site 2', slug='site-2')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
|
||||
cls.device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Device Role 2', slug='device-role-2', color='ffff00'
|
||||
)
|
||||
cls.device = Device.objects.create(
|
||||
name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
|
||||
)
|
||||
|
||||
def test_interface_label_count_valid(self):
|
||||
"""Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
|
||||
interface_data = {
|
||||
'device': self.device.pk,
|
||||
'name_pattern': 'eth[0-9]',
|
||||
'label_pattern': 'Interface[0-9]',
|
||||
'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
|
||||
}
|
||||
form = InterfaceCreateForm(interface_data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_interface_label_count_mismatch(self):
|
||||
"""Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
|
||||
bad_interface_data = {
|
||||
'device': self.device.pk,
|
||||
'name_pattern': 'eth[0-9]',
|
||||
'label_pattern': 'Interface[0-1]',
|
||||
'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
|
||||
}
|
||||
form = InterfaceCreateForm(bad_interface_data)
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label_pattern', form.errors)
|
||||
|
||||
@@ -4,6 +4,7 @@ import pytz
|
||||
import yaml
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
@@ -76,6 +77,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Site(name='Site 3', slug='site-3', region=regions[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Site X',
|
||||
'slug': 'site-x',
|
||||
@@ -94,7 +97,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'contact_phone': '123-555-9999',
|
||||
'contact_email': 'hank@stricklandpropane.com',
|
||||
'comments': 'Test site',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -196,12 +199,15 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'rack': rack.pk,
|
||||
'units': "10,11,12",
|
||||
'user': user3.pk,
|
||||
'tenant': None,
|
||||
'description': 'Rack reservation',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -249,6 +255,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Rack(name='Rack 3', site=sites[0]),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Rack X',
|
||||
'facility_id': 'Facility X',
|
||||
@@ -267,7 +275,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'outer_depth': 500,
|
||||
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
|
||||
'comments': 'Some comments',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -321,7 +329,18 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
)
|
||||
|
||||
|
||||
class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by absence of bulk import view for DeviceTypes
|
||||
class DeviceTypeTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
model = DeviceType
|
||||
|
||||
@classmethod
|
||||
@@ -339,6 +358,8 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
'model': 'Device Type X',
|
||||
@@ -348,7 +369,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'is_full_depth': True,
|
||||
'subdevice_role': '', # CharField
|
||||
'comments': 'Some comments',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -357,6 +378,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'is_full_depth': False,
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_import_objects(self):
|
||||
"""
|
||||
Custom import test for YAML-based imports (versus CSV)
|
||||
@@ -460,45 +482,45 @@ device-bays:
|
||||
self.assertEqual(dt.comments, 'test comment')
|
||||
|
||||
# Verify all of the components were created
|
||||
self.assertEqual(dt.consoleport_templates.count(), 3)
|
||||
self.assertEqual(dt.consoleporttemplates.count(), 3)
|
||||
cp1 = ConsolePortTemplate.objects.first()
|
||||
self.assertEqual(cp1.name, 'Console Port 1')
|
||||
self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
|
||||
|
||||
self.assertEqual(dt.consoleserverport_templates.count(), 3)
|
||||
self.assertEqual(dt.consoleserverporttemplates.count(), 3)
|
||||
csp1 = ConsoleServerPortTemplate.objects.first()
|
||||
self.assertEqual(csp1.name, 'Console Server Port 1')
|
||||
self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
|
||||
|
||||
self.assertEqual(dt.powerport_templates.count(), 3)
|
||||
self.assertEqual(dt.powerporttemplates.count(), 3)
|
||||
pp1 = PowerPortTemplate.objects.first()
|
||||
self.assertEqual(pp1.name, 'Power Port 1')
|
||||
self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
|
||||
|
||||
self.assertEqual(dt.poweroutlet_templates.count(), 3)
|
||||
self.assertEqual(dt.poweroutlettemplates.count(), 3)
|
||||
po1 = PowerOutletTemplate.objects.first()
|
||||
self.assertEqual(po1.name, 'Power Outlet 1')
|
||||
self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
|
||||
self.assertEqual(po1.power_port, pp1)
|
||||
self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
|
||||
|
||||
self.assertEqual(dt.interface_templates.count(), 3)
|
||||
self.assertEqual(dt.interfacetemplates.count(), 3)
|
||||
iface1 = InterfaceTemplate.objects.first()
|
||||
self.assertEqual(iface1.name, 'Interface 1')
|
||||
self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
|
||||
self.assertTrue(iface1.mgmt_only)
|
||||
|
||||
self.assertEqual(dt.rearport_templates.count(), 3)
|
||||
self.assertEqual(dt.rearporttemplates.count(), 3)
|
||||
rp1 = RearPortTemplate.objects.first()
|
||||
self.assertEqual(rp1.name, 'Rear Port 1')
|
||||
|
||||
self.assertEqual(dt.frontport_templates.count(), 3)
|
||||
self.assertEqual(dt.frontporttemplates.count(), 3)
|
||||
fp1 = FrontPortTemplate.objects.first()
|
||||
self.assertEqual(fp1.name, 'Front Port 1')
|
||||
self.assertEqual(fp1.rear_port, rp1)
|
||||
self.assertEqual(fp1.rear_port_position, 1)
|
||||
|
||||
self.assertEqual(dt.device_bay_templates.count(), 3)
|
||||
self.assertEqual(dt.devicebaytemplates.count(), 3)
|
||||
db1 = DeviceBayTemplate.objects.first()
|
||||
self.assertEqual(db1.name, 'Device Bay 1')
|
||||
|
||||
@@ -699,6 +721,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
cls.bulk_create_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'name_pattern': 'Interface Template [4-6]',
|
||||
# Test that a label can be applied to each generated interface templates
|
||||
'label_pattern': 'Interface Template Label [3-5]',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mgmt_only': True,
|
||||
}
|
||||
@@ -795,9 +819,6 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
|
||||
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
|
||||
model = DeviceBayTemplate
|
||||
|
||||
# Disable inapplicable views
|
||||
test_bulk_edit_objects = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||
@@ -823,6 +844,10 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
|
||||
'name_pattern': 'Device Bay Template [4-6]',
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'description': 'Foo bar',
|
||||
}
|
||||
|
||||
|
||||
class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = DeviceRole
|
||||
@@ -930,6 +955,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_role': deviceroles[1].pk,
|
||||
@@ -950,7 +977,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'vc_position': None,
|
||||
'vc_priority': None,
|
||||
'comments': 'A new device',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
'local_context_data': None,
|
||||
}
|
||||
|
||||
@@ -984,20 +1011,24 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
ConsolePort(device=device, name='Console Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Console Port X',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': sorted([t.pk for t in tags]),
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Console Port [4-6]',
|
||||
# Test that a label can be applied to each generated console ports
|
||||
'label_pattern': 'Serial[3-5]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': sorted([t.pk for t in tags]),
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1026,12 +1057,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
ConsoleServerPort(device=device, name='Console Server Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Console Server Port X',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console server port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1039,12 +1072,11 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'name_pattern': 'Console Server Port [4-6]',
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'description': 'A console server port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'device': device.pk,
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ45,
|
||||
'type': ConsolePortTypeChoices.TYPE_RJ11,
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@@ -1069,6 +1101,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
PowerPort(device=device, name='Power Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Power Port X',
|
||||
@@ -1076,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'maximum_draw': 100,
|
||||
'allocated_draw': 50,
|
||||
'description': 'A power port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1086,7 +1120,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'maximum_draw': 100,
|
||||
'allocated_draw': 50,
|
||||
'description': 'A power port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1123,6 +1157,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Power Outlet X',
|
||||
@@ -1130,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'power_port': powerports[1].pk,
|
||||
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
|
||||
'description': 'A power outlet',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1140,12 +1176,11 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'power_port': powerports[1].pk,
|
||||
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
|
||||
'description': 'A power outlet',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'device': device.pk,
|
||||
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
|
||||
'type': PowerOutletTypeChoices.TYPE_IEC_C15,
|
||||
'power_port': powerports[1].pk,
|
||||
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
|
||||
'description': 'New description',
|
||||
@@ -1159,10 +1194,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.DeviceComponentViewTestCase,
|
||||
):
|
||||
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
model = Interface
|
||||
|
||||
@classmethod
|
||||
@@ -1185,6 +1217,8 @@ class InterfaceTestCase(
|
||||
)
|
||||
VLAN.objects.bulk_create(vlans)
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'virtual_machine': None,
|
||||
@@ -1199,7 +1233,7 @@ class InterfaceTestCase(
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1215,13 +1249,12 @@ class InterfaceTestCase(
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [v.pk for v in vlans[1:4]],
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'device': device.pk,
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'enabled': False,
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
'enabled': True,
|
||||
'lag': interfaces[3].pk,
|
||||
'mac_address': EUI('01:02:03:04:05:06'),
|
||||
'mtu': 2000,
|
||||
@@ -1263,6 +1296,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Front Port X',
|
||||
@@ -1270,7 +1305,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'rear_port': rearports[3].pk,
|
||||
'rear_port_position': 1,
|
||||
'description': 'New description',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1281,7 +1316,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'{}:1'.format(rp.pk) for rp in rearports[3:6]
|
||||
],
|
||||
'description': 'New description',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1310,13 +1345,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
RearPort(device=device, name='Rear Port 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Rear Port X',
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'positions': 3,
|
||||
'description': 'A rear port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1325,7 +1362,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'type': PortTypeChoices.TYPE_8P8C,
|
||||
'positions': 3,
|
||||
'description': 'A rear port',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1357,18 +1394,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
DeviceBay(device=device, name='Device Bay 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'name': 'Device Bay X',
|
||||
'description': 'A device bay',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
'device': device.pk,
|
||||
'name_pattern': 'Device Bay [4-6]',
|
||||
'description': 'A device bay',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1397,6 +1436,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
InventoryItem(device=device, name='Inventory Item 3'),
|
||||
])
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'device': device.pk,
|
||||
'manufacturer': manufacturer.pk,
|
||||
@@ -1407,7 +1448,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'serial': '123ABC',
|
||||
'asset_tag': 'ABC123',
|
||||
'description': 'An inventory item',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_create_data = {
|
||||
@@ -1419,12 +1460,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'part_id': '123456',
|
||||
'serial': '123ABC',
|
||||
'description': 'An inventory item',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'device': device.pk,
|
||||
'manufacturer': manufacturer.pk,
|
||||
'part_id': '123456',
|
||||
'description': 'New description',
|
||||
}
|
||||
@@ -1437,12 +1476,20 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
)
|
||||
|
||||
|
||||
class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by lack of common creation view for cables (termination A must be initialized)
|
||||
class CableTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkImportObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
model = Cable
|
||||
|
||||
# TODO: Creation URL needs termination context
|
||||
test_create_object = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -1479,6 +1526,8 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
|
||||
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
interface_ct = ContentType.objects.get_for_model(Interface)
|
||||
cls.form_data = {
|
||||
# Changing terminations not supported when editing an existing Cable
|
||||
@@ -1492,6 +1541,7 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'color': 'c0c0c0',
|
||||
'length': 100,
|
||||
'length_unit': CableLengthUnitChoices.UNIT_FOOT,
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -1514,13 +1564,6 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = VirtualChassis
|
||||
|
||||
# Disable inapplicable tests
|
||||
test_import_objects = None
|
||||
|
||||
# TODO: Requires special form handling
|
||||
test_create_object = None
|
||||
test_edit_object = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -1533,33 +1576,56 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
name='Device Role', slug='device-role-1'
|
||||
)
|
||||
|
||||
# Create 9 member Devices
|
||||
device1 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 1', site=site
|
||||
devices = (
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 1', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 2', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 3', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 4', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 5', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 6', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 7', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 8', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 10', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 11', site=site),
|
||||
Device(device_type=device_type, device_role=device_role, name='Device 12', site=site),
|
||||
)
|
||||
device2 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 2', site=site
|
||||
)
|
||||
device3 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 3', site=site
|
||||
)
|
||||
device4 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 4', site=site
|
||||
)
|
||||
device5 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 5', site=site
|
||||
)
|
||||
device6 = Device.objects.create(
|
||||
device_type=device_type, device_role=device_role, name='Device 6', site=site
|
||||
Device.objects.bulk_create(devices)
|
||||
|
||||
# Create three VirtualChassis with three members each
|
||||
vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1')
|
||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1)
|
||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2)
|
||||
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3)
|
||||
vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2')
|
||||
Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1)
|
||||
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2)
|
||||
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3)
|
||||
vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3')
|
||||
Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1)
|
||||
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2)
|
||||
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3)
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'VC4',
|
||||
'domain': 'domain-4',
|
||||
# Management form data for VC members
|
||||
'form-TOTAL_FORMS': 0,
|
||||
'form-INITIAL_FORMS': 3,
|
||||
'form-MIN_NUM_FORMS': 0,
|
||||
'form-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,domain,master",
|
||||
"VC4,Domain 4,Device 10",
|
||||
"VC5,Domain 5,Device 11",
|
||||
"VC6,Domain 6,Device 12",
|
||||
)
|
||||
|
||||
# Create three VirtualChassis with two members each
|
||||
vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1')
|
||||
Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2)
|
||||
vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2')
|
||||
Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
|
||||
vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
|
||||
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
|
||||
cls.bulk_edit_data = {
|
||||
'domain': 'domain-x',
|
||||
}
|
||||
|
||||
|
||||
class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
@@ -1587,10 +1653,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'site': sites[1].pk,
|
||||
'rack_group': rackgroups[1].pk,
|
||||
'name': 'Power Panel X',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
@@ -1632,6 +1701,8 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
|
||||
))
|
||||
|
||||
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Power Feed X',
|
||||
'power_panel': powerpanels[1].pk,
|
||||
@@ -1644,7 +1715,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'amperage': 100,
|
||||
'max_utilization': 50,
|
||||
'comments': 'New comments',
|
||||
'tags': 'Alpha,Bravo,Charlie',
|
||||
'tags': [t.pk for t in tags],
|
||||
|
||||
# Connection
|
||||
'cable': None,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
|
||||
from ipam.views import ServiceCreateView
|
||||
from ipam.views import ServiceEditView
|
||||
from . import views
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
|
||||
PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
|
||||
VirtualChassis,
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
|
||||
InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
|
||||
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
|
||||
)
|
||||
|
||||
app_name = 'dcim'
|
||||
@@ -14,15 +14,16 @@ urlpatterns = [
|
||||
|
||||
# Regions
|
||||
path('regions/', views.RegionListView.as_view(), name='region_list'),
|
||||
path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
|
||||
path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
|
||||
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
|
||||
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
|
||||
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
|
||||
path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
|
||||
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
|
||||
|
||||
# Sites
|
||||
path('sites/', views.SiteListView.as_view(), name='site_list'),
|
||||
path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
|
||||
path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
|
||||
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
|
||||
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
|
||||
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
|
||||
@@ -34,23 +35,25 @@ urlpatterns = [
|
||||
|
||||
# Rack groups
|
||||
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
|
||||
path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
|
||||
path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
|
||||
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
|
||||
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
|
||||
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
|
||||
path('rack-groups/<int:pk>/delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
|
||||
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
|
||||
|
||||
# Rack roles
|
||||
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||
path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
|
||||
path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
|
||||
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
|
||||
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
|
||||
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
|
||||
path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
|
||||
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
|
||||
|
||||
# Rack reservations
|
||||
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
|
||||
path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
|
||||
path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'),
|
||||
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
|
||||
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
|
||||
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
|
||||
@@ -62,7 +65,7 @@ urlpatterns = [
|
||||
# Racks
|
||||
path('racks/', views.RackListView.as_view(), name='rack_list'),
|
||||
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
|
||||
path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
|
||||
path('racks/add/', views.RackEditView.as_view(), name='rack_add'),
|
||||
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
|
||||
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
|
||||
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
|
||||
@@ -74,15 +77,16 @@ urlpatterns = [
|
||||
|
||||
# Manufacturers
|
||||
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||
path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
|
||||
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
|
||||
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
|
||||
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
|
||||
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
|
||||
path('manufacturers/<slug:slug>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
|
||||
path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
|
||||
|
||||
# Device types
|
||||
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
|
||||
path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
|
||||
path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
|
||||
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
|
||||
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
|
||||
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
|
||||
@@ -94,6 +98,7 @@ urlpatterns = [
|
||||
# Console port templates
|
||||
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
|
||||
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
|
||||
path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'),
|
||||
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
|
||||
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
|
||||
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
|
||||
@@ -101,6 +106,7 @@ urlpatterns = [
|
||||
# Console server port templates
|
||||
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
|
||||
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
|
||||
path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'),
|
||||
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
|
||||
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
|
||||
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
|
||||
@@ -108,6 +114,7 @@ urlpatterns = [
|
||||
# Power port templates
|
||||
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
|
||||
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
|
||||
path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'),
|
||||
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
|
||||
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
|
||||
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
|
||||
@@ -115,6 +122,7 @@ urlpatterns = [
|
||||
# Power outlet templates
|
||||
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
|
||||
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
|
||||
path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'),
|
||||
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
|
||||
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
|
||||
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
|
||||
@@ -122,6 +130,7 @@ urlpatterns = [
|
||||
# Interface templates
|
||||
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
|
||||
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
|
||||
path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'),
|
||||
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
|
||||
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
|
||||
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
|
||||
@@ -129,6 +138,7 @@ urlpatterns = [
|
||||
# Front port templates
|
||||
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
|
||||
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
|
||||
path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'),
|
||||
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
|
||||
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
|
||||
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
|
||||
@@ -136,36 +146,40 @@ urlpatterns = [
|
||||
# Rear port templates
|
||||
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
|
||||
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
|
||||
path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'),
|
||||
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
|
||||
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
|
||||
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
|
||||
|
||||
# Device bay templates
|
||||
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
|
||||
# path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
|
||||
path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
|
||||
path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'),
|
||||
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
|
||||
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
|
||||
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
|
||||
|
||||
# Device roles
|
||||
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
|
||||
path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
|
||||
path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
|
||||
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
|
||||
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
|
||||
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
|
||||
path('device-roles/<slug:slug>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
|
||||
path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
|
||||
|
||||
# Platforms
|
||||
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
|
||||
path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
|
||||
path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
|
||||
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
|
||||
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
|
||||
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
|
||||
path('platforms/<slug:slug>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
|
||||
path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
|
||||
|
||||
# Devices
|
||||
path('devices/', views.DeviceListView.as_view(), name='device_list'),
|
||||
path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
|
||||
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
|
||||
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
|
||||
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
|
||||
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
|
||||
@@ -179,7 +193,7 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
|
||||
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
|
||||
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
# Console ports
|
||||
@@ -187,12 +201,15 @@ urlpatterns = [
|
||||
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
|
||||
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
|
||||
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
|
||||
# TODO: Bulk rename, disconnect views for ConsolePorts
|
||||
path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'),
|
||||
path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
|
||||
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
|
||||
path('console-ports/<int:pk>/', views.ConsolePortView.as_view(), name='consoleport'),
|
||||
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
|
||||
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
|
||||
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
|
||||
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
|
||||
# Console server ports
|
||||
@@ -203,10 +220,12 @@ urlpatterns = [
|
||||
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
|
||||
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
|
||||
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
|
||||
path('console-server-ports/<int:pk>/', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
|
||||
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
|
||||
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
|
||||
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
|
||||
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
|
||||
|
||||
# Power ports
|
||||
@@ -214,12 +233,15 @@ urlpatterns = [
|
||||
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
|
||||
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
|
||||
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
|
||||
# TODO: Bulk rename, disconnect views for PowerPorts
|
||||
path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'),
|
||||
path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'),
|
||||
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
|
||||
path('power-ports/<int:pk>/', views.PowerPortView.as_view(), name='powerport'),
|
||||
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
|
||||
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
|
||||
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
|
||||
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
|
||||
# Power outlets
|
||||
@@ -230,10 +252,12 @@ urlpatterns = [
|
||||
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
|
||||
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
|
||||
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
|
||||
path('power-outlets/<int:pk>/', views.PowerOutletView.as_view(), name='poweroutlet'),
|
||||
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
|
||||
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
|
||||
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
|
||||
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
|
||||
|
||||
# Interfaces
|
||||
@@ -244,12 +268,12 @@ urlpatterns = [
|
||||
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
|
||||
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
|
||||
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
|
||||
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
|
||||
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
|
||||
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
|
||||
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
|
||||
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
|
||||
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
|
||||
|
||||
# Front ports
|
||||
@@ -260,10 +284,12 @@ urlpatterns = [
|
||||
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
|
||||
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
|
||||
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
|
||||
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
|
||||
path('front-ports/<int:pk>/', views.FrontPortView.as_view(), name='frontport'),
|
||||
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
|
||||
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
|
||||
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
|
||||
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
|
||||
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
|
||||
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
|
||||
|
||||
# Rear ports
|
||||
@@ -274,10 +300,12 @@ urlpatterns = [
|
||||
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
|
||||
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
|
||||
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
|
||||
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
|
||||
path('rear-ports/<int:pk>/', views.RearPortView.as_view(), name='rearport'),
|
||||
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
|
||||
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
|
||||
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
|
||||
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
|
||||
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
|
||||
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
|
||||
|
||||
# Device bays
|
||||
@@ -287,8 +315,10 @@ urlpatterns = [
|
||||
path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
|
||||
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
|
||||
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
|
||||
path('device-bays/<int:pk>/', views.DeviceBayView.as_view(), name='devicebay'),
|
||||
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
|
||||
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
|
||||
path('device-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
|
||||
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
|
||||
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
|
||||
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
|
||||
@@ -298,10 +328,13 @@ urlpatterns = [
|
||||
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
|
||||
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
|
||||
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
|
||||
# TODO: Bulk rename view for InventoryItems
|
||||
path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'),
|
||||
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
|
||||
path('inventory-items/<int:pk>/', views.InventoryItemView.as_view(), name='inventoryitem'),
|
||||
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
|
||||
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
|
||||
path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
|
||||
path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
|
||||
|
||||
# Cables
|
||||
path('cables/', views.CableListView.as_view(), name='cable_list'),
|
||||
@@ -321,6 +354,7 @@ urlpatterns = [
|
||||
# Virtual chassis
|
||||
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
|
||||
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
|
||||
path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
|
||||
path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
|
||||
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
|
||||
path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'),
|
||||
@@ -332,7 +366,7 @@ urlpatterns = [
|
||||
|
||||
# Power panels
|
||||
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
|
||||
path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
|
||||
path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'),
|
||||
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
|
||||
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
|
||||
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
|
||||
@@ -343,7 +377,7 @@ urlpatterns = [
|
||||
|
||||
# Power feeds
|
||||
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
|
||||
path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
|
||||
path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
|
||||
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
|
||||
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
|
||||
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
|
||||
|
||||
1734
netbox/dcim/views.py
1734
netbox/dcim/views.py
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ from django import forms
|
||||
from django.contrib import admin
|
||||
|
||||
from utilities.forms import LaxURLField
|
||||
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
|
||||
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook
|
||||
from .reports import get_report
|
||||
|
||||
|
||||
@@ -228,27 +228,18 @@ class ExportTemplateAdmin(admin.ModelAdmin):
|
||||
# Reports
|
||||
#
|
||||
|
||||
@admin.register(ReportResult)
|
||||
class ReportResultAdmin(admin.ModelAdmin):
|
||||
@admin.register(JobResult)
|
||||
class JobResultAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'report', 'active', 'created', 'user', 'passing',
|
||||
'obj_type', 'name', 'created', 'completed', 'user', 'status',
|
||||
]
|
||||
fields = [
|
||||
'report', 'user', 'passing', 'data',
|
||||
'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
|
||||
]
|
||||
list_filter = [
|
||||
'failed',
|
||||
'status',
|
||||
]
|
||||
readonly_fields = fields
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def active(self, obj):
|
||||
module, report_name = obj.report.split('.')
|
||||
return True if get_report(module, report_name) else False
|
||||
active.boolean = True
|
||||
|
||||
def passing(self, obj):
|
||||
return not obj.failed
|
||||
passing.boolean = True
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from extras import models
|
||||
from utilities.api import WritableNestedSerializer
|
||||
from extras import choices, models
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
__all__ = [
|
||||
'NestedConfigContextSerializer',
|
||||
'NestedExportTemplateSerializer',
|
||||
'NestedGraphSerializer',
|
||||
'NestedReportResultSerializer',
|
||||
'NestedJobResultSerializer',
|
||||
'NestedTagSerializer',
|
||||
]
|
||||
|
||||
@@ -38,20 +39,19 @@ class NestedGraphSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedTagSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
tagged_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Tag
|
||||
fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
|
||||
fields = ['id', 'url', 'name', 'slug', 'color']
|
||||
|
||||
|
||||
class NestedReportResultSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:report-detail',
|
||||
lookup_field='report',
|
||||
lookup_url_kwarg='pk'
|
||||
class NestedJobResultSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
|
||||
status = ChoiceField(choices=choices.JobResultStatusChoices)
|
||||
user = NestedUserSerializer(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = models.ReportResult
|
||||
fields = ['url', 'created', 'user', 'failed']
|
||||
model = models.JobResult
|
||||
fields = ['url', 'created', 'completed', 'user', 'status']
|
||||
|
||||
@@ -9,9 +9,8 @@ from dcim.api.nested_serializers import (
|
||||
)
|
||||
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.models import (
|
||||
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
|
||||
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
|
||||
)
|
||||
from extras.utils import FeatureQuery
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
@@ -31,13 +30,14 @@ from .nested_serializers import *
|
||||
#
|
||||
|
||||
class GraphSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
|
||||
type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Graph
|
||||
fields = ['id', 'type', 'weight', 'name', 'template_language', 'source', 'link']
|
||||
fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link']
|
||||
|
||||
|
||||
class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
@@ -67,6 +67,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
#
|
||||
|
||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
|
||||
)
|
||||
@@ -78,7 +79,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = [
|
||||
'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
|
||||
'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
|
||||
'file_extension',
|
||||
]
|
||||
|
||||
@@ -88,11 +89,34 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class TagSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
|
||||
tagged_items = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
|
||||
fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
|
||||
|
||||
|
||||
class TaggedObjectSerializer(serializers.Serializer):
|
||||
tags = NestedTagSerializer(many=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
instance = super().create(validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set(*[t.name for t in tags])
|
||||
|
||||
return instance
|
||||
|
||||
|
||||
#
|
||||
@@ -100,6 +124,7 @@ class TagSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
@@ -108,7 +133,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ImageAttachment
|
||||
fields = [
|
||||
'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created',
|
||||
'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
|
||||
'created',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
@@ -147,6 +173,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
|
||||
regions = SerializedPKRelatedField(
|
||||
queryset=Region.objects.all(),
|
||||
serializer=NestedRegionSerializer,
|
||||
@@ -205,8 +232,29 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = ConfigContext
|
||||
fields = [
|
||||
'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
|
||||
'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
|
||||
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# Job Results
|
||||
#
|
||||
|
||||
class JobResultSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
|
||||
user = NestedUserSerializer(
|
||||
read_only=True
|
||||
)
|
||||
status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
|
||||
obj_type = ContentTypeField(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
|
||||
]
|
||||
|
||||
|
||||
@@ -214,23 +262,22 @@ class ConfigContextSerializer(ValidatedModelSerializer):
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportResultSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = ReportResult
|
||||
fields = ['created', 'user', 'failed', 'data']
|
||||
|
||||
|
||||
class ReportSerializer(serializers.Serializer):
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:report-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(max_length=255)
|
||||
description = serializers.CharField(max_length=255, required=False)
|
||||
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
|
||||
result = NestedReportResultSerializer()
|
||||
result = NestedJobResultSerializer()
|
||||
|
||||
|
||||
class ReportDetailSerializer(ReportSerializer):
|
||||
result = ReportResultSerializer()
|
||||
result = JobResultSerializer()
|
||||
|
||||
|
||||
#
|
||||
@@ -238,19 +285,17 @@ class ReportDetailSerializer(ReportSerializer):
|
||||
#
|
||||
|
||||
class ScriptSerializer(serializers.Serializer):
|
||||
id = serializers.SerializerMethodField(read_only=True)
|
||||
name = serializers.SerializerMethodField(read_only=True)
|
||||
description = serializers.SerializerMethodField(read_only=True)
|
||||
url = serializers.HyperlinkedIdentityField(
|
||||
view_name='extras-api:script-detail',
|
||||
lookup_field='full_name',
|
||||
lookup_url_kwarg='pk'
|
||||
)
|
||||
id = serializers.CharField(read_only=True, source="full_name")
|
||||
module = serializers.CharField(max_length=255)
|
||||
name = serializers.CharField(read_only=True)
|
||||
description = serializers.CharField(read_only=True)
|
||||
vars = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_id(self, instance):
|
||||
return '{}.{}'.format(instance.__module__, instance.__name__)
|
||||
|
||||
def get_name(self, instance):
|
||||
return getattr(instance.Meta, 'name', instance.__name__)
|
||||
|
||||
def get_description(self, instance):
|
||||
return getattr(instance.Meta, 'description', '')
|
||||
result = NestedJobResultSerializer()
|
||||
|
||||
def get_vars(self, instance):
|
||||
return {
|
||||
@@ -258,6 +303,10 @@ class ScriptSerializer(serializers.Serializer):
|
||||
}
|
||||
|
||||
|
||||
class ScriptDetailSerializer(ScriptSerializer):
|
||||
result = JobResultSerializer()
|
||||
|
||||
|
||||
class ScriptInputSerializer(serializers.Serializer):
|
||||
data = serializers.JSONField()
|
||||
commit = serializers.BooleanField()
|
||||
@@ -268,7 +317,7 @@ class ScriptLogMessageSerializer(serializers.Serializer):
|
||||
message = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_status(self, instance):
|
||||
return LOG_LEVEL_CODES.get(instance[0])
|
||||
return instance[0]
|
||||
|
||||
def get_message(self, instance):
|
||||
return instance[1]
|
||||
@@ -284,6 +333,7 @@ class ScriptOutputSerializer(serializers.Serializer):
|
||||
#
|
||||
|
||||
class ObjectChangeSerializer(serializers.ModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
|
||||
user = NestedUserSerializer(
|
||||
read_only=True
|
||||
)
|
||||
@@ -301,8 +351,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
fields = [
|
||||
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
|
||||
'changed_object', 'object_data',
|
||||
'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
|
||||
'changed_object_id', 'changed_object', 'object_data',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
|
||||
@@ -41,5 +41,8 @@ router.register('scripts', views.ScriptViewSet, basename='script')
|
||||
# Change logging
|
||||
router.register('object-changes', views.ObjectChangeViewSet)
|
||||
|
||||
# Job Results
|
||||
router.register('job-results', views.JobResultViewSet)
|
||||
|
||||
app_name = 'extras-api'
|
||||
urlpatterns = router.urls
|
||||
|
||||
@@ -3,20 +3,25 @@ from collections import OrderedDict
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count
|
||||
from django.http import Http404
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
|
||||
from rq import Worker
|
||||
|
||||
from extras import filters
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from extras.models import (
|
||||
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
|
||||
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
|
||||
)
|
||||
from extras.reports import get_report, get_reports
|
||||
from extras.reports import get_report, get_reports, run_report
|
||||
from extras.scripts import get_script, get_scripts, run_script
|
||||
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||
from utilities.exceptions import RQWorkerNotRunningException
|
||||
from utilities.metadata import ContentTypeMetadata
|
||||
from utilities.utils import copy_safe_request
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -112,8 +117,8 @@ class ExportTemplateViewSet(ModelViewSet):
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=Count('extras_taggeditem_items', distinct=True)
|
||||
)
|
||||
tagged_items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filters.TagFilterSet
|
||||
|
||||
@@ -169,13 +174,21 @@ class ReportViewSet(ViewSet):
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
report_list = []
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in JobResult.objects.filter(
|
||||
obj_type=report_content_type,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).defer('data')
|
||||
}
|
||||
|
||||
# Iterate through all available Reports.
|
||||
for module_name, reports in get_reports():
|
||||
for report in reports:
|
||||
|
||||
# Attach the relevant ReportResult (if any) to each Report.
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
|
||||
# Attach the relevant JobResult (if any) to each Report.
|
||||
report.result = results.get(report.full_name, None)
|
||||
report_list.append(report)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
@@ -189,29 +202,46 @@ class ReportViewSet(ViewSet):
|
||||
Retrieve a single Report identified as "<module>.<report>".
|
||||
"""
|
||||
|
||||
# Retrieve the Report and ReportResult, if any.
|
||||
# Retrieve the Report and JobResult, if any.
|
||||
report = self._retrieve_report(pk)
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
report.result = JobResult.objects.filter(
|
||||
obj_type=report_content_type,
|
||||
name=report.full_name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report)
|
||||
serializer = serializers.ReportDetailSerializer(report, context={
|
||||
'request': request
|
||||
})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def run(self, request, pk):
|
||||
"""
|
||||
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
|
||||
Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
|
||||
"""
|
||||
|
||||
# Check that the user has permission to run reports.
|
||||
if not request.user.has_perm('extras.add_reportresult'):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
raise PermissionDenied("This user does not have permission to run reports.")
|
||||
|
||||
# Retrieve and run the Report. This will create a new ReportResult.
|
||||
report = self._retrieve_report(pk)
|
||||
report.run()
|
||||
# Check that at least one RQ worker is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report)
|
||||
# Retrieve and run the Report. This will create a new JobResult.
|
||||
report = self._retrieve_report(pk)
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user
|
||||
)
|
||||
report.result = job_result
|
||||
|
||||
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
@@ -235,34 +265,68 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
def list(self, request):
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).defer('data').order_by('created')
|
||||
}
|
||||
|
||||
flat_list = []
|
||||
for script_list in get_scripts().values():
|
||||
flat_list.extend(script_list.values())
|
||||
|
||||
# Attach JobResult objects to each script (if any)
|
||||
for script in flat_list:
|
||||
script.result = results.get(script.full_name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
script = self._get_script(pk)
|
||||
serializer = serializers.ScriptSerializer(script, context={'request': request})
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
script.result = JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
name=script.full_name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request, pk):
|
||||
"""
|
||||
Run a Script identified as "<module>.<script>".
|
||||
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
|
||||
"""
|
||||
script = self._get_script(pk)()
|
||||
input_serializer = serializers.ScriptInputSerializer(data=request.data)
|
||||
|
||||
# Check that at least one RQ worker is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
raise RQWorkerNotRunningException()
|
||||
|
||||
if input_serializer.is_valid():
|
||||
data = input_serializer.data['data']
|
||||
commit = input_serializer.data['commit']
|
||||
script.output, execution_time = run_script(script, data, request, commit)
|
||||
output_serializer = serializers.ScriptOutputSerializer(script)
|
||||
|
||||
return Response(output_serializer.data)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_script,
|
||||
script.full_name,
|
||||
script_content_type,
|
||||
request.user,
|
||||
data=data,
|
||||
request=copy_safe_request(request),
|
||||
commit=commit
|
||||
)
|
||||
script.result = job_result
|
||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||
|
||||
return Response(serializer.data)
|
||||
|
||||
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
@@ -279,3 +343,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
|
||||
queryset = ObjectChange.objects.prefetch_related('user')
|
||||
serializer_class = serializers.ObjectChangeSerializer
|
||||
filterset_class = filters.ObjectChangeFilterSet
|
||||
|
||||
|
||||
#
|
||||
# Job Results
|
||||
#
|
||||
|
||||
class JobResultViewSet(ReadOnlyModelViewSet):
|
||||
"""
|
||||
Retrieve a list of job results
|
||||
"""
|
||||
queryset = JobResult.objects.prefetch_related('user')
|
||||
serializer_class = serializers.JobResultSerializer
|
||||
filterset_class = filters.JobResultFilterSet
|
||||
|
||||
@@ -23,15 +23,6 @@ class CustomFieldTypeChoices(ChoiceSet):
|
||||
(TYPE_SELECT, 'Selection'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
TYPE_TEXT: 100,
|
||||
TYPE_INTEGER: 200,
|
||||
TYPE_BOOLEAN: 300,
|
||||
TYPE_DATE: 400,
|
||||
TYPE_URL: 500,
|
||||
TYPE_SELECT: 600,
|
||||
}
|
||||
|
||||
|
||||
class CustomFieldFilterLogicChoices(ChoiceSet):
|
||||
|
||||
@@ -45,12 +36,6 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
|
||||
(FILTER_EXACT, 'Exact'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
FILTER_DISABLED: 0,
|
||||
FILTER_LOOSE: 1,
|
||||
FILTER_EXACT: 2,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CustomLinks
|
||||
@@ -93,12 +78,6 @@ class ObjectChangeActionChoices(ChoiceSet):
|
||||
(ACTION_DELETE, 'Deleted'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
ACTION_CREATE: 1,
|
||||
ACTION_UPDATE: 2,
|
||||
ACTION_DELETE: 3,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# ExportTemplates
|
||||
@@ -114,10 +93,61 @@ class TemplateLanguageChoices(ChoiceSet):
|
||||
(LANGUAGE_JINJA2, 'Jinja2'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
LANGUAGE_DJANGO: 10,
|
||||
LANGUAGE_JINJA2: 20,
|
||||
}
|
||||
|
||||
#
|
||||
# Log Levels for Reports and Scripts
|
||||
#
|
||||
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
|
||||
LOG_DEFAULT = 'default'
|
||||
LOG_SUCCESS = 'success'
|
||||
LOG_INFO = 'info'
|
||||
LOG_WARNING = 'warning'
|
||||
LOG_FAILURE = 'failure'
|
||||
|
||||
CHOICES = (
|
||||
(LOG_DEFAULT, 'Default'),
|
||||
(LOG_SUCCESS, 'Success'),
|
||||
(LOG_INFO, 'Info'),
|
||||
(LOG_WARNING, 'Warning'),
|
||||
(LOG_FAILURE, 'Failure'),
|
||||
)
|
||||
|
||||
CLASS_MAP = (
|
||||
(LOG_DEFAULT, 'default'),
|
||||
(LOG_SUCCESS, 'success'),
|
||||
(LOG_INFO, 'info'),
|
||||
(LOG_WARNING, 'warning'),
|
||||
(LOG_FAILURE, 'danger'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Job results
|
||||
#
|
||||
|
||||
class JobResultStatusChoices(ChoiceSet):
|
||||
|
||||
STATUS_PENDING = 'pending'
|
||||
STATUS_RUNNING = 'running'
|
||||
STATUS_COMPLETED = 'completed'
|
||||
STATUS_ERRORED = 'errored'
|
||||
STATUS_FAILED = 'failed'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_PENDING, 'Pending'),
|
||||
(STATUS_RUNNING, 'Running'),
|
||||
(STATUS_COMPLETED, 'Completed'),
|
||||
(STATUS_ERRORED, 'Errored'),
|
||||
(STATUS_FAILED, 'Failed'),
|
||||
)
|
||||
|
||||
TERMINAL_STATE_CHOICES = (
|
||||
STATUS_COMPLETED,
|
||||
STATUS_ERRORED,
|
||||
STATUS_FAILED,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
# Report logging levels
|
||||
LOG_DEFAULT = 0
|
||||
LOG_SUCCESS = 10
|
||||
LOG_INFO = 20
|
||||
LOG_WARNING = 30
|
||||
LOG_FAILURE = 40
|
||||
LOG_LEVEL_CODES = {
|
||||
LOG_DEFAULT: 'default',
|
||||
LOG_SUCCESS: 'success',
|
||||
LOG_INFO: 'info',
|
||||
LOG_WARNING: 'warning',
|
||||
LOG_FAILURE: 'failure',
|
||||
}
|
||||
|
||||
# Webhook content types
|
||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
@@ -19,7 +5,8 @@ HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
EXTRAS_FEATURES = [
|
||||
'custom_fields',
|
||||
'custom_links',
|
||||
'graphs',
|
||||
'export_templates',
|
||||
'graphs',
|
||||
'job_results',
|
||||
'webhooks'
|
||||
]
|
||||
|
||||
@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.filters import BaseFilterSet
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -287,3 +287,33 @@ class CreatedUpdatedFilterSet(django_filters.FilterSet):
|
||||
field_name='last_updated',
|
||||
lookup_expr='lte'
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Job Results
|
||||
#
|
||||
|
||||
class JobResultFilterSet(BaseFilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
created = django_filters.DateTimeFilter()
|
||||
completed = django_filters.DateTimeFilter()
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=JobResultStatusChoices,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JobResult
|
||||
fields = [
|
||||
'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(user__username__icontains=value)
|
||||
)
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
from taggit.forms import TagField as TagField_
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
|
||||
ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
|
||||
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from .choices import *
|
||||
@@ -142,15 +141,6 @@ class CustomFieldFilterForm(forms.Form):
|
||||
# Tags
|
||||
#
|
||||
|
||||
class TagField(TagField_):
|
||||
|
||||
def widget_attrs(self, widget):
|
||||
# Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
|
||||
return {
|
||||
'class': 'tagfield'
|
||||
}
|
||||
|
||||
|
||||
class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
@@ -161,6 +151,17 @@ class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
]
|
||||
|
||||
|
||||
class TagCSVForm(CSVModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = Tag.csv_headers
|
||||
help_texts = {
|
||||
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
|
||||
}
|
||||
|
||||
|
||||
class AddRemoveTagsForm(forms.Form):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -209,10 +210,9 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
#
|
||||
|
||||
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
regions = TreeNodeMultipleChoiceField(
|
||||
regions = DynamicModelMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
widget=StaticSelect2Multiple()
|
||||
required=False
|
||||
)
|
||||
sites = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
@@ -410,11 +410,13 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
# TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
|
||||
user = forms.ModelChoiceField(
|
||||
queryset=User.objects.order_by('username'),
|
||||
user = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
display_field='username'
|
||||
)
|
||||
)
|
||||
changed_object_type = forms.ModelChoiceField(
|
||||
queryset=ContentType.objects.order_by('model'),
|
||||
|
||||
@@ -16,7 +16,6 @@ class Migration(migrations.Migration):
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
],
|
||||
options={
|
||||
'permissions': (('run_script', 'Can run script'),),
|
||||
'managed': False,
|
||||
},
|
||||
),
|
||||
|
||||
22
netbox/extras/migrations/0043_report.py
Normal file
22
netbox/extras/migrations/0043_report.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 3.0.7 on 2020-06-23 02:28
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0042_customfield_manager'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Report',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
],
|
||||
options={
|
||||
'managed': False,
|
||||
},
|
||||
)
|
||||
]
|
||||
75
netbox/extras/migrations/0044_jobresult.py
Normal file
75
netbox/extras/migrations/0044_jobresult.py
Normal file
@@ -0,0 +1,75 @@
|
||||
import uuid
|
||||
|
||||
import django.contrib.postgres.fields.jsonb
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import extras.utils
|
||||
from extras.choices import JobResultStatusChoices
|
||||
|
||||
|
||||
def convert_job_results(apps, schema_editor):
|
||||
"""
|
||||
Convert ReportResult objects to JobResult objects
|
||||
"""
|
||||
Report = apps.get_model('extras', 'Report')
|
||||
ReportResult = apps.get_model('extras', 'ReportResult')
|
||||
JobResult = apps.get_model('extras', 'JobResult')
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
report_content_type = ContentType.objects.get_for_model(Report)
|
||||
|
||||
job_results = []
|
||||
for report_result in ReportResult.objects.all():
|
||||
if report_result.failed:
|
||||
status = JobResultStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
status = JobResultStatusChoices.STATUS_COMPLETED
|
||||
job_results.append(
|
||||
JobResult(
|
||||
name=report_result.report,
|
||||
obj_type=report_content_type,
|
||||
created=report_result.created,
|
||||
completed=report_result.created,
|
||||
user=report_result.user,
|
||||
status=status,
|
||||
data=report_result.data,
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
)
|
||||
JobResult.objects.bulk_create(job_results)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('extras', '0043_report'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='JobResult',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=255)),
|
||||
('created', models.DateTimeField(auto_now_add=True)),
|
||||
('completed', models.DateTimeField(blank=True, null=True)),
|
||||
('status', models.CharField(default='pending', max_length=30)),
|
||||
('data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
|
||||
('job_id', models.UUIDField(unique=True)),
|
||||
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('job_results'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.ContentType')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['obj_type', 'name', '-created'],
|
||||
},
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=convert_job_results
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ReportResult'
|
||||
)
|
||||
]
|
||||
23
netbox/extras/migrations/0045_configcontext_changelog.py
Normal file
23
netbox/extras/migrations/0045_configcontext_changelog.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 3.0.6 on 2020-07-09 20:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0044_jobresult'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='created',
|
||||
field=models.DateField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='configcontext',
|
||||
name='last_updated',
|
||||
field=models.DateTimeField(auto_now=True, null=True),
|
||||
),
|
||||
]
|
||||
28
netbox/extras/migrations/0046_update_jsonfield.py
Normal file
28
netbox/extras/migrations/0046_update_jsonfield.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 3.1b1 on 2020-07-16 16:01
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0045_configcontext_changelog'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='configcontext',
|
||||
name='data',
|
||||
field=models.JSONField(),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='jobresult',
|
||||
name='data',
|
||||
field=models.JSONField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='objectchange',
|
||||
name='object_data',
|
||||
field=models.JSONField(editable=False),
|
||||
),
|
||||
]
|
||||
17
netbox/extras/migrations/0047_tag_ordering.py
Normal file
17
netbox/extras/migrations/0047_tag_ordering.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 3.1rc1 on 2020-07-23 18:22
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0046_update_jsonfield'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='tag',
|
||||
options={'ordering': ['name']},
|
||||
),
|
||||
]
|
||||
@@ -1,11 +1,13 @@
|
||||
from .change_logging import ChangeLoggedModel, ObjectChange
|
||||
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
|
||||
from .models import (
|
||||
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
|
||||
Script, Webhook,
|
||||
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, Report, Script,
|
||||
Webhook,
|
||||
)
|
||||
from .tags import Tag, TaggedItem
|
||||
|
||||
__all__ = (
|
||||
'ChangeLoggedModel',
|
||||
'ConfigContext',
|
||||
'ConfigContextModel',
|
||||
'CustomField',
|
||||
@@ -16,8 +18,9 @@ __all__ = (
|
||||
'ExportTemplate',
|
||||
'Graph',
|
||||
'ImageAttachment',
|
||||
'JobResult',
|
||||
'ObjectChange',
|
||||
'ReportResult',
|
||||
'Report',
|
||||
'Script',
|
||||
'Tag',
|
||||
'TaggedItem',
|
||||
|
||||
154
netbox/extras/models/change_logging.py
Normal file
154
netbox/extras/models/change_logging.py
Normal file
@@ -0,0 +1,154 @@
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import serialize_object
|
||||
from extras.choices import *
|
||||
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ChangeLoggedModel(models.Model):
|
||||
"""
|
||||
An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
|
||||
null to facilitate adding these fields to existing instances via a database migration.
|
||||
"""
|
||||
created = models.DateField(
|
||||
auto_now_add=True,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
last_updated = models.DateTimeField(
|
||||
auto_now=True,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def to_objectchange(self, action):
|
||||
"""
|
||||
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
|
||||
by extras.middleware.ChangeLoggingMiddleware.
|
||||
"""
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
object_data=serialize_object(self)
|
||||
)
|
||||
|
||||
|
||||
class ObjectChange(models.Model):
|
||||
"""
|
||||
Record a change to an object and the user account associated with that change. A change record may optionally
|
||||
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
|
||||
parent device. This will ensure changes made to component models appear in the parent model's changelog.
|
||||
"""
|
||||
time = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
db_index=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='changes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
user_name = models.CharField(
|
||||
max_length=150,
|
||||
editable=False
|
||||
)
|
||||
request_id = models.UUIDField(
|
||||
editable=False
|
||||
)
|
||||
action = models.CharField(
|
||||
max_length=50,
|
||||
choices=ObjectChangeActionChoices
|
||||
)
|
||||
changed_object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
changed_object_id = models.PositiveIntegerField()
|
||||
changed_object = GenericForeignKey(
|
||||
ct_field='changed_object_type',
|
||||
fk_field='changed_object_id'
|
||||
)
|
||||
related_object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
related_object_id = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
related_object = GenericForeignKey(
|
||||
ct_field='related_object_type',
|
||||
fk_field='related_object_id'
|
||||
)
|
||||
object_repr = models.CharField(
|
||||
max_length=200,
|
||||
editable=False
|
||||
)
|
||||
object_data = models.JSONField(
|
||||
editable=False
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = [
|
||||
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
|
||||
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['-time']
|
||||
|
||||
def __str__(self):
|
||||
return '{} {} {} by {}'.format(
|
||||
self.changed_object_type,
|
||||
self.object_repr,
|
||||
self.get_action_display().lower(),
|
||||
self.user_name
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Record the user's name and the object's representation as static strings
|
||||
if not self.user_name:
|
||||
self.user_name = self.user.username
|
||||
if not self.object_repr:
|
||||
self.object_repr = str(self.changed_object)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:objectchange', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.time,
|
||||
self.user,
|
||||
self.user_name,
|
||||
self.request_id,
|
||||
self.get_action_display(),
|
||||
self.changed_object_type,
|
||||
self.changed_object_id,
|
||||
self.related_object_type,
|
||||
self.related_object_id,
|
||||
self.object_repr,
|
||||
self.object_data,
|
||||
)
|
||||
@@ -1,22 +1,25 @@
|
||||
import json
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib.postgres.fields import JSONField
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from utilities.utils import deepmerge, render_jinja2
|
||||
from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.models import ChangeLoggedModel
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from extras.utils import extras_features, FeatureQuery, image_upload
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import deepmerge, render_jinja2
|
||||
|
||||
|
||||
#
|
||||
@@ -231,6 +234,8 @@ class Graph(models.Model):
|
||||
verbose_name='Link URL'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique
|
||||
|
||||
@@ -298,6 +303,8 @@ class ExportTemplate(models.Model):
|
||||
help_text='Extension to append to the rendered filename'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['content_type', 'name']
|
||||
unique_together = [
|
||||
@@ -426,7 +433,7 @@ class ImageAttachment(models.Model):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContext(models.Model):
|
||||
class ConfigContext(ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
@@ -491,7 +498,7 @@ class ConfigContext(models.Model):
|
||||
related_name='+',
|
||||
blank=True
|
||||
)
|
||||
data = JSONField()
|
||||
data = models.JSONField()
|
||||
|
||||
objects = ConfigContextQuerySet.as_manager()
|
||||
|
||||
@@ -518,7 +525,7 @@ class ConfigContextModel(models.Model):
|
||||
A model which includes local configuration context data. This local data will override any inherited data from
|
||||
ConfigContexts.
|
||||
"""
|
||||
local_context_data = JSONField(
|
||||
local_context_data = models.JSONField(
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
@@ -557,32 +564,56 @@ class ConfigContextModel(models.Model):
|
||||
# Custom scripts
|
||||
#
|
||||
|
||||
@extras_features('job_results')
|
||||
class Script(models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
managed = False
|
||||
permissions = (
|
||||
('run_script', 'Can run script'),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Report results
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportResult(models.Model):
|
||||
@extras_features('job_results')
|
||||
class Report(models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
managed = False
|
||||
|
||||
|
||||
#
|
||||
# Job results
|
||||
#
|
||||
|
||||
class JobResult(models.Model):
|
||||
"""
|
||||
This model stores the results from running a user-defined report.
|
||||
"""
|
||||
report = models.CharField(
|
||||
max_length=255,
|
||||
unique=True
|
||||
name = models.CharField(
|
||||
max_length=255
|
||||
)
|
||||
obj_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
related_name='job_results',
|
||||
verbose_name='Object types',
|
||||
limit_choices_to=FeatureQuery('job_results'),
|
||||
help_text="The object type to which this job result applies.",
|
||||
on_delete=models.CASCADE,
|
||||
)
|
||||
created = models.DateTimeField(
|
||||
auto_now_add=True
|
||||
)
|
||||
completed = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=User,
|
||||
on_delete=models.SET_NULL,
|
||||
@@ -590,126 +621,65 @@ class ReportResult(models.Model):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
failed = models.BooleanField()
|
||||
data = JSONField()
|
||||
status = models.CharField(
|
||||
max_length=30,
|
||||
choices=JobResultStatusChoices,
|
||||
default=JobResultStatusChoices.STATUS_PENDING
|
||||
)
|
||||
data = models.JSONField(
|
||||
null=True,
|
||||
blank=True
|
||||
)
|
||||
job_id = models.UUIDField(
|
||||
unique=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['report']
|
||||
ordering = ['obj_type', 'name', '-created']
|
||||
|
||||
def __str__(self):
|
||||
return "{} {} at {}".format(
|
||||
self.report,
|
||||
"passed" if not self.failed else "failed",
|
||||
self.created
|
||||
return str(self.job_id)
|
||||
|
||||
@property
|
||||
def duration(self):
|
||||
if not self.completed:
|
||||
return None
|
||||
|
||||
duration = self.completed - self.created
|
||||
minutes, seconds = divmod(duration.total_seconds(), 60)
|
||||
|
||||
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
|
||||
|
||||
def set_status(self, status):
|
||||
"""
|
||||
Helper method to change the status of the job result and save. If the target status is terminal, the
|
||||
completion time is also set.
|
||||
"""
|
||||
self.status = status
|
||||
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
self.completed = timezone.now()
|
||||
|
||||
self.save()
|
||||
|
||||
@classmethod
|
||||
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
|
||||
"""
|
||||
Create a JobResult instance and enqueue a job using the given callable
|
||||
|
||||
func: The callable object to be enqueued for execution
|
||||
name: Name for the JobResult instance
|
||||
obj_type: ContentType to link to the JobResult instance obj_type
|
||||
user: User object to link to the JobResult instance
|
||||
args: additional args passed to the callable
|
||||
kwargs: additional kargs passed to the callable
|
||||
"""
|
||||
job_result = cls.objects.create(
|
||||
name=name,
|
||||
obj_type=obj_type,
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
|
||||
func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
|
||||
|
||||
#
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ObjectChange(models.Model):
|
||||
"""
|
||||
Record a change to an object and the user account associated with that change. A change record may optionally
|
||||
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
|
||||
parent device. This will ensure changes made to component models appear in the parent model's changelog.
|
||||
"""
|
||||
time = models.DateTimeField(
|
||||
auto_now_add=True,
|
||||
editable=False,
|
||||
db_index=True
|
||||
)
|
||||
user = models.ForeignKey(
|
||||
to=User,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='changes',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
user_name = models.CharField(
|
||||
max_length=150,
|
||||
editable=False
|
||||
)
|
||||
request_id = models.UUIDField(
|
||||
editable=False
|
||||
)
|
||||
action = models.CharField(
|
||||
max_length=50,
|
||||
choices=ObjectChangeActionChoices
|
||||
)
|
||||
changed_object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+'
|
||||
)
|
||||
changed_object_id = models.PositiveIntegerField()
|
||||
changed_object = GenericForeignKey(
|
||||
ct_field='changed_object_type',
|
||||
fk_field='changed_object_id'
|
||||
)
|
||||
related_object_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.PROTECT,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
related_object_id = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
related_object = GenericForeignKey(
|
||||
ct_field='related_object_type',
|
||||
fk_field='related_object_id'
|
||||
)
|
||||
object_repr = models.CharField(
|
||||
max_length=200,
|
||||
editable=False
|
||||
)
|
||||
object_data = JSONField(
|
||||
editable=False
|
||||
)
|
||||
|
||||
csv_headers = [
|
||||
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
|
||||
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['-time']
|
||||
|
||||
def __str__(self):
|
||||
return '{} {} {} by {}'.format(
|
||||
self.changed_object_type,
|
||||
self.object_repr,
|
||||
self.get_action_display().lower(),
|
||||
self.user_name
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Record the user's name and the object's representation as static strings
|
||||
if not self.user_name:
|
||||
self.user_name = self.user.username
|
||||
if not self.object_repr:
|
||||
self.object_repr = str(self.changed_object)
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:objectchange', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.time,
|
||||
self.user,
|
||||
self.user_name,
|
||||
self.request_id,
|
||||
self.get_action_display(),
|
||||
self.changed_object_type,
|
||||
self.changed_object_id,
|
||||
self.related_object_type,
|
||||
self.related_object_id,
|
||||
self.object_repr,
|
||||
self.object_data,
|
||||
)
|
||||
return job_result
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from extras.models import ChangeLoggedModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
#
|
||||
@@ -21,8 +21,12 @@ class Tag(TagBase, ChangeLoggedModel):
|
||||
blank=True,
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tag', args=[self.slug])
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
csv_headers = ['name', 'slug', 'color', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
def slugify(self, tag, i=None):
|
||||
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
|
||||
@@ -31,6 +35,14 @@ class Tag(TagBase, ChangeLoggedModel):
|
||||
slug += "_%d" % i
|
||||
return slug
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.name,
|
||||
self.slug,
|
||||
self.color,
|
||||
self.description
|
||||
)
|
||||
|
||||
|
||||
class TaggedItem(GenericTaggedItemBase):
|
||||
tag = models.ForeignKey(
|
||||
|
||||
@@ -2,6 +2,8 @@ from collections import OrderedDict
|
||||
|
||||
from django.db.models import Q, QuerySet
|
||||
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
class CustomFieldQueryset:
|
||||
"""
|
||||
@@ -19,7 +21,7 @@ class CustomFieldQueryset:
|
||||
yield obj
|
||||
|
||||
|
||||
class ConfigContextQuerySet(QuerySet):
|
||||
class ConfigContextQuerySet(RestrictedQuerySet):
|
||||
|
||||
def get_for_object(self, obj):
|
||||
"""
|
||||
|
||||
@@ -5,10 +5,15 @@ import pkgutil
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
from django_rq import job
|
||||
|
||||
from .constants import *
|
||||
from .models import ReportResult
|
||||
from .choices import JobResultStatusChoices, LogLevelChoices
|
||||
from .models import JobResult
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def is_report(obj):
|
||||
@@ -60,6 +65,32 @@ def get_reports():
|
||||
return module_list
|
||||
|
||||
|
||||
@job('default')
|
||||
def run_report(job_result, *args, **kwargs):
|
||||
"""
|
||||
Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
|
||||
method for queueing into the background processor.
|
||||
"""
|
||||
module_name, report_name = job_result.name.split('.', 1)
|
||||
report = get_report(module_name, report_name)
|
||||
|
||||
try:
|
||||
report.run(job_result)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
logging.error(f"Error during execution of report {job_result.name}")
|
||||
|
||||
# Delete any previous terminal state results
|
||||
JobResult.objects.filter(
|
||||
obj_type=job_result.obj_type,
|
||||
name=job_result.name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).exclude(
|
||||
pk=job_result.pk
|
||||
).delete()
|
||||
|
||||
|
||||
class Report(object):
|
||||
"""
|
||||
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
|
||||
@@ -115,22 +146,29 @@ class Report(object):
|
||||
return self.__module__
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
def class_name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
return '.'.join([self.__module__, self.__class__.__name__])
|
||||
def name(self):
|
||||
"""
|
||||
Override this attribute to set a custom display name.
|
||||
"""
|
||||
return self.class_name
|
||||
|
||||
def _log(self, obj, message, level=LOG_DEFAULT):
|
||||
@property
|
||||
def full_name(self):
|
||||
return f'{self.module}.{self.class_name}'
|
||||
|
||||
def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
|
||||
"""
|
||||
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
if level not in LOG_LEVEL_CODES:
|
||||
if level not in LogLevelChoices.as_dict():
|
||||
raise Exception("Unknown logging level: {}".format(level))
|
||||
self._results[self.active_test]['log'].append((
|
||||
timezone.now().isoformat(),
|
||||
LOG_LEVEL_CODES.get(level),
|
||||
level,
|
||||
str(obj) if obj else None,
|
||||
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
|
||||
message,
|
||||
@@ -140,7 +178,7 @@ class Report(object):
|
||||
"""
|
||||
Log a message which is not associated with a particular object.
|
||||
"""
|
||||
self._log(None, message, level=LOG_DEFAULT)
|
||||
self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
|
||||
self.logger.info(message)
|
||||
|
||||
def log_success(self, obj, message=None):
|
||||
@@ -148,7 +186,7 @@ class Report(object):
|
||||
Record a successful test against an object. Logging a message is optional.
|
||||
"""
|
||||
if message:
|
||||
self._log(obj, message, level=LOG_SUCCESS)
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
|
||||
self._results[self.active_test]['success'] += 1
|
||||
self.logger.info(f"Success | {obj}: {message}")
|
||||
|
||||
@@ -156,7 +194,7 @@ class Report(object):
|
||||
"""
|
||||
Log an informational message.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_INFO)
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_INFO)
|
||||
self._results[self.active_test]['info'] += 1
|
||||
self.logger.info(f"Info | {obj}: {message}")
|
||||
|
||||
@@ -164,7 +202,7 @@ class Report(object):
|
||||
"""
|
||||
Log a warning.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_WARNING)
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
|
||||
self._results[self.active_test]['warning'] += 1
|
||||
self.logger.info(f"Warning | {obj}: {message}")
|
||||
|
||||
@@ -172,32 +210,34 @@ class Report(object):
|
||||
"""
|
||||
Log a failure. Calling this method will automatically mark the report as failed.
|
||||
"""
|
||||
self._log(obj, message, level=LOG_FAILURE)
|
||||
self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
|
||||
self._results[self.active_test]['failure'] += 1
|
||||
self.logger.info(f"Failure | {obj}: {message}")
|
||||
self.failed = True
|
||||
|
||||
def run(self):
|
||||
def run(self, job_result):
|
||||
"""
|
||||
Run the report and return its results. Each test method will be executed in order.
|
||||
Run the report and save its results. Each test method will be executed in order.
|
||||
"""
|
||||
self.logger.info(f"Running report")
|
||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
|
||||
for method_name in self.test_methods:
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
|
||||
# Delete any previous ReportResult and create a new one to record the result.
|
||||
ReportResult.objects.filter(report=self.full_name).delete()
|
||||
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
|
||||
result.save()
|
||||
self.result = result
|
||||
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job_result.status = JobResultStatusChoices.STATUS_FAILED
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
|
||||
|
||||
job_result.data = self._results
|
||||
job_result.completed = timezone.now()
|
||||
job_result.save()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import pkgutil
|
||||
import time
|
||||
import traceback
|
||||
from collections import OrderedDict
|
||||
|
||||
@@ -12,12 +11,14 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.validators import RegexValidator
|
||||
from django.db import transaction
|
||||
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
|
||||
from mptt.models import MPTTModel
|
||||
from django.utils.functional import classproperty
|
||||
from django_rq import job
|
||||
|
||||
from extras.api.serializers import ScriptOutputSerializer
|
||||
from extras.choices import JobResultStatusChoices, LogLevelChoices
|
||||
from extras.models import JobResult
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .forms import ScriptForm
|
||||
@@ -177,10 +178,6 @@ class ObjectVar(ScriptVariable):
|
||||
# Queryset for field choices
|
||||
self.field_attrs['queryset'] = queryset
|
||||
|
||||
# Update form field for MPTT (nested) objects
|
||||
if issubclass(queryset.model, MPTTModel):
|
||||
self.form_field = TreeNodeChoiceField
|
||||
|
||||
|
||||
class MultiObjectVar(ScriptVariable):
|
||||
"""
|
||||
@@ -194,10 +191,6 @@ class MultiObjectVar(ScriptVariable):
|
||||
# Queryset for field choices
|
||||
self.field_attrs['queryset'] = queryset
|
||||
|
||||
# Update form field for MPTT (nested) objects
|
||||
if issubclass(queryset.model, MPTTModel):
|
||||
self.form_field = TreeNodeMultipleChoiceField
|
||||
|
||||
|
||||
class FileVar(ScriptVariable):
|
||||
"""
|
||||
@@ -267,8 +260,20 @@ class BaseScript:
|
||||
self.source = inspect.getsource(self.__class__)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classproperty
|
||||
def name(self):
|
||||
return getattr(self.Meta, 'name', self.__class__.__name__)
|
||||
|
||||
@classproperty
|
||||
def full_name(self):
|
||||
return '.'.join([self.__module__, self.__name__])
|
||||
|
||||
@classproperty
|
||||
def description(self):
|
||||
return getattr(self.Meta, 'description', '')
|
||||
|
||||
@classmethod
|
||||
def module(cls):
|
||||
return cls.__module__
|
||||
@@ -306,23 +311,23 @@ class BaseScript:
|
||||
|
||||
def log_debug(self, message):
|
||||
self.logger.log(logging.DEBUG, message)
|
||||
self.log.append((LOG_DEFAULT, message))
|
||||
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
|
||||
|
||||
def log_success(self, message):
|
||||
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
|
||||
self.log.append((LOG_SUCCESS, message))
|
||||
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
|
||||
|
||||
def log_info(self, message):
|
||||
self.logger.log(logging.INFO, message)
|
||||
self.log.append((LOG_INFO, message))
|
||||
self.log.append((LogLevelChoices.LOG_INFO, message))
|
||||
|
||||
def log_warning(self, message):
|
||||
self.logger.log(logging.WARNING, message)
|
||||
self.log.append((LOG_WARNING, message))
|
||||
self.log.append((LogLevelChoices.LOG_WARNING, message))
|
||||
|
||||
def log_failure(self, message):
|
||||
self.logger.log(logging.ERROR, message)
|
||||
self.log.append((LOG_FAILURE, message))
|
||||
self.log.append((LogLevelChoices.LOG_FAILURE, message))
|
||||
|
||||
# Convenience functions
|
||||
|
||||
@@ -375,17 +380,21 @@ def is_variable(obj):
|
||||
return isinstance(obj, ScriptVariable)
|
||||
|
||||
|
||||
def run_script(script, data, request, commit=True):
|
||||
@job('default')
|
||||
def run_script(data, request, commit=True, *args, **kwargs):
|
||||
"""
|
||||
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
|
||||
exists outside of the Script class to ensure it cannot be overridden by a script author.
|
||||
"""
|
||||
output = None
|
||||
start_time = None
|
||||
end_time = None
|
||||
job_result = kwargs.pop('job_result')
|
||||
module, script_name = job_result.name.split('.', 1)
|
||||
|
||||
script_name = script.__class__.__name__
|
||||
logger = logging.getLogger(f"netbox.scripts.{script.module()}.{script_name}")
|
||||
script = get_script(module, script_name)()
|
||||
|
||||
job_result.status = JobResultStatusChoices.STATUS_RUNNING
|
||||
job_result.save()
|
||||
|
||||
logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
|
||||
logger.info(f"Running script (commit={commit})")
|
||||
|
||||
# Add files to form data
|
||||
@@ -405,13 +414,14 @@ def run_script(script, data, request, commit=True):
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
start_time = time.time()
|
||||
output = script.run(**kwargs)
|
||||
end_time = time.time()
|
||||
script.output = script.run(**kwargs)
|
||||
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
||||
except AbortTransaction:
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
script.log_failure(
|
||||
@@ -419,7 +429,13 @@ def run_script(script, data, request, commit=True):
|
||||
)
|
||||
logger.error(f"Exception raised during script execution: {e}")
|
||||
commit = False
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
|
||||
|
||||
finally:
|
||||
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
|
||||
job_result.data = ScriptOutputSerializer(script).data
|
||||
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
if not commit:
|
||||
# Delete all pending changelog entries
|
||||
purge_changelog.send(Script)
|
||||
@@ -427,14 +443,16 @@ def run_script(script, data, request, commit=True):
|
||||
"Database changes have been reverted automatically."
|
||||
)
|
||||
|
||||
# Calculate execution time
|
||||
if end_time is not None:
|
||||
execution_time = end_time - start_time
|
||||
logger.info(f"Script completed in {execution_time:.4f} seconds")
|
||||
else:
|
||||
execution_time = None
|
||||
logger.info(f"Script completed in {job_result.duration}")
|
||||
|
||||
return output, execution_time
|
||||
# Delete any previous terminal state results
|
||||
JobResult.objects.filter(
|
||||
obj_type=job_result.obj_type,
|
||||
name=job_result.name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).exclude(
|
||||
pk=job_result.pk
|
||||
).delete()
|
||||
|
||||
|
||||
def get_scripts(use_names=False):
|
||||
|
||||
@@ -1,21 +1,8 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn
|
||||
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
|
||||
|
||||
TAG_ACTIONS = """
|
||||
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.taggit.change_tag %}
|
||||
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% if perms.taggit.delete_tag %}
|
||||
<a href="{% url 'extras:tag_delete' slug=record.slug %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TAGGED_ITEM = """
|
||||
{% if value.get_absolute_url %}
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
@@ -64,16 +51,8 @@ OBJECTCHANGE_REQUEST_ID = """
|
||||
|
||||
class TagTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn(
|
||||
viewname='extras:tag',
|
||||
args=[Accessor('slug')]
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=TAG_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(Tag, pk_field='slug')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Tag
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django import template
|
||||
|
||||
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
|
||||
from extras.choices import LogLevelChoices
|
||||
|
||||
|
||||
register = template.Library()
|
||||
@@ -11,27 +11,7 @@ def log_level(level):
|
||||
"""
|
||||
Display a label indicating a syslog severity (e.g. info, warning, etc.).
|
||||
"""
|
||||
levels = {
|
||||
LOG_DEFAULT: {
|
||||
'name': 'Default',
|
||||
'class': 'default'
|
||||
},
|
||||
LOG_SUCCESS: {
|
||||
'name': 'Success',
|
||||
'class': 'success',
|
||||
},
|
||||
LOG_INFO: {
|
||||
'name': 'Info',
|
||||
'class': 'info'
|
||||
},
|
||||
LOG_WARNING: {
|
||||
'name': 'Warning',
|
||||
'class': 'warning'
|
||||
},
|
||||
LOG_FAILURE: {
|
||||
'name': 'Failure',
|
||||
'class': 'danger'
|
||||
}
|
||||
return {
|
||||
'name': LogLevelChoices.as_dict()[level],
|
||||
'class': dict(LogLevelChoices.CLASS_MAP)[level]
|
||||
}
|
||||
|
||||
return levels[level]
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import datetime
|
||||
from unittest import skipIf
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django_rq.queues import get_connection
|
||||
from rest_framework import status
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
|
||||
from extras.api.views import ScriptViewSet
|
||||
from extras.api.views import ReportViewSet, ScriptViewSet
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||
from extras.reports import Report
|
||||
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
|
||||
from utilities.testing import APITestCase, APIViewTestCases
|
||||
|
||||
|
||||
rq_worker_running = Worker.count(get_connection('default'))
|
||||
|
||||
|
||||
class AppTest(APITestCase):
|
||||
|
||||
def test_root(self):
|
||||
@@ -102,7 +109,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
|
||||
class TagTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Tag
|
||||
brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url']
|
||||
brief_fields = ['color', 'id', 'name', 'slug', 'url']
|
||||
create_data = [
|
||||
{
|
||||
'name': 'Tag 4',
|
||||
@@ -207,6 +214,39 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
|
||||
self.assertEqual(rendered_context['bar'], 456)
|
||||
|
||||
|
||||
class ReportTest(APITestCase):
|
||||
|
||||
class TestReport(Report):
|
||||
|
||||
def test_foo(self):
|
||||
self.log_success(None, "Report completed")
|
||||
|
||||
def get_test_report(self, *args):
|
||||
return self.TestReport()
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
# Monkey-patch the API viewset's _get_script method to return our test script above
|
||||
ReportViewSet._retrieve_report = self.get_test_report
|
||||
|
||||
def test_get_report(self):
|
||||
url = reverse('extras-api:report-detail', kwargs={'pk': None})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.TestReport.__name__)
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_report(self):
|
||||
self.add_permissions('extras.run_script')
|
||||
|
||||
url = reverse('extras-api:report-run', kwargs={'pk': None})
|
||||
response = self.client.post(url, {}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class ScriptTest(APITestCase):
|
||||
|
||||
class TestScript(Script):
|
||||
@@ -246,6 +286,7 @@ class ScriptTest(APITestCase):
|
||||
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
|
||||
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
|
||||
|
||||
@skipIf(not rq_worker_running, "RQ worker not running")
|
||||
def test_run_script(self):
|
||||
|
||||
script_data = {
|
||||
@@ -263,13 +304,7 @@ class ScriptTest(APITestCase):
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
self.assertEqual(response.data['log'][0]['status'], 'info')
|
||||
self.assertEqual(response.data['log'][0]['message'], script_data['var1'])
|
||||
self.assertEqual(response.data['log'][1]['status'], 'success')
|
||||
self.assertEqual(response.data['log'][1]['message'], script_data['var2'])
|
||||
self.assertEqual(response.data['log'][2]['status'], 'failure')
|
||||
self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
|
||||
self.assertEqual(response.data['output'], 'Script complete')
|
||||
self.assertEqual(response.data['result']['status']['value'], 'pending')
|
||||
|
||||
|
||||
class CreatedUpdatedFilterTest(APITestCase):
|
||||
@@ -295,6 +330,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
)
|
||||
|
||||
def test_get_rack_created(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
|
||||
|
||||
@@ -302,6 +338,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_created_gte(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
@@ -309,6 +346,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
|
||||
def test_get_rack_created_lte(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
|
||||
|
||||
@@ -316,6 +354,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
@@ -323,6 +362,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
|
||||
|
||||
def test_get_rack_last_updated_gte(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
@@ -330,6 +370,7 @@ class CreatedUpdatedFilterTest(APITestCase):
|
||||
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
|
||||
|
||||
def test_get_rack_last_updated_lte(self):
|
||||
self.add_permissions('dcim.view_rack')
|
||||
url = reverse('dcim-api:rack-list')
|
||||
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from rest_framework import status
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.models import CustomField, CustomFieldValue, ObjectChange
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
@@ -12,7 +11,6 @@ from utilities.testing import APITestCase
|
||||
class ChangeLogTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
# Create a custom field on the Site model
|
||||
@@ -26,21 +24,17 @@ class ChangeLogTest(APITestCase):
|
||||
cf.obj_type.set([ct])
|
||||
|
||||
def test_create_object(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Site 1',
|
||||
'slug': 'test-site-1',
|
||||
'custom_fields': {
|
||||
'my_field': 'ABC'
|
||||
},
|
||||
'tags': [
|
||||
'bar', 'foo'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
@@ -52,10 +46,8 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_CREATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_update_object(self):
|
||||
|
||||
site = Site(name='Test Site 1', slug='test-site-1')
|
||||
site.save()
|
||||
|
||||
@@ -65,14 +57,11 @@ class ChangeLogTest(APITestCase):
|
||||
'custom_fields': {
|
||||
'my_field': 'DEF'
|
||||
},
|
||||
'tags': [
|
||||
'abc', 'xyz'
|
||||
],
|
||||
}
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
self.add_permissions('dcim.change_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
@@ -84,27 +73,23 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.changed_object, site)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
|
||||
|
||||
def test_delete_object(self):
|
||||
|
||||
site = Site(
|
||||
name='Test Site 1',
|
||||
slug='test-site-1'
|
||||
)
|
||||
site.save()
|
||||
site.tags.add('foo', 'bar')
|
||||
CustomFieldValue.objects.create(
|
||||
field=CustomField.objects.get(name='my_field'),
|
||||
obj=site,
|
||||
value='ABC'
|
||||
)
|
||||
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
self.add_permissions('dcim.delete_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
response = self.client.delete(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Site.objects.count(), 0)
|
||||
|
||||
@@ -113,4 +98,3 @@ class ChangeLogTest(APITestCase):
|
||||
self.assertEqual(oc.object_repr, site.name)
|
||||
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
|
||||
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
|
||||
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from datetime import date
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
@@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
|
||||
from utilities.testing import APITestCase, create_test_user
|
||||
from utilities.testing import APITestCase, TestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
|
||||
@@ -183,8 +182,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
Validate that custom fields are present on an object even if it has no values defined.
|
||||
"""
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.add_permissions('dcim.view_site')
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['name'], self.sites[0].name)
|
||||
self.assertEqual(response.data['custom_fields'], {
|
||||
'text_field': None,
|
||||
@@ -202,10 +202,10 @@ class CustomFieldAPITest(APITestCase):
|
||||
site2_cfvs = {
|
||||
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
self.add_permissions('dcim.view_site')
|
||||
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['name'], self.sites[1].name)
|
||||
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
|
||||
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
|
||||
@@ -222,8 +222,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
'name': 'Site 3',
|
||||
'slug': 'site-3',
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
@@ -264,8 +265,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
'choice_field': self.cf_select_choice2.pk,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
|
||||
@@ -310,8 +312,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
'slug': 'site-5',
|
||||
},
|
||||
)
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), len(data))
|
||||
@@ -368,8 +371,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
'custom_fields': custom_field_data,
|
||||
},
|
||||
)
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(len(response.data), len(data))
|
||||
@@ -411,8 +415,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
'number_field': 1234,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
@@ -470,17 +475,10 @@ class CustomFieldChoiceAPITest(APITestCase):
|
||||
|
||||
|
||||
class CustomFieldImportTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
user = create_test_user(
|
||||
permissions=[
|
||||
'dcim.view_site',
|
||||
'dcim.add_site',
|
||||
]
|
||||
)
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
user_permissions = (
|
||||
'dcim.view_site',
|
||||
'dcim.add_site',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -9,45 +9,53 @@ class TaggedItemTest(APITestCase):
|
||||
"""
|
||||
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
def test_create_tagged_item(self):
|
||||
|
||||
tags = self.create_tags("Foo", "Bar", "Baz")
|
||||
data = {
|
||||
'name': 'Test Site',
|
||||
'slug': 'test-site',
|
||||
'tags': ['Foo', 'Bar', 'Baz']
|
||||
'tags': [t.pk for t in tags]
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:site-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.add_permissions('dcim.add_site')
|
||||
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
|
||||
self.assertListEqual(
|
||||
sorted([t['id'] for t in response.data['tags']]),
|
||||
sorted(data['tags'])
|
||||
)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
tags = [tag.name for tag in site.tags.all()]
|
||||
self.assertEqual(sorted(tags), sorted(data['tags']))
|
||||
self.assertListEqual(
|
||||
sorted([t.name for t in site.tags.all()]),
|
||||
sorted(["Foo", "Bar", "Baz"])
|
||||
)
|
||||
|
||||
def test_update_tagged_item(self):
|
||||
|
||||
site = Site.objects.create(
|
||||
name='Test Site',
|
||||
slug='test-site'
|
||||
)
|
||||
site.tags.add('Foo', 'Bar', 'Baz')
|
||||
|
||||
site.tags.add("Foo", "Bar", "Baz")
|
||||
self.create_tags("New Tag")
|
||||
data = {
|
||||
'tags': ['Foo', 'Bar', 'New Tag']
|
||||
'tags': [
|
||||
{"name": "Foo"},
|
||||
{"name": "Bar"},
|
||||
{"name": "New Tag"},
|
||||
]
|
||||
}
|
||||
|
||||
self.add_permissions('dcim.change_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
|
||||
self.assertListEqual(
|
||||
sorted([t['name'] for t in response.data['tags']]),
|
||||
sorted([t['name'] for t in data['tags']])
|
||||
)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
tags = [tag.name for tag in site.tags.all()]
|
||||
self.assertEqual(sorted(tags), sorted(data['tags']))
|
||||
self.assertListEqual(
|
||||
sorted([t.name for t in site.tags.all()]),
|
||||
sorted(["Foo", "Bar", "New Tag"])
|
||||
)
|
||||
|
||||
@@ -10,13 +10,9 @@ from extras.models import ConfigContext, ObjectChange, Tag
|
||||
from utilities.testing import ViewTestCases, TestCase
|
||||
|
||||
|
||||
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = Tag
|
||||
|
||||
# Disable inapplicable tests
|
||||
test_create_object = None
|
||||
test_import_objects = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -33,21 +29,30 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'comments': 'Some comments',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,color,description",
|
||||
"Tag 4,tag-4,ff0000,Fourth tag",
|
||||
"Tag 5,tag-5,00ff00,Fifth tag",
|
||||
"Tag 6,tag-6,0000ff,Sixth tag",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'color': '00ff00',
|
||||
}
|
||||
|
||||
|
||||
class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by absence of standard create/edit, bulk create views
|
||||
class ConfigContextTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
model = ConfigContext
|
||||
|
||||
# Disable inapplicable tests
|
||||
test_import_objects = None
|
||||
|
||||
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
|
||||
test_create_object = None
|
||||
test_edit_object = None
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
@@ -108,7 +113,7 @@ class ObjectChangeTestCase(TestCase):
|
||||
|
||||
url = reverse('extras:objectchange_list')
|
||||
params = {
|
||||
"user": User.objects.first(),
|
||||
"user": User.objects.first().pk,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
|
||||
@@ -42,13 +42,13 @@ class WebhookTest(APITestCase):
|
||||
webhook.obj_type.set([site_ct])
|
||||
|
||||
def test_enqueue_webhook_create(self):
|
||||
|
||||
# Create an object via the REST API
|
||||
data = {
|
||||
'name': 'Test Site',
|
||||
'slug': 'test-site',
|
||||
}
|
||||
url = reverse('dcim-api:site-list')
|
||||
self.add_permissions('dcim.add_site')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Site.objects.count(), 1)
|
||||
@@ -62,14 +62,13 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE)
|
||||
|
||||
def test_enqueue_webhook_update(self):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
# Update an object via the REST API
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
data = {
|
||||
'comments': 'Updated the site',
|
||||
}
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
self.add_permissions('dcim.change_site')
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
|
||||
@@ -82,11 +81,10 @@ class WebhookTest(APITestCase):
|
||||
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE)
|
||||
|
||||
def test_enqueue_webhook_delete(self):
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
# Delete an object via the REST API
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
self.add_permissions('dcim.delete_site')
|
||||
response = self.client.delete(url, **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras import views
|
||||
from extras.models import Tag
|
||||
from extras.models import ConfigContext, Tag
|
||||
|
||||
|
||||
app_name = 'extras'
|
||||
@@ -9,21 +9,23 @@ urlpatterns = [
|
||||
|
||||
# Tags
|
||||
path('tags/', views.TagListView.as_view(), name='tag_list'),
|
||||
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
|
||||
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
|
||||
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
|
||||
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
|
||||
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
|
||||
path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
|
||||
path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
|
||||
path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
|
||||
|
||||
# Config contexts
|
||||
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
|
||||
path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
|
||||
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
|
||||
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
|
||||
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
|
||||
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
|
||||
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
|
||||
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
|
||||
path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', kwargs={'model': ConfigContext}),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
@@ -35,11 +37,12 @@ urlpatterns = [
|
||||
|
||||
# Reports
|
||||
path('reports/', views.ReportListView.as_view(), name='report_list'),
|
||||
path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
|
||||
path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
|
||||
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
|
||||
|
||||
# Scripts
|
||||
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
|
||||
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,125 +1,105 @@
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, Prefetch, Q
|
||||
from django.http import Http404, HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
from django_rq.queues import get_connection
|
||||
from django_tables2 import RequestConfig
|
||||
from rq import Worker
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.utils import shallow_compare_dict
|
||||
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
|
||||
from . import filters, forms
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports
|
||||
from utilities.utils import copy_safe_request, shallow_compare_dict
|
||||
from utilities.views import (
|
||||
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
|
||||
ContentTypePermissionRequiredMixin,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from . import filters, forms, tables
|
||||
from .choices import JobResultStatusChoices
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag
|
||||
from .reports import get_report, get_reports, run_report
|
||||
from .scripts import get_scripts, run_script
|
||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
class TagListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'extras.view_tag'
|
||||
class TagListView(ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items', distinct=True)
|
||||
).order_by(
|
||||
'name'
|
||||
)
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
filterset = filters.TagFilterSet
|
||||
filterset_form = forms.TagFilterForm
|
||||
table = TagTable
|
||||
action_buttons = ()
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
class TagView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_tag'
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
tag = get_object_or_404(Tag, slug=slug)
|
||||
tagged_items = TaggedItem.objects.filter(
|
||||
tag=tag
|
||||
).prefetch_related(
|
||||
'content_type', 'content_object'
|
||||
)
|
||||
|
||||
# Generate a table of all items tagged with this Tag
|
||||
items_table = TaggedItemTable(tagged_items)
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(items_table)
|
||||
|
||||
return render(request, 'extras/tag.html', {
|
||||
'tag': tag,
|
||||
'items_count': tagged_items.count(),
|
||||
'items_table': items_table,
|
||||
})
|
||||
|
||||
|
||||
class TagEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'extras.change_tag'
|
||||
model = Tag
|
||||
class TagEditView(ObjectEditView):
|
||||
queryset = Tag.objects.all()
|
||||
model_form = forms.TagForm
|
||||
default_return_url = 'extras:tag_list'
|
||||
template_name = 'extras/tag_edit.html'
|
||||
|
||||
|
||||
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'extras.delete_tag'
|
||||
model = Tag
|
||||
default_return_url = 'extras:tag_list'
|
||||
class TagDeleteView(ObjectDeleteView):
|
||||
queryset = Tag.objects.all()
|
||||
|
||||
|
||||
class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'extras.change_tag'
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items', distinct=True)
|
||||
).order_by(
|
||||
'name'
|
||||
)
|
||||
table = TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
default_return_url = 'extras:tag_list'
|
||||
class TagBulkImportView(BulkImportView):
|
||||
queryset = Tag.objects.all()
|
||||
model_form = forms.TagCSVForm
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'extras.delete_tag'
|
||||
class TagBulkEditView(BulkEditView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(
|
||||
'name'
|
||||
)
|
||||
table = TagTable
|
||||
default_return_url = 'extras:tag_list'
|
||||
).order_by(*Tag._meta.ordering)
|
||||
table = tables.TagTable
|
||||
form = forms.TagBulkEditForm
|
||||
|
||||
|
||||
class TagBulkDeleteView(BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(*Tag._meta.ordering)
|
||||
table = tables.TagTable
|
||||
|
||||
|
||||
#
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'extras.view_configcontext'
|
||||
class ConfigContextListView(ObjectListView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = filters.ConfigContextFilterSet
|
||||
filterset_form = forms.ConfigContextFilterForm
|
||||
table = ConfigContextTable
|
||||
table = tables.ConfigContextTable
|
||||
action_buttons = ('add',)
|
||||
|
||||
|
||||
class ConfigContextView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_configcontext'
|
||||
class ConfigContextView(ObjectView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
configcontext = get_object_or_404(ConfigContext, pk=pk)
|
||||
# Extend queryset to prefetch related objects
|
||||
self.queryset = self.queryset.prefetch_related(
|
||||
Prefetch('regions', queryset=Region.objects.restrict(request.user)),
|
||||
Prefetch('sites', queryset=Site.objects.restrict(request.user)),
|
||||
Prefetch('roles', queryset=DeviceRole.objects.restrict(request.user)),
|
||||
Prefetch('platforms', queryset=Platform.objects.restrict(request.user)),
|
||||
Prefetch('clusters', queryset=Cluster.objects.restrict(request.user)),
|
||||
Prefetch('cluster_groups', queryset=ClusterGroup.objects.restrict(request.user)),
|
||||
Prefetch('tenants', queryset=Tenant.objects.restrict(request.user)),
|
||||
Prefetch('tenant_groups', queryset=TenantGroup.objects.restrict(request.user)),
|
||||
)
|
||||
|
||||
configcontext = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
@@ -137,49 +117,36 @@ class ConfigContextView(PermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'extras.add_configcontext'
|
||||
model = ConfigContext
|
||||
class ConfigContextEditView(ObjectEditView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
model_form = forms.ConfigContextForm
|
||||
default_return_url = 'extras:configcontext_list'
|
||||
template_name = 'extras/configcontext_edit.html'
|
||||
|
||||
|
||||
class ConfigContextEditView(ConfigContextCreateView):
|
||||
permission_required = 'extras.change_configcontext'
|
||||
|
||||
|
||||
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'extras.change_configcontext'
|
||||
class ConfigContextBulkEditView(BulkEditView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
filterset = filters.ConfigContextFilterSet
|
||||
table = ConfigContextTable
|
||||
table = tables.ConfigContextTable
|
||||
form = forms.ConfigContextBulkEditForm
|
||||
default_return_url = 'extras:configcontext_list'
|
||||
|
||||
|
||||
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'extras.delete_configcontext'
|
||||
model = ConfigContext
|
||||
default_return_url = 'extras:configcontext_list'
|
||||
|
||||
|
||||
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'extras.delete_configcontext'
|
||||
class ConfigContextDeleteView(ObjectDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
table = ConfigContextTable
|
||||
default_return_url = 'extras:configcontext_list'
|
||||
|
||||
|
||||
class ObjectConfigContextView(View):
|
||||
object_class = None
|
||||
class ConfigContextBulkDeleteView(BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
table = tables.ConfigContextTable
|
||||
|
||||
|
||||
class ObjectConfigContextView(ObjectView):
|
||||
base_template = None
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
obj = get_object_or_404(self.object_class, pk=pk)
|
||||
source_contexts = ConfigContext.objects.get_for_object(obj)
|
||||
model_name = self.object_class._meta.model_name
|
||||
obj = get_object_or_404(self.queryset, pk=pk)
|
||||
source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
|
||||
model_name = self.queryset.model._meta.model_name
|
||||
|
||||
# Determine user's preferred output format
|
||||
if request.GET.get('format') in ['json', 'yaml']:
|
||||
@@ -206,30 +173,33 @@ class ObjectConfigContextView(View):
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'extras.view_objectchange'
|
||||
class ObjectChangeListView(ObjectListView):
|
||||
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
|
||||
filterset = filters.ObjectChangeFilterSet
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = ObjectChangeTable
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'extras/objectchange_list.html'
|
||||
action_buttons = ('export',)
|
||||
|
||||
|
||||
class ObjectChangeView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_objectchange'
|
||||
class ObjectChangeView(ObjectView):
|
||||
queryset = ObjectChange.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
objectchange = get_object_or_404(ObjectChange, pk=pk)
|
||||
objectchange = get_object_or_404(self.queryset, pk=pk)
|
||||
|
||||
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
|
||||
related_changes_table = ObjectChangeTable(
|
||||
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
request_id=objectchange.request_id
|
||||
).exclude(
|
||||
pk=objectchange.pk
|
||||
)
|
||||
related_changes_table = tables.ObjectChangeTable(
|
||||
data=related_changes[:50],
|
||||
orderable=False
|
||||
)
|
||||
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
|
||||
changed_object_type=objectchange.changed_object_type,
|
||||
changed_object_id=objectchange.changed_object_id,
|
||||
)
|
||||
@@ -266,18 +236,21 @@ class ObjectChangeLogView(View):
|
||||
|
||||
def get(self, request, model, **kwargs):
|
||||
|
||||
# Get object my model and kwargs (e.g. slug='foo')
|
||||
obj = get_object_or_404(model, **kwargs)
|
||||
# Handle QuerySet restriction of parent object if needed
|
||||
if hasattr(model.objects, 'restrict'):
|
||||
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
|
||||
else:
|
||||
obj = get_object_or_404(model, **kwargs)
|
||||
|
||||
# Gather all changes for this object (and its related objects)
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
objectchanges = ObjectChange.objects.prefetch_related(
|
||||
objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
||||
'user', 'changed_object_type'
|
||||
).filter(
|
||||
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
|
||||
Q(related_object_type=content_type, related_object_id=obj.pk)
|
||||
)
|
||||
objectchanges_table = ObjectChangeTable(
|
||||
objectchanges_table = tables.ObjectChangeTable(
|
||||
data=objectchanges,
|
||||
orderable=False
|
||||
)
|
||||
@@ -300,6 +273,7 @@ class ObjectChangeLogView(View):
|
||||
|
||||
return render(request, 'extras/object_changelog.html', {
|
||||
object_var: obj,
|
||||
'instance': obj, # We'll eventually standardize on 'instance` for the object variable name
|
||||
'table': objectchanges_table,
|
||||
'base_template': base_template,
|
||||
'active_tab': 'changelog',
|
||||
@@ -310,9 +284,8 @@ class ObjectChangeLogView(View):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'extras.change_imageattachment'
|
||||
model = ImageAttachment
|
||||
class ImageAttachmentEditView(ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
model_form = forms.ImageAttachmentForm
|
||||
|
||||
def alter_obj(self, imageattachment, request, args, kwargs):
|
||||
@@ -326,9 +299,8 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
|
||||
|
||||
class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'extras.delete_imageattachment'
|
||||
model = ImageAttachment
|
||||
class ImageAttachmentDeleteView(ObjectDeleteView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
@@ -338,16 +310,24 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportListView(PermissionRequiredMixin, View):
|
||||
class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
|
||||
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
|
||||
"""
|
||||
permission_required = 'extras.view_reportresult'
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
reports = get_reports()
|
||||
results = {r.report: r for r in ReportResult.objects.all()}
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in JobResult.objects.filter(
|
||||
obj_type=report_content_type,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).defer('data')
|
||||
}
|
||||
|
||||
ret = []
|
||||
for module, report_list in reports:
|
||||
@@ -362,83 +342,152 @@ class ReportListView(PermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class ReportView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a single Report and its associated ReportResult (if any).
|
||||
"""
|
||||
permission_required = 'extras.view_reportresult'
|
||||
|
||||
def get(self, request, name):
|
||||
|
||||
# Retrieve the Report by "<module>.<report>"
|
||||
module_name, report_name = name.split('.')
|
||||
report = get_report(module_name, report_name)
|
||||
class GetReportMixin:
|
||||
def _get_report(self, name, module=None):
|
||||
if module is None:
|
||||
module, name = name.split('.', 1)
|
||||
report = get_report(module, name)
|
||||
if report is None:
|
||||
raise Http404
|
||||
|
||||
# Attach the ReportResult (if any)
|
||||
report.result = ReportResult.objects.filter(report=report.full_name).first()
|
||||
return report
|
||||
|
||||
|
||||
class ReportView(GetReportMixin, ContentTypePermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a single Report and its associated JobResult (if any).
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
report = self._get_report(name, module)
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
report.result = JobResult.objects.filter(
|
||||
obj_type=report_content_type,
|
||||
name=report.full_name,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
'run_form': ConfirmationForm(),
|
||||
})
|
||||
|
||||
def post(self, request, module, name):
|
||||
|
||||
class ReportRunView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Run a Report and record a new ReportResult.
|
||||
"""
|
||||
permission_required = 'extras.add_reportresult'
|
||||
# Permissions check
|
||||
if not request.user.has_perm('extras.run_report'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
def post(self, request, name):
|
||||
report = self._get_report(name, module)
|
||||
form = ConfirmationForm(request.POST)
|
||||
|
||||
# Retrieve the Report by "<module>.<report>"
|
||||
module_name, report_name = name.split('.')
|
||||
report = get_report(module_name, report_name)
|
||||
if report is None:
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
messages.error(request, "Unable to run report: RQ worker process not running.")
|
||||
|
||||
elif form.is_valid():
|
||||
|
||||
# Run the Report. A new JobResult is created.
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
request.user
|
||||
)
|
||||
|
||||
return redirect('extras:report_result', job_result_pk=job_result.pk)
|
||||
|
||||
return render(request, 'extras/report.html', {
|
||||
'report': report,
|
||||
'run_form': form,
|
||||
})
|
||||
|
||||
|
||||
class ReportResultView(ContentTypePermissionRequiredMixin, GetReportMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, job_result_pk):
|
||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
if result.obj_type != report_content_type:
|
||||
raise Http404
|
||||
|
||||
form = ConfirmationForm(request.POST)
|
||||
if form.is_valid():
|
||||
report = self._get_report(result.name)
|
||||
|
||||
# Run the Report. A new ReportResult is created.
|
||||
report.run()
|
||||
result = 'failed' if report.failed else 'passed'
|
||||
msg = "Ran report {} ({})".format(report.full_name, result)
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
return redirect('extras:report', name=report.full_name)
|
||||
return render(request, 'extras/report_result.html', {
|
||||
'report': report,
|
||||
'result': result,
|
||||
'class_name': report.name,
|
||||
'run_form': ConfirmationForm(),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Scripts
|
||||
#
|
||||
|
||||
class ScriptListView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'scripts': get_scripts(use_names=True),
|
||||
})
|
||||
|
||||
|
||||
class ScriptView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_script'
|
||||
|
||||
def _get_script(self, module, name):
|
||||
class GetScriptMixin:
|
||||
def _get_script(self, name, module=None):
|
||||
if module is None:
|
||||
module, name = name.split('.', 1)
|
||||
scripts = get_scripts()
|
||||
try:
|
||||
return scripts[module][name]()
|
||||
except KeyError:
|
||||
raise Http404
|
||||
|
||||
|
||||
class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
scripts = get_scripts(use_names=True)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).defer('data')
|
||||
}
|
||||
|
||||
for _scripts in scripts.values():
|
||||
for script in _scripts.values():
|
||||
script.result = results.get(script.full_name)
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'scripts': scripts,
|
||||
})
|
||||
|
||||
|
||||
class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
script = self._get_script(module, name)
|
||||
script = self._get_script(name, module)
|
||||
form = script.as_form(initial=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')
|
||||
script.result = JobResult.objects.filter(
|
||||
obj_type=script_content_type,
|
||||
name=script.full_name,
|
||||
).exclude(
|
||||
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).first()
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
@@ -451,19 +500,51 @@ class ScriptView(PermissionRequiredMixin, View):
|
||||
if not request.user.has_perm('extras.run_script'):
|
||||
return HttpResponseForbidden()
|
||||
|
||||
script = self._get_script(module, name)
|
||||
script = self._get_script(name, module)
|
||||
form = script.as_form(request.POST, request.FILES)
|
||||
output = None
|
||||
execution_time = None
|
||||
|
||||
if form.is_valid():
|
||||
# Allow execution only if RQ worker process is running
|
||||
if not Worker.count(get_connection('default')):
|
||||
messages.error(request, "Unable to run script: RQ worker process not running.")
|
||||
|
||||
elif form.is_valid():
|
||||
commit = form.cleaned_data.pop('_commit')
|
||||
output, execution_time = run_script(script, form.cleaned_data, request, commit)
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_script,
|
||||
script.full_name,
|
||||
script_content_type,
|
||||
request.user,
|
||||
data=form.cleaned_data,
|
||||
request=copy_safe_request(request),
|
||||
commit=commit
|
||||
)
|
||||
|
||||
return redirect('extras:script_result', job_result_pk=job_result.pk)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'module': module,
|
||||
'script': script,
|
||||
'form': form,
|
||||
'output': output,
|
||||
'execution_time': execution_time,
|
||||
})
|
||||
|
||||
|
||||
class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
|
||||
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, job_result_pk):
|
||||
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
if result.obj_type != script_content_type:
|
||||
raise Http404
|
||||
|
||||
script = self._get_script(result.name)
|
||||
|
||||
return render(request, 'extras/script_result.html', {
|
||||
'script': script,
|
||||
'result': result,
|
||||
'class_name': script.__class__.__name__
|
||||
})
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.validators import UniqueTogetherValidator
|
||||
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
|
||||
from dcim.models import Interface
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from extras.api.serializers import TaggedObjectSerializer
|
||||
from ipam.choices import *
|
||||
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer
|
||||
from utilities.api import (
|
||||
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
|
||||
get_serializer_for_model,
|
||||
)
|
||||
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
|
||||
from .nested_serializers import *
|
||||
@@ -22,17 +26,17 @@ from .nested_serializers import *
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
ipaddress_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = [
|
||||
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
|
||||
'created', 'last_updated', 'ipaddress_count', 'prefix_count',
|
||||
'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name',
|
||||
'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -41,22 +45,23 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class RIRSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
|
||||
|
||||
|
||||
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
rir = NestedRIRSerializer()
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Aggregate
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
|
||||
'id', 'url', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
@@ -67,21 +72,23 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class RoleSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count']
|
||||
|
||||
|
||||
class VLANGroupSerializer(ValidatedModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'name', 'slug', 'site', 'description', 'vlan_count']
|
||||
fields = ['id', 'url', 'name', 'slug', 'site', 'description', 'vlan_count']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
@@ -98,20 +105,20 @@ class VLANGroupSerializer(ValidatedModelSerializer):
|
||||
return data
|
||||
|
||||
|
||||
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
group = NestedVLANGroupSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=VLANStatusChoices, required=False)
|
||||
role = NestedRoleSerializer(required=False, allow_null=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
fields = [
|
||||
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
|
||||
'custom_fields', 'created', 'last_updated', 'prefix_count',
|
||||
'id', 'url', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
|
||||
'display_name', 'custom_fields', 'created', 'last_updated', 'prefix_count',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -133,7 +140,8 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
@@ -141,13 +149,12 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=PrefixStatusChoices, required=False)
|
||||
role = NestedRoleSerializer(required=False, allow_null=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
fields = [
|
||||
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
|
||||
'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
@@ -203,48 +210,38 @@ class AvailablePrefixSerializer(serializers.Serializer):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressInterfaceSerializer(WritableNestedSerializer):
|
||||
"""
|
||||
Nested representation of an Interface which may belong to a Device *or* a VirtualMachine.
|
||||
"""
|
||||
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'url', 'device', 'virtual_machine', 'name',
|
||||
]
|
||||
|
||||
def get_url(self, obj):
|
||||
"""
|
||||
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
|
||||
if the parent is a VirtualMachine.
|
||||
"""
|
||||
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
|
||||
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
|
||||
|
||||
|
||||
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
|
||||
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
|
||||
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
|
||||
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
|
||||
assigned_object_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
|
||||
required=False
|
||||
)
|
||||
assigned_object = serializers.SerializerMethodField(read_only=True)
|
||||
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
|
||||
nat_outside = NestedIPAddressSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
|
||||
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'url', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
|
||||
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_assigned_object(self, obj):
|
||||
if obj.assigned_object is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
|
||||
context = {'request': self.context['request']}
|
||||
return serializer(obj.assigned_object, context=context).data
|
||||
|
||||
|
||||
class AvailableIPSerializer(serializers.Serializer):
|
||||
"""
|
||||
@@ -270,7 +267,8 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
|
||||
device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
|
||||
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
|
||||
@@ -280,11 +278,10 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
tags = TagListSerializerField(required=False)
|
||||
|
||||
class Meta:
|
||||
model = Service
|
||||
fields = [
|
||||
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
|
||||
'id', 'url', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.db.models import Count, Prefetch
|
||||
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.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
@@ -25,7 +24,7 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
|
||||
ipaddress_count=get_subquery(IPAddress, 'vrf'),
|
||||
prefix_count=get_subquery(Prefix, 'vrf')
|
||||
)
|
||||
).order_by(*VRF._meta.ordering)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilterSet
|
||||
|
||||
@@ -37,7 +36,7 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=Count('aggregates')
|
||||
)
|
||||
).order_by(*RIR._meta.ordering)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilterSet
|
||||
|
||||
@@ -60,7 +59,7 @@ class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
)
|
||||
).order_by(*Role._meta.ordering)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilterSet
|
||||
|
||||
@@ -90,7 +89,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
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(Prefix, pk=pk)
|
||||
prefix = get_object_or_404(self.queryset, pk=pk)
|
||||
available_prefixes = prefix.get_available_prefixes()
|
||||
|
||||
if request.method == 'POST':
|
||||
@@ -169,7 +168,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
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(Prefix, pk=pk)
|
||||
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
|
||||
|
||||
# Create the next available IP within the prefix
|
||||
if request.method == 'POST':
|
||||
@@ -239,8 +238,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
|
||||
|
||||
class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
queryset = IPAddress.objects.prefetch_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
|
||||
'nat_outside', 'tags',
|
||||
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
|
||||
)
|
||||
serializer_class = serializers.IPAddressSerializer
|
||||
filterset_class = filters.IPAddressFilterSet
|
||||
@@ -253,7 +251,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.prefetch_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
)
|
||||
).order_by(*VLANGroup._meta.ordering)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filters.VLANGroupFilterSet
|
||||
|
||||
@@ -267,7 +265,7 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
'site', 'group', 'tenant', 'role', 'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'vlan')
|
||||
)
|
||||
).order_by(*VLAN._meta.ordering)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilterSet
|
||||
|
||||
@@ -277,6 +275,8 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
queryset = Service.objects.prefetch_related('device').prefetch_related('tags')
|
||||
queryset = Service.objects.prefetch_related(
|
||||
'device', 'virtual_machine', 'tags', 'ipaddresses'
|
||||
)
|
||||
serializer_class = serializers.ServiceSerializer
|
||||
filterset_class = filters.ServiceFilterSet
|
||||
|
||||
@@ -30,13 +30,6 @@ class PrefixStatusChoices(ChoiceSet):
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_CONTAINER: 0,
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_RESERVED: 2,
|
||||
STATUS_DEPRECATED: 3,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# IPAddresses
|
||||
@@ -56,13 +49,6 @@ class IPAddressStatusChoices(ChoiceSet):
|
||||
(STATUS_DHCP, 'DHCP'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_RESERVED: 2,
|
||||
STATUS_DEPRECATED: 3,
|
||||
STATUS_DHCP: 5,
|
||||
}
|
||||
|
||||
|
||||
class IPAddressRoleChoices(ChoiceSet):
|
||||
|
||||
@@ -86,17 +72,6 @@ class IPAddressRoleChoices(ChoiceSet):
|
||||
(ROLE_CARP, 'CARP'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
ROLE_LOOPBACK: 10,
|
||||
ROLE_SECONDARY: 20,
|
||||
ROLE_ANYCAST: 30,
|
||||
ROLE_VIP: 40,
|
||||
ROLE_VRRP: 41,
|
||||
ROLE_HSRP: 42,
|
||||
ROLE_GLBP: 43,
|
||||
ROLE_CARP: 44,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# VLANs
|
||||
@@ -114,12 +89,6 @@ class VLANStatusChoices(ChoiceSet):
|
||||
(STATUS_DEPRECATED, 'Deprecated'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
STATUS_ACTIVE: 1,
|
||||
STATUS_RESERVED: 2,
|
||||
STATUS_DEPRECATED: 3,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
@@ -134,8 +103,3 @@ class ServiceProtocolChoices(ChoiceSet):
|
||||
(PROTOCOL_TCP, 'TCP'),
|
||||
(PROTOCOL_UDP, 'UDP'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
PROTOCOL_TCP: 6,
|
||||
PROTOCOL_UDP: 17,
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from django.db.models import Q
|
||||
|
||||
from .choices import IPAddressRoleChoices
|
||||
|
||||
# BGP ASN bounds
|
||||
@@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
|
||||
# IPAddresses
|
||||
#
|
||||
|
||||
IPADDRESS_ASSIGNMENT_MODELS = Q(
|
||||
Q(app_label='dcim', model='interface') |
|
||||
Q(app_label='virtualization', model='vminterface')
|
||||
)
|
||||
|
||||
IPADDRESS_MASK_LENGTH_MIN = 1
|
||||
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from utilities.filters import (
|
||||
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
|
||||
TreeNodeMultipleChoiceFilter,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
@@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
|
||||
field_name='pk',
|
||||
label='Device (ID)',
|
||||
)
|
||||
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface__virtual_machine',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
label='Virtual machine (ID)',
|
||||
)
|
||||
virtual_machine = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface__virtual_machine__name',
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
to_field_name='name',
|
||||
virtual_machine = MultiValueCharFilter(
|
||||
method='filter_virtual_machine',
|
||||
field_name='name',
|
||||
label='Virtual machine (name)',
|
||||
)
|
||||
virtual_machine_id = MultiValueNumberFilter(
|
||||
method='filter_virtual_machine',
|
||||
field_name='pk',
|
||||
label='Virtual machine (ID)',
|
||||
)
|
||||
interface = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface__name',
|
||||
queryset=Interface.objects.all(),
|
||||
to_field_name='name',
|
||||
label='Interface (ID)',
|
||||
label='Interface (name)',
|
||||
)
|
||||
interface_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='interface',
|
||||
queryset=Interface.objects.all(),
|
||||
label='Interface (ID)',
|
||||
)
|
||||
vminterface = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface__name',
|
||||
queryset=VMInterface.objects.all(),
|
||||
to_field_name='name',
|
||||
label='VM interface (name)',
|
||||
)
|
||||
vminterface_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='vminterface',
|
||||
queryset=VMInterface.objects.all(),
|
||||
label='VM interface (ID)',
|
||||
)
|
||||
assigned_to_interface = django_filters.BooleanFilter(
|
||||
method='_assigned_to_interface',
|
||||
label='Is assigned to an interface',
|
||||
@@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
|
||||
return queryset.filter(address__net_mask_length=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
try:
|
||||
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
|
||||
vc_interface_ids = []
|
||||
for device in devices:
|
||||
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
|
||||
return queryset.filter(interface_id__in=vc_interface_ids)
|
||||
except Device.DoesNotExist:
|
||||
devices = Device.objects.filter(**{'{}__in'.format(name): value})
|
||||
if not devices.exists():
|
||||
return queryset.none()
|
||||
interface_ids = []
|
||||
for device in devices:
|
||||
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
|
||||
return queryset.filter(
|
||||
interface__in=interface_ids
|
||||
)
|
||||
|
||||
def filter_virtual_machine(self, queryset, name, value):
|
||||
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
|
||||
if not virtual_machines.exists():
|
||||
return queryset.none()
|
||||
interface_ids = []
|
||||
for vm in virtual_machines:
|
||||
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
|
||||
return queryset.filter(
|
||||
vminterface__in=interface_ids
|
||||
)
|
||||
|
||||
def _assigned_to_interface(self, queryset, name, value):
|
||||
return queryset.exclude(interface__isnull=value)
|
||||
return queryset.exclude(assigned_object_id__isnull=value)
|
||||
|
||||
|
||||
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
|
||||
from dcim.models import Device, Interface, Rack, Region, Site
|
||||
from extras.forms import (
|
||||
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
|
||||
TagField,
|
||||
)
|
||||
from extras.models import Tag
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -14,7 +15,7 @@ from utilities.forms import (
|
||||
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .constants import *
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
@@ -33,7 +34,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
|
||||
#
|
||||
|
||||
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -141,7 +143,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
|
||||
rir = DynamicModelChoiceField(
|
||||
queryset=RIR.objects.all()
|
||||
)
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -292,7 +295,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
queryset=Role.objects.all(),
|
||||
required=False
|
||||
)
|
||||
tags = TagField(required=False)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Prefix
|
||||
@@ -517,10 +523,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
|
||||
interface = forms.ModelChoiceField(
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'interface': 'device_id'
|
||||
}
|
||||
)
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
filter_for={
|
||||
'vminterface': 'virtual_machine_id'
|
||||
}
|
||||
)
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
label='Interface'
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@@ -584,15 +613,16 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
required=False,
|
||||
label='Make this the primary IP for the device/VM'
|
||||
)
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
|
||||
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
|
||||
'nat_inside', 'tenant_group', 'tenant', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2(),
|
||||
@@ -604,32 +634,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
if instance and instance.nat_inside and instance.nat_inside.device is not None:
|
||||
initial['nat_site'] = instance.nat_inside.device.site
|
||||
initial['nat_rack'] = instance.nat_inside.device.rack
|
||||
initial['nat_device'] = instance.nat_inside.device
|
||||
if instance:
|
||||
if type(instance.assigned_object) is Interface:
|
||||
initial['device'] = instance.assigned_object.device
|
||||
initial['interface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VMInterface:
|
||||
initial['virtual_machine'] = instance.assigned_object.virtual_machine
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
if instance.nat_inside and instance.nat_inside.device is not None:
|
||||
initial['nat_site'] = instance.nat_inside.device.site
|
||||
initial['nat_rack'] = instance.nat_inside.device.rack
|
||||
initial['nat_device'] = instance.nat_inside.device
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.fields['vrf'].empty_label = 'Global'
|
||||
|
||||
# Limit interface selections to those belonging to the parent device/VM
|
||||
if self.instance and self.instance.interface:
|
||||
self.fields['interface'].queryset = Interface.objects.filter(
|
||||
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
|
||||
).prefetch_related(
|
||||
'device__primary_ip4',
|
||||
'device__primary_ip6',
|
||||
'virtual_machine__primary_ip4',
|
||||
'virtual_machine__primary_ip6',
|
||||
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
|
||||
else:
|
||||
self.fields['interface'].choices = []
|
||||
|
||||
# Initialize primary_for_parent if IP address is already assigned
|
||||
if self.instance.pk and self.instance.interface is not None:
|
||||
parent = self.instance.interface.parent
|
||||
if self.instance.pk and self.instance.assigned_object:
|
||||
parent = self.instance.assigned_object.parent
|
||||
if (
|
||||
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
|
||||
@@ -639,32 +663,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
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")
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
if self.cleaned_data.get('primary_for_parent') and not interface:
|
||||
self.add_error(
|
||||
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set assigned object
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
if interface:
|
||||
self.instance.assigned_object = interface
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if interface and self.cleaned_data['primary_for_parent']:
|
||||
if ipaddress.address.version == 4:
|
||||
parent.primary_ip4 = ipaddress
|
||||
interface.parent.primary_ip4 = ipaddress
|
||||
else:
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
elif self.cleaned_data['interface']:
|
||||
parent = self.cleaned_data['interface'].parent
|
||||
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
|
||||
parent.primary_ip4 = None
|
||||
parent.save()
|
||||
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
|
||||
parent.primary_ip6 = None
|
||||
parent.save()
|
||||
interface.primary_ip6 = ipaddress
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
@@ -681,7 +712,8 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
required=False,
|
||||
label='VRF'
|
||||
)
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -735,7 +767,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
help_text='Parent VM of assigned interface (if any)'
|
||||
)
|
||||
interface = CSVModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
queryset=Interface.objects.none(), # Can also refer to VMInterface
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned interface'
|
||||
@@ -754,21 +786,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
if data:
|
||||
|
||||
# Limit interface queryset by assigned device or virtual machine
|
||||
# Limit interface queryset by assigned device
|
||||
if data.get('device'):
|
||||
params = {
|
||||
f"device__{self.fields['device'].to_field_name}": data.get('device')
|
||||
}
|
||||
self.fields['interface'].queryset = Interface.objects.filter(
|
||||
**{f"device__{self.fields['device'].to_field_name}": data['device']}
|
||||
)
|
||||
|
||||
# Limit interface queryset by assigned device
|
||||
elif data.get('virtual_machine'):
|
||||
params = {
|
||||
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
|
||||
}
|
||||
else:
|
||||
params = {
|
||||
'device': None,
|
||||
'virtual_machine': None,
|
||||
}
|
||||
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
|
||||
self.fields['interface'].queryset = VMInterface.objects.filter(
|
||||
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -783,6 +811,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set interface assignment
|
||||
if self.cleaned_data['interface']:
|
||||
self.instance.assigned_object = self.cleaned_data['interface']
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Set as primary for device/VM
|
||||
@@ -993,7 +1025,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
queryset=Role.objects.all(),
|
||||
required=False
|
||||
)
|
||||
tags = TagField(required=False)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLAN
|
||||
@@ -1165,7 +1200,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
min_value=SERVICE_PORT_MIN,
|
||||
max_value=SERVICE_PORT_MAX
|
||||
)
|
||||
tags = TagField(
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
@@ -1188,13 +1224,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
# Limit IP address choices to those assigned to interfaces of the parent device/VM
|
||||
if self.instance.device:
|
||||
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface_id__in=vc_interface_ids
|
||||
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
|
||||
)
|
||||
elif self.instance.virtual_machine:
|
||||
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
|
||||
interface__virtual_machine=self.instance.virtual_machine
|
||||
vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
|
||||
)
|
||||
else:
|
||||
self.fields['ipaddresses'].choices = []
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from django.db import models
|
||||
from django.db.models import Manager
|
||||
|
||||
from ipam.lookups import Host, Inet
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
|
||||
|
||||
class IPAddressManager(models.Manager):
|
||||
class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -13,5 +14,4 @@ class IPAddressManager(models.Manager):
|
||||
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
|
||||
IP address as a /32 or /128.
|
||||
"""
|
||||
qs = super().get_queryset()
|
||||
return qs.order_by(Inet(Host('address')))
|
||||
return super().get_queryset().order_by(Inet(Host('address')))
|
||||
|
||||
40
netbox/ipam/migrations/0037_ipaddress_assignment.py
Normal file
40
netbox/ipam/migrations/0037_ipaddress_assignment.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
def set_assigned_object_type(apps, schema_editor):
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
IPAddress = apps.get_model('ipam', 'IPAddress')
|
||||
|
||||
device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
|
||||
IPAddress.objects.update(assigned_object_type=device_ct)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('ipam', '0036_standardize_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='ipaddress',
|
||||
old_name='interface',
|
||||
new_name='assigned_object_id',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='assigned_object_id',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='assigned_object_type',
|
||||
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=set_assigned_object_type
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user