mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-14 06:13:32 +01:00
Compare commits
270 Commits
v2.5.5
...
v2.6-beta1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d2ac1ca53 | ||
|
|
05a21369ae | ||
|
|
9b404facab | ||
|
|
f31d6c55be | ||
|
|
37c2c4b4a2 | ||
|
|
d5dcb77d99 | ||
|
|
9717c6f825 | ||
|
|
92c227d103 | ||
|
|
1491222642 | ||
|
|
39fceeb455 | ||
|
|
3562b5552b | ||
|
|
6d778f686d | ||
|
|
245a97176a | ||
|
|
ca56871aaa | ||
|
|
bd4086cb50 | ||
|
|
d8c9b1af27 | ||
|
|
19d2850b29 | ||
|
|
2c730b08e4 | ||
|
|
e1bca52d57 | ||
|
|
06245f6422 | ||
|
|
f057a2c016 | ||
|
|
f4636537ad | ||
|
|
a026ec45b8 | ||
|
|
695a07daf4 | ||
|
|
c2d0e8fd95 | ||
|
|
1be5cf8184 | ||
|
|
f4aec1e7d0 | ||
|
|
e4c06700bb | ||
|
|
46b3512c45 | ||
|
|
017a5011ec | ||
|
|
f4bbdf30e8 | ||
|
|
7d41a9ccdb | ||
|
|
074d0349a1 | ||
|
|
6ab56c3978 | ||
|
|
5303d2c16d | ||
|
|
30da4594cd | ||
|
|
92567137a5 | ||
|
|
7b6c104768 | ||
|
|
d27d347d21 | ||
|
|
16b4ffa3fa | ||
|
|
8b75969d1d | ||
|
|
a5e1088f1f | ||
|
|
92a450e59c | ||
|
|
2f3c39295c | ||
|
|
000fde25c6 | ||
|
|
cd3924520d | ||
|
|
c7778db7f2 | ||
|
|
2580b026fe | ||
|
|
eb86053a53 | ||
|
|
fd4c5031c7 | ||
|
|
655e48823a | ||
|
|
a108807534 | ||
|
|
dbe0e9506d | ||
|
|
75b4ba2c3a | ||
|
|
de7207de55 | ||
|
|
bf2f314cd4 | ||
|
|
6034265dfd | ||
|
|
48fe5470d2 | ||
|
|
2b2de8f8a5 | ||
|
|
dd58e78fde | ||
|
|
2ec7ac1ea3 | ||
|
|
f342a37362 | ||
|
|
a411e32a9f | ||
|
|
1877afc760 | ||
|
|
6f055792df | ||
|
|
4536754b20 | ||
|
|
351736cc6d | ||
|
|
4723ddb5ce | ||
|
|
20670c546d | ||
|
|
cdff29c7d5 | ||
|
|
c11abbe8ca | ||
|
|
f0505477b8 | ||
|
|
c484b27a35 | ||
|
|
04c1945abc | ||
|
|
ad4d23fa20 | ||
|
|
a46b43bff6 | ||
|
|
b1c160f9d4 | ||
|
|
533520ec1f | ||
|
|
4acd842237 | ||
|
|
778d56ac12 | ||
|
|
1a2c9e3bba | ||
|
|
067c788df7 | ||
|
|
78c90ba24b | ||
|
|
573af6a236 | ||
|
|
b29944d5d7 | ||
|
|
dfa26cc5e2 | ||
|
|
8c5d544734 | ||
|
|
df0686a1bd | ||
|
|
3e818cde69 | ||
|
|
43a569d18a | ||
|
|
e710ccb0e6 | ||
|
|
ea6815b9bb | ||
|
|
3b06251720 | ||
|
|
bfa07aec39 | ||
|
|
05c19af2a3 | ||
|
|
b22fd2bc44 | ||
|
|
ac3e899f5e | ||
|
|
3d5f85c0ca | ||
|
|
0ddd71fc36 | ||
|
|
8230ea1c83 | ||
|
|
c1127148e2 | ||
|
|
ef867e789d | ||
|
|
cd655d289b | ||
|
|
f1d1e8b537 | ||
|
|
71b674d11a | ||
|
|
4f9b666eee | ||
|
|
8d79353d9b | ||
|
|
a8d20e13a8 | ||
|
|
9a91bdbdb2 | ||
|
|
6f8591f769 | ||
|
|
7f6d79362e | ||
|
|
c032413201 | ||
|
|
e556c78599 | ||
|
|
1b389d662b | ||
|
|
090efde21a | ||
|
|
74c03e3295 | ||
|
|
858be6d216 | ||
|
|
1e160fd9e9 | ||
|
|
4884f3ac77 | ||
|
|
0e5dff212e | ||
|
|
e0085ec819 | ||
|
|
07bfd7c8e5 | ||
|
|
c8cccc30d1 | ||
|
|
7c6d2a6281 | ||
|
|
738a20ad34 | ||
|
|
3602d5a84c | ||
|
|
d23ca041cf | ||
|
|
110387e81b | ||
|
|
498f132cad | ||
|
|
dff3165402 | ||
|
|
3f5f75c71f | ||
|
|
2e1887eb0e | ||
|
|
2170eedf08 | ||
|
|
a208cd156d | ||
|
|
3acc8ca3ab | ||
|
|
fc76c8eb0f | ||
|
|
681e20133a | ||
|
|
7d1ee2e94e | ||
|
|
3cea92384c | ||
|
|
6ba3d611af | ||
|
|
f6345b9a5d | ||
|
|
044f7395bb | ||
|
|
8bda6be65a | ||
|
|
e544705256 | ||
|
|
f88099eb3b | ||
|
|
5f40be4bd5 | ||
|
|
1bf04f2e30 | ||
|
|
7edad4eba9 | ||
|
|
dd7249d2ef | ||
|
|
aab84ba6f3 | ||
|
|
520af82f05 | ||
|
|
9292534324 | ||
|
|
61efe6102e | ||
|
|
705f82e416 | ||
|
|
e06dece00c | ||
|
|
3b9c0e4c67 | ||
|
|
5b753923b6 | ||
|
|
0b95016e00 | ||
|
|
0022a5a6e2 | ||
|
|
332487efcd | ||
|
|
28331dfd53 | ||
|
|
88933a3120 | ||
|
|
e05871b467 | ||
|
|
7e70bfaacc | ||
|
|
f4b85751bb | ||
|
|
ea11e70e3f | ||
|
|
bd8b239e15 | ||
|
|
d9c8c0cbc1 | ||
|
|
7a8fc8dfd5 | ||
|
|
4334f1bc65 | ||
|
|
1995091169 | ||
|
|
ef089d3722 | ||
|
|
1d904b1722 | ||
|
|
ab02f26a0e | ||
|
|
f35b4bf768 | ||
|
|
f2382dd255 | ||
|
|
8df169b170 | ||
|
|
88aeaaffb0 | ||
|
|
e9546b810c | ||
|
|
c208d8fc2e | ||
|
|
8a1d7fdb37 | ||
|
|
c52d077f92 | ||
|
|
7294f43fa3 | ||
|
|
0572c66c17 | ||
|
|
6406e213bd | ||
|
|
cfb56f7cfe | ||
|
|
bd65e782bb | ||
|
|
66d9d9d9cb | ||
|
|
b9f4a9e57b | ||
|
|
80b3a5ffc4 | ||
|
|
0c142f2078 | ||
|
|
7295c3554e | ||
|
|
78725b8483 | ||
|
|
e97ad3f066 | ||
|
|
3a62e9a322 | ||
|
|
4d18d9661b | ||
|
|
11976f8968 | ||
|
|
5991bd368c | ||
|
|
b381bdec27 | ||
|
|
231a5aa9fd | ||
|
|
6f5c35c278 | ||
|
|
3e6033e9ff | ||
|
|
32f63a18ff | ||
|
|
00aaf500de | ||
|
|
f2471aedb2 | ||
|
|
beff774295 | ||
|
|
8dfef83f1a | ||
|
|
d707844c30 | ||
|
|
6a8c935380 | ||
|
|
00c4d3dd92 | ||
|
|
3ffea43253 | ||
|
|
8e548605c8 | ||
|
|
de52f21905 | ||
|
|
b9d11aa4ca | ||
|
|
77c387a559 | ||
|
|
36101f50c3 | ||
|
|
08dad7f7af | ||
|
|
298152bc50 | ||
|
|
993e94c00c | ||
|
|
fba6d28603 | ||
|
|
bb6fb81fc0 | ||
|
|
4c3a7b84c1 | ||
|
|
8961a54a03 | ||
|
|
2184a9402f | ||
|
|
b9f1d14d4e | ||
|
|
fba154386e | ||
|
|
c8366eff8f | ||
|
|
161d9ed512 | ||
|
|
fadc9521f0 | ||
|
|
40cb47868f | ||
|
|
0a06d92c2e | ||
|
|
fc2bb724fa | ||
|
|
e521508de9 | ||
|
|
bd573fd5cf | ||
|
|
9d055ff4fd | ||
|
|
9d69f14faa | ||
|
|
8e3ea6c878 | ||
|
|
971f3cd63c | ||
|
|
a9d7a7e306 | ||
|
|
8797298a71 | ||
|
|
3bc182e453 | ||
|
|
7a0ab3aa15 | ||
|
|
fdbef8ee71 | ||
|
|
d95b127378 | ||
|
|
008138cd02 | ||
|
|
2a1630e068 | ||
|
|
31611203eb | ||
|
|
1ee6d16d78 | ||
|
|
0e8c3a8efe | ||
|
|
d084d19675 | ||
|
|
8152dc4b04 | ||
|
|
109b233e14 | ||
|
|
cc3b26998b | ||
|
|
95dea1faaa | ||
|
|
57fecdbf17 | ||
|
|
3b4bcc881f | ||
|
|
dfa4dfa4a4 | ||
|
|
8d86e97247 | ||
|
|
5da9d6b46b | ||
|
|
100809f11a | ||
|
|
5256077a3c | ||
|
|
375e66047d | ||
|
|
42d1d6e1b0 | ||
|
|
ca51fab4d8 | ||
|
|
73c983516d | ||
|
|
f733d5a4da | ||
|
|
3d2948daf3 | ||
|
|
69a5d3644a | ||
|
|
2f1018c742 | ||
|
|
9991985170 |
@@ -1,6 +1,7 @@
|
||||
sudo: required
|
||||
services:
|
||||
- postgresql
|
||||
- redis-server
|
||||
addons:
|
||||
postgresql: "9.4"
|
||||
language: python
|
||||
|
||||
343
CHANGELOG.md
343
CHANGELOG.md
@@ -1,3 +1,346 @@
|
||||
v2.6-beta1 (2019-04-29)
|
||||
|
||||
**WARNING:** This is a beta release of NetBox intended for development use only. **Do not** rely on it for production
|
||||
use. A migration path forward to a stable release **will not** be provided; expect to ultimately lose any data input
|
||||
into this instance.
|
||||
|
||||
## New Features
|
||||
|
||||
### Power Panels and Feeds ([#54](https://github.com/digitalocean/netbox/issues/54))
|
||||
|
||||
NetBox now supports power supply modeling via two new models: power panels and power feeds. Power feeds are terminated
|
||||
to power panels and are optionally associated with individual racks. Each power feed defines a supply type (AC/DC),
|
||||
amperage, voltage, and phase. A power port can be connected directly to a power feed, but a power feed may have only one
|
||||
power port connected to it.
|
||||
|
||||
Additionally, the power port model has been extended to include fields denoting maximum and allocated draw, in watts.
|
||||
This allows a device (e.g. a PDU) to calculate its total load compared to its connected power feed.
|
||||
|
||||
### Caching ([#2647](https://github.com/digitalocean/netbox/issues/2647))
|
||||
|
||||
To improve performance, NetBox now supports caching for most object and list views. Caching is implemented using Redis,
|
||||
which is now a required dependency. (Previously, Redis was required only if webhooks were enabled.)
|
||||
|
||||
A new configuration parameter is available to control the cache timeout:
|
||||
|
||||
```
|
||||
# Cache timeout (in seconds)
|
||||
CACHE_TIMEOUT = 900
|
||||
```
|
||||
|
||||
### View Permissions ([#323](https://github.com/digitalocean/netbox/issues/323))
|
||||
|
||||
Django 2.1 introduced the ability to enforce view-only permissions for different object types. NetBox now enforces
|
||||
these by default. You can grant view permission to a user or group by assigning the "can view" permission for the
|
||||
desired object(s).
|
||||
|
||||
To exempt certain object types from the enforcement of view permissions, so that any user (including anonymous users)
|
||||
can view them, add them to the new `EXEMPT_VIEW_PERMISSIONS` setting in `configuration.py`:
|
||||
|
||||
```
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
'dcim.site',
|
||||
'ipam.prefix',
|
||||
]
|
||||
```
|
||||
|
||||
To exclude _all_ objects, effectively disabling view permissions, set:
|
||||
|
||||
```
|
||||
EXEMPT_VIEW_PERMISSIONS = ['*']
|
||||
```
|
||||
|
||||
### Custom Links ([#969](https://github.com/digitalocean/netbox/issues/969))
|
||||
|
||||
Custom links are created under the admin UI and will be displayed on each object of the selected type. Link text and
|
||||
URLs can be formed from Jinja2 template code, with the viewed object passed as context data. For example, to link to an
|
||||
external NMS from the device view, you might create a custom link with the following URL:
|
||||
|
||||
```
|
||||
https://nms.example.com/nodes/?name={{ obj.name }}
|
||||
```
|
||||
|
||||
Custom links appear as buttons at the top of the object view. Grouped links will render as a dropdown menu beneath a
|
||||
single button.
|
||||
|
||||
### Prometheus Metrics ([#3104](https://github.com/digitalocean/netbox/issues/3104))
|
||||
|
||||
NetBox now supports exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a
|
||||
popular time series metric platform used for monitoring. NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g.
|
||||
`https://netbox.local/metrics`. Metric exposition can be toggled with the `METRICS_ENABLED` configuration setting.
|
||||
Metrics are exposed by default.
|
||||
|
||||
NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of
|
||||
different types of metrics, including:
|
||||
|
||||
- Per model insert, update, and delete counters
|
||||
- Per view request counters
|
||||
- Per view request latency histograms
|
||||
- Request body size histograms
|
||||
- Response body size histograms
|
||||
- Response code counters
|
||||
- Database connection, execution, and error counters
|
||||
- Cache hit, miss, and invalidation counters
|
||||
- Django middleware latency histograms
|
||||
- Other Django related metadata metrics
|
||||
|
||||
For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance.
|
||||
|
||||
## Changes
|
||||
|
||||
### New Dependency: Redis
|
||||
|
||||
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component
|
||||
of NetBox since the introduction of webhooks in version 2.4, it is now required to support NetBox's new caching
|
||||
functionality (as well as other planned features). Redis can be installed via your platform's package manager: for
|
||||
example, `sudo apt-get install redis-server` on Ubuntu or `sudo yum install redis` on CentOS.
|
||||
|
||||
The Redis database is configured using a configuration setting similar to `DATABASE` in `configuration.py`:
|
||||
|
||||
```
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'CACHE_DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
```
|
||||
|
||||
Note that if you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
|
||||
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
|
||||
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting. It is
|
||||
highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result
|
||||
in webhook processing data being lost during cache flushing events.
|
||||
|
||||
### API Support for Specifying Related Objects by Attributes([#3077](https://github.com/digitalocean/netbox/issues/3077))
|
||||
|
||||
Previously, specifying a related object in an API request required knowing the primary key (integer ID) of that object.
|
||||
For example, when creating a new device, its rack would be specified as an integer:
|
||||
|
||||
```
|
||||
{
|
||||
"name": "MyNewDevice",
|
||||
"rack": 123,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
The NetBox API now supports referencing related objects by a set of sufficiently unique attrbiutes. For example, a rack
|
||||
can be identified by its name and parent site:
|
||||
|
||||
```
|
||||
{
|
||||
"name": "MyNewDevice",
|
||||
"rack": {
|
||||
"site": {
|
||||
"name": "Equinix DC6"
|
||||
},
|
||||
"name": "R204"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
There is no limit to the depth of nested references. Note that if the provided parameters do not return exactly one
|
||||
object, a validation error is raised.
|
||||
|
||||
### API Device/VM Config Context Included by Default ([#2350](https://github.com/digitalocean/netbox/issues/2350))
|
||||
|
||||
The rendered config context for devices and VMs is now included by default in all API results (list and detail views).
|
||||
Previously, the rendered config context was available only in the detail view for individual objects. Users with large
|
||||
amounts of context data may observe a performance drop when returning multiple objects. To combat this, in cases where
|
||||
the rendered config context is not needed, the query parameter `?exclude=config_context` may be added to the request to
|
||||
exclude the config context data from the API response.
|
||||
|
||||
### Changes to Tag Permissions
|
||||
|
||||
NetBox now makes use of its own `Tag` model instead of the stock model which ships with django-taggit. This new model
|
||||
lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed
|
||||
to now use "Extras | Tag." Also note that the admin interface for tags has been removed as it was redundant to the
|
||||
functionality provided by the front end UI.
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#166](https://github.com/digitalocean/netbox/issues/166) - Add `dns_name` field to IPAddress
|
||||
* [#1792](https://github.com/digitalocean/netbox/issues/1792) - Add CustomFieldChoices API endpoint at `/api/extras/_custom_field_choices/`
|
||||
* [#1863](https://github.com/digitalocean/netbox/issues/1863) - Add child object counts to API representation of organizational objects
|
||||
* [#2324](https://github.com/digitalocean/netbox/issues/2324) - Add `color` field for tags
|
||||
* [#2643](https://github.com/digitalocean/netbox/issues/2643) - Add `description` field to console/power components and device bays
|
||||
* [#2791](https://github.com/digitalocean/netbox/issues/2791) - Add `comments` field for tags
|
||||
* [#2920](https://github.com/digitalocean/netbox/issues/2920) - Rename Interface `form_factor` to `type` (backward-compatible until v2.7)
|
||||
* [#2926](https://github.com/digitalocean/netbox/issues/2926) - Add change logging to the Tag model
|
||||
|
||||
## API Changes
|
||||
|
||||
* New API endpoints for power modeling: `/api/dcim/power-panels/` and `/api/dcim/power-feeds/`
|
||||
* New API endpoint for custom field choices: `/api/extras/_custom_field_choices/`
|
||||
* ForeignKey fields now accept either the related object PK or a dictionary of attributes describing the related object.
|
||||
* Organizational objects now include child object counts. For example, the Role serializer includes `prefix_count` and `vlan_count`.
|
||||
* Added a `description` field for all device components.
|
||||
* dcim.Device: The devices list endpoint now includes rendered context data.
|
||||
* dcim.DeviceType: `instance_count` has been renamed to `device_count`.
|
||||
* dcim.Interface: `form_factor` has been renamed to `type`. Backward compatibility for `form_factor` will be maintained until NetBox v2.7.
|
||||
* dcim.Interface: The `type` filter has been renamed to `kind`.
|
||||
* dcim.Site: The `count_*` read-only fields have been renamed to `*_count` for consistency with other objects.
|
||||
* dcim.Site: Added the `virtualmachine_count` read-only field.
|
||||
* extras.Tag: Added `color` and `comments` fields to the Tag serializer.
|
||||
* virtualization.VirtualMachine: The virtual machines list endpoint now includes rendered context data.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2968](https://github.com/digitalocean/netbox/issues/2968) - Correct API documentation for SerializerMethodFields
|
||||
|
||||
---
|
||||
|
||||
2.5.11 (2019-04-29)
|
||||
|
||||
## Notes
|
||||
|
||||
This release upgrades the Django framework to version 2.2.
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components
|
||||
* [#3023](https://github.com/digitalocean/netbox/issues/3023) - Add support for filtering cables by connected device
|
||||
* [#3070](https://github.com/digitalocean/netbox/issues/3070) - Add decommissioning status for devices
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware
|
||||
* [#3072](https://github.com/digitalocean/netbox/issues/3072) - Preserve multiselect filter values when updating per-page count for list views
|
||||
* [#3112](https://github.com/digitalocean/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device
|
||||
* [#3116](https://github.com/digitalocean/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint
|
||||
* [#3118](https://github.com/digitalocean/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled
|
||||
|
||||
---
|
||||
|
||||
v2.5.10 (2019-04-08)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view
|
||||
* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces
|
||||
* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API
|
||||
* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update
|
||||
* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search
|
||||
* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint
|
||||
* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API
|
||||
|
||||
---
|
||||
|
||||
v2.5.9 (2019-04-01)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2933](https://github.com/digitalocean/netbox/issues/2933) - Add username to outbound webhook requests
|
||||
* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+)
|
||||
* [#3025](https://github.com/digitalocean/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes deterministic ordering of interfaces
|
||||
* [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering
|
||||
* [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE
|
||||
* [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry
|
||||
* [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable
|
||||
* [#3001](https://github.com/digitalocean/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type`
|
||||
* [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering
|
||||
* [#3019](https://github.com/digitalocean/netbox/issues/3019) - Fix tag population when running NetBox within a path
|
||||
* [#3022](https://github.com/digitalocean/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint
|
||||
* [#3026](https://github.com/digitalocean/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher
|
||||
* [#3027](https://github.com/digitalocean/netbox/issues/3027) - Ignore empty local context data when rendering config contexts
|
||||
* [#3032](https://github.com/digitalocean/netbox/issues/3032) - Save assigned tags when creating a new secret
|
||||
|
||||
---
|
||||
|
||||
v2.5.8 (2019-03-11)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2435](https://github.com/digitalocean/netbox/issues/2435) - Printer friendly CSS
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2065](https://github.com/digitalocean/netbox/issues/2065) - Correct documentation for VM interface serializer
|
||||
* [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs
|
||||
* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions
|
||||
* [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default
|
||||
* [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API
|
||||
* [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint
|
||||
* [#2940](https://github.com/digitalocean/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned
|
||||
* [#2944](https://github.com/digitalocean/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any)
|
||||
* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function
|
||||
* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows
|
||||
* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices
|
||||
* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length
|
||||
* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API
|
||||
* [#2972](https://github.com/digitalocean/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data
|
||||
* [#2976](https://github.com/digitalocean/netbox/issues/2976) - Add delete button to tag view
|
||||
* [#2980](https://github.com/digitalocean/netbox/issues/2980) - Improve rendering time for API docs
|
||||
* [#2982](https://github.com/digitalocean/netbox/issues/2982) - Correct CSS class assignment on color picker
|
||||
* [#2984](https://github.com/digitalocean/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion
|
||||
* [#2985](https://github.com/digitalocean/netbox/issues/2985) - Fix pagination page length for rack elevations
|
||||
|
||||
---
|
||||
|
||||
v2.5.7 (2019-02-21)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2357](https://github.com/digitalocean/netbox/issues/2357) - Enable filtering of devices by rack face
|
||||
* [#2638](https://github.com/digitalocean/netbox/issues/2638) - Add button to copy unlocked secret to clipboard
|
||||
* [#2870](https://github.com/digitalocean/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields
|
||||
* [#2878](https://github.com/digitalocean/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber
|
||||
* [#2890](https://github.com/digitalocean/netbox/issues/2890) - Add port types for APC fiber
|
||||
* [#2898](https://github.com/digitalocean/netbox/issues/2898) - Enable filtering cables list by connection status
|
||||
* [#2903](https://github.com/digitalocean/netbox/issues/2903) - Clarify purpose of tags field on interface edit form
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2852](https://github.com/digitalocean/netbox/issues/2852) - Allow filtering devices by null rack position
|
||||
* [#2884](https://github.com/digitalocean/netbox/issues/2884) - Don't display connect button for wireless interfaces
|
||||
* [#2888](https://github.com/digitalocean/netbox/issues/2888) - Correct foreground color of device roles in rack elevations
|
||||
* [#2893](https://github.com/digitalocean/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view
|
||||
* [#2895](https://github.com/digitalocean/netbox/issues/2895) - Fix filtering of nullable character fields
|
||||
* [#2901](https://github.com/digitalocean/netbox/issues/2901) - Fix ordering regions by site count
|
||||
* [#2910](https://github.com/digitalocean/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements
|
||||
* [#2912](https://github.com/digitalocean/netbox/issues/2912) - Cable type in filter form should be blank by default
|
||||
* [#2913](https://github.com/digitalocean/netbox/issues/2913) - Fix assigned prefixes link on VRF view
|
||||
* [#2914](https://github.com/digitalocean/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list
|
||||
* [#2915](https://github.com/digitalocean/netbox/issues/2915) - Fix bulk editing of pass-through ports
|
||||
|
||||
---
|
||||
|
||||
v2.5.6 (2019-02-13)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#2758](https://github.com/digitalocean/netbox/issues/2758) - Add cable trace button to pass-through ports
|
||||
* [#2839](https://github.com/digitalocean/netbox/issues/2839) - Add "110 punch" type for pass-through ports
|
||||
* [#2854](https://github.com/digitalocean/netbox/issues/2854) - Enable bulk editing of pass-through ports
|
||||
* [#2866](https://github.com/digitalocean/netbox/issues/2866) - Add cellular interface types (GSM/CDMA/LTE)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2841](https://github.com/digitalocean/netbox/issues/2841) - Fix filtering by VRF for prefix and IP address lists
|
||||
* [#2844](https://github.com/digitalocean/netbox/issues/2844) - Correct display of far cable end for pass-through ports
|
||||
* [#2845](https://github.com/digitalocean/netbox/issues/2845) - Enable filtering of rack unit list by unit ID
|
||||
* [#2856](https://github.com/digitalocean/netbox/issues/2856) - Fix navigation links between LAG interfaces and their members on device view
|
||||
* [#2857](https://github.com/digitalocean/netbox/issues/2857) - Add `display_name` to DeviceType API serializer; fix DeviceType list for bulk device edit
|
||||
* [#2862](https://github.com/digitalocean/netbox/issues/2862) - Follow return URL when connecting a cable
|
||||
* [#2864](https://github.com/digitalocean/netbox/issues/2864) - Correct display of VRF name when no RD is assigned
|
||||
* [#2877](https://github.com/digitalocean/netbox/issues/2877) - Fixed device role label display on light background color
|
||||
* [#2880](https://github.com/digitalocean/netbox/issues/2880) - Sanitize user password if an exception is raised during login
|
||||
|
||||
---
|
||||
|
||||
v2.5.5 (2019-01-31)
|
||||
|
||||
## Enhancements
|
||||
|
||||
@@ -45,13 +45,13 @@ and run `upgrade.sh`.
|
||||
|
||||
## Supported SDK
|
||||
|
||||
- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox.
|
||||
- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox
|
||||
|
||||
## Community SDK
|
||||
|
||||
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2.
|
||||
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox
|
||||
- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox
|
||||
|
||||
## Ansible Inventory
|
||||
|
||||
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox.
|
||||
|
||||
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for Netbox
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
# https://github.com/django/django
|
||||
Django
|
||||
|
||||
# Django caching using Redis
|
||||
# https://github.com/Suor/django-cacheops
|
||||
django-cacheops
|
||||
|
||||
# Django middleware which permits cross-domain API requests
|
||||
# https://github.com/OttoYiu/django-cors-headers
|
||||
django-cors-headers
|
||||
@@ -18,6 +22,14 @@ django-filter
|
||||
# https://github.com/django-mptt/django-mptt
|
||||
django-mptt
|
||||
|
||||
# Django integration for RQ (Reqis queuing)
|
||||
# https://github.com/rq/django-rq
|
||||
django-rq
|
||||
|
||||
# Prometheus metrics library for Django
|
||||
# https://github.com/korfuri/django-prometheus
|
||||
django-prometheus
|
||||
|
||||
# Abstraction models for rendering and paginating HTML tables
|
||||
# https://github.com/jieter/django-tables2
|
||||
django-tables2
|
||||
|
||||
21
docs/additional-features/caching.md
Normal file
21
docs/additional-features/caching.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# Caching
|
||||
|
||||
To improve performance, NetBox supports caching for most object and list views. Caching is implemented using Redis,
|
||||
and [django-cacheops](https://github.com/Suor/django-cacheops)
|
||||
|
||||
Several management commands are avaliable for administrators to manaully invalidate cache entries in extenuating circumstances.
|
||||
|
||||
To invalidate a specifc model instance (for example a Device with ID 34):
|
||||
```
|
||||
python netbox/manage.py invalidate dcim.Device.34
|
||||
```
|
||||
|
||||
To invalidate all instance of a model:
|
||||
```
|
||||
python netbox/manage.py invalidate dcim.Device
|
||||
```
|
||||
|
||||
To flush the entire cache database:
|
||||
```
|
||||
python netbox/manage.py invalidate all
|
||||
```
|
||||
22
docs/additional-features/prometheus-metrics.md
Normal file
22
docs/additional-features/prometheus-metrics.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Prometheus Metrics
|
||||
|
||||
NetBox supports optionally exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a popular time series metric platform used for monitoring.
|
||||
|
||||
NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g. `https://netbox.local/metrics`. Metric exposition can be toggled with the `METRICS_ENABLED` configuration setting. Metrics are exposed by default.
|
||||
|
||||
## Metric Types
|
||||
|
||||
NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of different types of metrics, including:
|
||||
|
||||
- Per model insert, update, and delete counters
|
||||
- Per view request counters
|
||||
- Per view request latency histograms
|
||||
- Request body size histograms
|
||||
- Response body size histograms
|
||||
- Response code counters
|
||||
- Database connection, execution, and error counters
|
||||
- Cache hit, miss, and invalidation counters
|
||||
- Django middleware latency histograms
|
||||
- Other Django related metadata metrics
|
||||
|
||||
For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance.
|
||||
@@ -128,4 +128,4 @@ Reports can be run on the CLI by invoking the management command:
|
||||
python3 manage.py runreport <module>
|
||||
```
|
||||
|
||||
One or more report modules may be specified.
|
||||
where ``<module>`` is the name of the python file in the ``reports`` directory without the ``.py`` extension. One or more report modules may be specified.
|
||||
|
||||
@@ -4,14 +4,6 @@ A webhook defines an HTTP request that is sent to an external application when c
|
||||
|
||||
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
|
||||
|
||||
## Installation
|
||||
|
||||
If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below.
|
||||
|
||||
* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages)
|
||||
* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration)
|
||||
* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation)
|
||||
|
||||
## Requests
|
||||
|
||||
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
|
||||
|
||||
@@ -104,24 +104,37 @@ The base serializer is used to represent the default view of a model. This inclu
|
||||
}
|
||||
```
|
||||
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
|
||||
## Related Objects
|
||||
|
||||
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
|
||||
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to display the object to a user. When performing write API actions (`POST`, `PUT`, and `PATCH`), related objects may be specified by either numeric ID (primary key), or by a set of attributes sufficiently unique to return the desired object.
|
||||
|
||||
For example, when creating a new device, its rack can be specified by NetBox ID (PK):
|
||||
|
||||
```
|
||||
{
|
||||
"id": 1201,
|
||||
"site": 7,
|
||||
"group": 4,
|
||||
"vid": 102,
|
||||
"name": "Users-Floor2",
|
||||
"tenant": null,
|
||||
"status": 1,
|
||||
"role": 9,
|
||||
"description": ""
|
||||
"name": "MyNewDevice",
|
||||
"rack": 123,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Or by a set of nested attributes used to identify the rack:
|
||||
|
||||
```
|
||||
{
|
||||
"name": "MyNewDevice",
|
||||
"rack": {
|
||||
"site": {
|
||||
"name": "Equinix DC6"
|
||||
},
|
||||
"name": "R204"
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Note that if the provided parameters do not return exactly one object, a validation error is raised.
|
||||
|
||||
## Brief Format
|
||||
|
||||
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
|
||||
@@ -261,7 +274,7 @@ A list of objects retrieved via the API can be filtered by passing one or more q
|
||||
GET /api/ipam/prefixes/?status=1
|
||||
```
|
||||
|
||||
The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
|
||||
Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
|
||||
|
||||
```
|
||||
GET /api/ipam/prefixes/?status=1&status=2
|
||||
|
||||
@@ -44,6 +44,14 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
---
|
||||
|
||||
## CACHE_TIMEOUT
|
||||
|
||||
Default: 900
|
||||
|
||||
The number of seconds to retain cache entries before automatically invalidating them.
|
||||
|
||||
---
|
||||
|
||||
## CHANGELOG_RETENTION
|
||||
|
||||
Default: 90
|
||||
@@ -89,6 +97,30 @@ In order to send email, NetBox needs an email server configured. The following i
|
||||
|
||||
---
|
||||
|
||||
## EXEMPT_VIEW_PERMISSIONS
|
||||
|
||||
Default: Empty list
|
||||
|
||||
A list of models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users and by anonymous users.
|
||||
|
||||
List models in the form `<app>.<model>`. For example:
|
||||
|
||||
```
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
'dcim.site',
|
||||
'dcim.region',
|
||||
'ipam.prefix',
|
||||
]
|
||||
```
|
||||
|
||||
To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.)
|
||||
|
||||
```
|
||||
EXEMPT_VIEW_PERMISSIONS = ['*']
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ENFORCE_GLOBAL_UNIQUE
|
||||
|
||||
Default: False
|
||||
@@ -165,6 +197,14 @@ The file path to the location where media files (such as image attachments) are
|
||||
|
||||
---
|
||||
|
||||
## METRICS_ENABLED
|
||||
|
||||
Default: True
|
||||
|
||||
Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics/) documentation for more details.
|
||||
|
||||
---
|
||||
|
||||
## NAPALM_USERNAME
|
||||
|
||||
## NAPALM_PASSWORD
|
||||
@@ -269,49 +309,3 @@ SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00
|
||||
DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m.
|
||||
SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Redis Connection Settings
|
||||
|
||||
[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../additional-features/webhooks/). A Redis connection is configured using a dictionary similar to the following:
|
||||
|
||||
```
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
}
|
||||
```
|
||||
|
||||
### DATABASE
|
||||
|
||||
Default: 0
|
||||
|
||||
The Redis database ID.
|
||||
|
||||
### DEFAULT_TIMEOUT
|
||||
|
||||
Default: 300
|
||||
|
||||
The timeout value to use when connecting to the Redis server (in seconds).
|
||||
|
||||
### HOST
|
||||
|
||||
Default: localhost
|
||||
|
||||
The hostname or IP address of the Redis server.
|
||||
|
||||
### PORT
|
||||
|
||||
Default: 6379
|
||||
|
||||
The TCP port to use when connecting to the Redis server.
|
||||
|
||||
### PASSWORD
|
||||
|
||||
Default: None
|
||||
|
||||
The password to use when authenticating to the Redis server (optional).
|
||||
|
||||
@@ -43,3 +43,44 @@ This is a secret cryptographic key is used to improve the security of cookies an
|
||||
Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox.
|
||||
|
||||
`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key.
|
||||
|
||||
---
|
||||
|
||||
## REDIS
|
||||
|
||||
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
|
||||
NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching
|
||||
functionality (as well as other planned features).
|
||||
|
||||
Redis is configured using a configuration setting similar to `DATABASE`:
|
||||
|
||||
* HOST - Name or IP address of the Redis server (use `localhost` if running locally)
|
||||
* PORT - TCP port of the Redis service; leave blank for default port (6379)
|
||||
* PASSWORD - Redis password (if set)
|
||||
* DATABASE - Numeric database ID for webhooks
|
||||
* CACHE_DATABASE - Numeric database ID for caching
|
||||
* DEFAULT_TIMEOUT - Connection timeout in seconds
|
||||
* SSL - Use SSL connection to Redis
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'CACHE_DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
```
|
||||
|
||||
!!! note:
|
||||
If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
|
||||
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
|
||||
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
|
||||
|
||||
!!! warning:
|
||||
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
|
||||
processing data being lost in cache flushing events.
|
||||
|
||||
@@ -13,6 +13,10 @@ Some devices house child devices which share physical resources, like space and
|
||||
!!! note
|
||||
This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane.
|
||||
|
||||
For that application you should create a single Device for the chassis, and add Interfaces directly to it. Interfaces can be created in bulk using range patterns, e.g. "Gi1/[1-24]".
|
||||
|
||||
Add Inventory Items if you want to record the line cards themselves as separate entities. There is no explicit relationship between each interface and its line card, but it may be implied by the naming (e.g. interfaces "Gi1/x" are on line card 1)
|
||||
|
||||
## Manufacturers
|
||||
|
||||
Each device type must be assigned to a manufacturer. The model number of a device type must be unique to its manufacturer.
|
||||
@@ -77,7 +81,7 @@ Power ports connect only to power outlets. Power connections can be marked as ei
|
||||
|
||||
Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*.
|
||||
|
||||
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned.
|
||||
Each interface is a assigned a type denoting its physical properties. Two special types exist: the "virtual" type can be used to designate logical interfaces (such as SVIs), and the "LAG" type can be used to desinate link aggregation groups to which physical interfaces can be assigned.
|
||||
|
||||
Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address.
|
||||
|
||||
@@ -93,6 +97,10 @@ Pass-through ports can also be used to model "bump in the wire" devices, such as
|
||||
|
||||
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
|
||||
|
||||
Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices.
|
||||
|
||||
Therefore, Device bays are **not** suitable for modeling chassis-based switches and routers. These should instead be modeled as a single Device, with the line cards as Inventory Items.
|
||||
|
||||
## Device Roles
|
||||
|
||||
Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches.
|
||||
@@ -111,7 +119,7 @@ The assignment of platforms to devices is an optional feature, and may be disreg
|
||||
|
||||
# Inventory Items
|
||||
|
||||
Inventory items represent hardware components installed within a device, such as a power supply or CPU. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer.
|
||||
Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Like device types, each item can optionally be assigned a manufacturer.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ Update the following static libraries to their most recent stable release:
|
||||
|
||||
* Bootstrap 3
|
||||
* Font Awesome 4
|
||||
* Select2
|
||||
* jQuery
|
||||
* jQuery UI
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
# Installation
|
||||
|
||||
This section of the documentation discusses installing and configuring the NetBox application.
|
||||
This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
|
||||
|
||||
**Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
|
||||
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev
|
||||
```
|
||||
|
||||
**CentOS**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y epel-release
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
|
||||
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
|
||||
# easy_install-3.6 pip
|
||||
# ln -s /usr/bin/python36 /usr/bin/python3
|
||||
```
|
||||
@@ -90,28 +90,6 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
|
||||
# pip3 install napalm
|
||||
```
|
||||
|
||||
## Webhooks (Optional)
|
||||
|
||||
[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one.
|
||||
|
||||
**Ubuntu**
|
||||
|
||||
```no-highlight
|
||||
# apt-get install -y redis-server
|
||||
```
|
||||
|
||||
**CentOS**
|
||||
|
||||
```no-highlight
|
||||
# yum install -y redis
|
||||
```
|
||||
|
||||
Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks.
|
||||
|
||||
```no-highlight
|
||||
# pip3 install django-rq
|
||||
```
|
||||
|
||||
# Configuration
|
||||
|
||||
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Migration
|
||||
|
||||
!!! warning
|
||||
Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible.
|
||||
As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later.
|
||||
|
||||
## Ubuntu
|
||||
|
||||
@@ -36,9 +36,3 @@ If using LDAP authentication, install the `django-auth-ldap` package:
|
||||
```no-highlight
|
||||
# pip3 install django-auth-ldap
|
||||
```
|
||||
|
||||
If using Webhooks, install the `django-rq` package:
|
||||
|
||||
```no-highlight
|
||||
# pip3 install django-rq
|
||||
```
|
||||
|
||||
@@ -36,6 +36,8 @@ pages:
|
||||
- Reports: 'additional-features/reports.md'
|
||||
- Webhooks: 'additional-features/webhooks.md'
|
||||
- Change Logging: 'additional-features/change-logging.md'
|
||||
- Caching: 'additional-features/caching.md'
|
||||
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
|
||||
- Administration:
|
||||
- Replicating NetBox: 'administration/replicating-netbox.md'
|
||||
- NetBox Shell: 'administration/netbox-shell.md'
|
||||
|
||||
@@ -17,10 +17,11 @@ __all__ = [
|
||||
|
||||
class NestedProviderSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Provider
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
|
||||
|
||||
|
||||
#
|
||||
@@ -29,10 +30,11 @@ class NestedProviderSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedCircuitTypeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
|
||||
|
||||
|
||||
class NestedCircuitSerializer(WritableNestedSerializer):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from rest_framework import serializers
|
||||
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
|
||||
|
||||
from circuits.constants import CIRCUIT_STATUS_CHOICES
|
||||
@@ -16,12 +17,13 @@ from .nested_serializers import *
|
||||
|
||||
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
tags = TagListSerializerField(required=False)
|
||||
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',
|
||||
'custom_fields', 'created', 'last_updated',
|
||||
'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -30,10 +32,11 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class CircuitTypeSerializer(ValidatedModelSerializer):
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CircuitType
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'circuit_count']
|
||||
|
||||
|
||||
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
@@ -27,7 +28,9 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
#
|
||||
|
||||
class ProviderViewSet(CustomFieldModelViewSet):
|
||||
queryset = Provider.objects.prefetch_related('tags')
|
||||
queryset = Provider.objects.prefetch_related('tags').annotate(
|
||||
circuit_count=Count('circuits')
|
||||
)
|
||||
serializer_class = serializers.ProviderSerializer
|
||||
filterset_class = filters.ProviderFilter
|
||||
|
||||
@@ -47,7 +50,9 @@ class ProviderViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class CircuitTypeViewSet(ModelViewSet):
|
||||
queryset = CircuitType.objects.all()
|
||||
queryset = CircuitType.objects.annotate(
|
||||
circuit_count=Count('circuits')
|
||||
)
|
||||
serializer_class = serializers.CircuitTypeSerializer
|
||||
filterset_class = filters.CircuitTypeFilter
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
widget=APISelect(
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
)
|
||||
|
||||
25
netbox/circuits/migrations/0015_custom_tag_models.py
Normal file
25
netbox/circuits/migrations/0015_custom_tag_models.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 06:56
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0014_circuittermination_description'),
|
||||
('extras', '0019_tag_taggeditem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='circuit',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='provider',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
@@ -6,7 +6,7 @@ from taggit.managers import TaggableManager
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import CableTermination
|
||||
from extras.models import CustomFieldModel, ObjectChange
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object
|
||||
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
|
||||
@@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
|
||||
|
||||
@@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
|
||||
|
||||
@@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.circuit.change_circuittype %}
|
||||
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<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 %}
|
||||
"""
|
||||
|
||||
@@ -59,7 +59,7 @@ class CircuitTypeTable(BaseTable):
|
||||
name = tables.LinkColumn()
|
||||
circuit_count = tables.Column(verbose_name='Circuits')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
|
||||
template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
||||
@@ -61,7 +61,7 @@ class ProviderTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_provider(self):
|
||||
@@ -162,7 +162,7 @@ class CircuitTypeTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['circuit_count', 'id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_circuittype(self):
|
||||
|
||||
95
netbox/circuits/tests/test_views.py
Normal file
95
netbox/circuits/tests/test_views.py
Normal file
@@ -0,0 +1,95 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from circuits.models import Circuit, CircuitType, Provider
|
||||
from utilities.testing import create_test_user
|
||||
|
||||
|
||||
class ProviderTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['circuits.view_provider'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
Provider.objects.bulk_create([
|
||||
Provider(name='Provider 1', slug='provider-1', asn=65001),
|
||||
Provider(name='Provider 2', slug='provider-2', asn=65002),
|
||||
Provider(name='Provider 3', slug='provider-3', asn=65003),
|
||||
])
|
||||
|
||||
def test_provider_list(self):
|
||||
|
||||
url = reverse('circuits:provider_list')
|
||||
params = {
|
||||
"q": "test",
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_provider(self):
|
||||
|
||||
provider = Provider.objects.first()
|
||||
response = self.client.get(provider.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class CircuitTypeTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['circuits.view_circuittype'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
CircuitType.objects.bulk_create([
|
||||
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
|
||||
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
|
||||
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
|
||||
])
|
||||
|
||||
def test_circuittype_list(self):
|
||||
|
||||
url = reverse('circuits:circuittype_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class CircuitTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['circuits.view_circuit'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
|
||||
provider.save()
|
||||
|
||||
circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
|
||||
circuittype.save()
|
||||
|
||||
Circuit.objects.bulk_create([
|
||||
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
|
||||
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
|
||||
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
|
||||
])
|
||||
|
||||
def test_circuit_list(self):
|
||||
|
||||
url = reverse('circuits:circuit_list')
|
||||
params = {
|
||||
"provider": Provider.objects.first().slug,
|
||||
"type": CircuitType.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_circuit(self):
|
||||
|
||||
circuit = Circuit.objects.first()
|
||||
response = self.client.get(circuit.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -43,7 +43,7 @@ urlpatterns = [
|
||||
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
|
||||
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
|
||||
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
|
||||
url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
|
||||
url(r'^circuit-terminations/(?P<pk>\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.generic import View
|
||||
|
||||
from extras.models import Graph, GRAPH_TYPE_PROVIDER
|
||||
@@ -20,7 +22,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
|
||||
# Providers
|
||||
#
|
||||
|
||||
class ProviderListView(ObjectListView):
|
||||
class ProviderListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_provider'
|
||||
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
|
||||
filter = filters.ProviderFilter
|
||||
filter_form = forms.ProviderFilterForm
|
||||
@@ -28,7 +31,8 @@ class ProviderListView(ObjectListView):
|
||||
template_name = 'circuits/provider_list.html'
|
||||
|
||||
|
||||
class ProviderView(View):
|
||||
class ProviderView(PermissionRequiredMixin, View):
|
||||
permission_required = 'circuits.view_provider'
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
@@ -93,7 +97,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Circuit Types
|
||||
#
|
||||
|
||||
class CircuitTypeListView(ObjectListView):
|
||||
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_circuittype'
|
||||
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
|
||||
table = tables.CircuitTypeTable
|
||||
template_name = 'circuits/circuittype_list.html'
|
||||
@@ -128,7 +133,8 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Circuits
|
||||
#
|
||||
|
||||
class CircuitListView(ObjectListView):
|
||||
class CircuitListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'circuits.view_circuit'
|
||||
queryset = Circuit.objects.select_related(
|
||||
'provider', 'type', 'tenant'
|
||||
).prefetch_related(
|
||||
@@ -140,7 +146,8 @@ class CircuitListView(ObjectListView):
|
||||
template_name = 'circuits/circuit_list.html'
|
||||
|
||||
|
||||
class CircuitView(View):
|
||||
class CircuitView(PermissionRequiredMixin, View):
|
||||
permission_required = 'circuits.view_circuit'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ from rest_framework import serializers
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
|
||||
Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate,
|
||||
Region, Site, VirtualChassis,
|
||||
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole,
|
||||
RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
)
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
@@ -21,7 +21,9 @@ __all__ = [
|
||||
'NestedInterfaceSerializer',
|
||||
'NestedManufacturerSerializer',
|
||||
'NestedPlatformSerializer',
|
||||
'NestedPowerFeedSerializer',
|
||||
'NestedPowerOutletSerializer',
|
||||
'NestedPowerPanelSerializer',
|
||||
'NestedPowerPortSerializer',
|
||||
'NestedRackGroupSerializer',
|
||||
'NestedRackRoleSerializer',
|
||||
@@ -40,10 +42,11 @@ __all__ = [
|
||||
|
||||
class NestedRegionSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'site_count']
|
||||
|
||||
|
||||
class NestedSiteSerializer(WritableNestedSerializer):
|
||||
@@ -60,26 +63,29 @@ class NestedSiteSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedRackGroupSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count']
|
||||
|
||||
|
||||
class NestedRackRoleSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'rack_count']
|
||||
|
||||
|
||||
class NestedRackSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = ['id', 'url', 'name', 'display_name']
|
||||
fields = ['id', 'url', 'name', 'display_name', 'device_count']
|
||||
|
||||
|
||||
#
|
||||
@@ -88,19 +94,21 @@ class NestedRackSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedManufacturerSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
|
||||
|
||||
|
||||
class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
|
||||
manufacturer = NestedManufacturerSerializer(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug']
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
|
||||
|
||||
|
||||
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
|
||||
@@ -125,18 +133,22 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedDeviceRoleSerializer(WritableNestedSerializer):
|
||||
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', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
class NestedPlatformSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
class NestedDeviceSerializer(WritableNestedSerializer):
|
||||
@@ -243,7 +255,29 @@ class NestedCableSerializer(serializers.ModelSerializer):
|
||||
class NestedVirtualChassisSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
|
||||
master = NestedDeviceSerializer()
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'master']
|
||||
fields = ['id', 'url', 'master', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels/feeds
|
||||
#
|
||||
|
||||
class NestedPowerPanelSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'url', 'name', 'powerfeed_count']
|
||||
|
||||
|
||||
class NestedPowerFeedSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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
|
||||
@@ -6,8 +8,9 @@ from dcim.constants import *
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.customfields import CustomFieldModelSerializer
|
||||
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
|
||||
@@ -35,6 +38,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_connected_endpoint(self, obj):
|
||||
"""
|
||||
Return the appropriate serializer for the type of connected object.
|
||||
@@ -55,10 +59,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
|
||||
|
||||
class RegionSerializer(serializers.ModelSerializer):
|
||||
parent = NestedRegionSerializer(required=False, allow_null=True)
|
||||
site_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Region
|
||||
fields = ['id', 'name', 'slug', 'parent']
|
||||
fields = ['id', 'name', 'slug', 'parent', 'site_count']
|
||||
|
||||
|
||||
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
@@ -67,19 +72,20 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
time_zone = TimeZoneField(required=False)
|
||||
tags = TagListSerializerField(required=False)
|
||||
count_prefixes = serializers.IntegerField(read_only=True)
|
||||
count_vlans = serializers.IntegerField(read_only=True)
|
||||
count_racks = serializers.IntegerField(read_only=True)
|
||||
count_devices = serializers.IntegerField(read_only=True)
|
||||
count_circuits = serializers.IntegerField(read_only=True)
|
||||
circuit_count = serializers.IntegerField(read_only=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Site
|
||||
fields = [
|
||||
'id', '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', 'count_prefixes',
|
||||
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
|
||||
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
|
||||
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -89,17 +95,19 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
|
||||
class RackGroupSerializer(ValidatedModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
fields = ['id', 'name', 'slug', 'site', 'rack_count']
|
||||
|
||||
|
||||
class RackRoleSerializer(ValidatedModelSerializer):
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RackRole
|
||||
fields = ['id', 'name', 'slug', 'color']
|
||||
fields = ['id', 'name', 'slug', 'color', 'rack_count']
|
||||
|
||||
|
||||
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
@@ -112,13 +120,15 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
|
||||
outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, 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',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
|
||||
]
|
||||
# Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This
|
||||
# prevents facility_id from being interpreted as a required field.
|
||||
@@ -165,23 +175,26 @@ class RackReservationSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class ManufacturerSerializer(ValidatedModelSerializer):
|
||||
devicetype_count = serializers.IntegerField(read_only=True)
|
||||
inventoryitem_count = serializers.IntegerField(read_only=True)
|
||||
platform_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Manufacturer
|
||||
fields = ['id', 'name', 'slug']
|
||||
fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count']
|
||||
|
||||
|
||||
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
manufacturer = NestedManufacturerSerializer()
|
||||
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
|
||||
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count',
|
||||
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -206,24 +219,34 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw']
|
||||
|
||||
|
||||
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
power_port = PowerPortTemplateSerializer(
|
||||
required=False
|
||||
)
|
||||
feed_leg = ChoiceField(
|
||||
choices=POWERFEED_LEG_CHOICES,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutletTemplate
|
||||
fields = ['id', 'device_type', 'name']
|
||||
fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg']
|
||||
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
|
||||
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
|
||||
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
|
||||
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
|
||||
fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only']
|
||||
|
||||
|
||||
class RearPortTemplateSerializer(ValidatedModelSerializer):
|
||||
@@ -258,18 +281,25 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class DeviceRoleSerializer(ValidatedModelSerializer):
|
||||
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']
|
||||
fields = ['id', 'name', 'slug', 'color', 'vm_role', 'device_count', 'virtualmachine_count']
|
||||
|
||||
|
||||
class PlatformSerializer(ValidatedModelSerializer):
|
||||
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
|
||||
device_count = serializers.IntegerField(read_only=True)
|
||||
virtualmachine_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Platform
|
||||
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
|
||||
fields = [
|
||||
'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count',
|
||||
'virtualmachine_count',
|
||||
]
|
||||
|
||||
|
||||
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
@@ -312,6 +342,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
|
||||
return data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
|
||||
def get_parent_device(self, obj):
|
||||
try:
|
||||
device_bay = obj.parent_bay
|
||||
@@ -334,6 +365,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
||||
'custom_fields', 'config_context', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_config_context(self, obj):
|
||||
return obj.get_config_context()
|
||||
|
||||
@@ -346,8 +378,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
|
||||
'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -359,21 +391,33 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
|
||||
'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
cable = NestedCableSerializer(read_only=True)
|
||||
tags = TagListSerializerField(required=False)
|
||||
power_port = NestedPowerPortSerializer(
|
||||
required=False
|
||||
)
|
||||
feed_leg = ChoiceField(
|
||||
choices=POWERFEED_LEG_CHOICES,
|
||||
required=False,
|
||||
allow_null=True
|
||||
)
|
||||
cable = NestedCableSerializer(
|
||||
read_only=True
|
||||
)
|
||||
tags = TagListSerializerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@@ -385,14 +429,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = [
|
||||
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
|
||||
'tags',
|
||||
'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
|
||||
'connected_endpoint', 'connection_status', 'cable', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
|
||||
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
|
||||
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
|
||||
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
@@ -408,9 +454,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = [
|
||||
'id', 'device', 'name', 'form_factor', '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', 'device', 'name', 'type', 'form_factor', '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()
|
||||
@@ -475,7 +521,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['id', 'device', 'name', 'installed_device', 'tags']
|
||||
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
|
||||
|
||||
|
||||
#
|
||||
@@ -502,12 +548,16 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class CableSerializer(ValidatedModelSerializer):
|
||||
termination_a_type = ContentTypeField()
|
||||
termination_b_type = ContentTypeField()
|
||||
termination_a_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
|
||||
)
|
||||
termination_b_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
|
||||
)
|
||||
termination_a = serializers.SerializerMethodField(read_only=True)
|
||||
termination_b = serializers.SerializerMethodField(read_only=True)
|
||||
status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
|
||||
length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False)
|
||||
length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False, allow_null=True)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
@@ -531,9 +581,11 @@ class CableSerializer(ValidatedModelSerializer):
|
||||
|
||||
return data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_termination_a(self, obj):
|
||||
return self._get_termination(obj, 'a')
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_termination_b(self, obj):
|
||||
return self._get_termination(obj, 'b')
|
||||
|
||||
@@ -564,6 +616,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
model = Interface
|
||||
fields = ['interface_a', 'interface_b', 'connection_status']
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
|
||||
def get_interface_a(self, obj):
|
||||
context = {'request': self.context['request']}
|
||||
return NestedInterfaceSerializer(instance=obj, context=context).data
|
||||
@@ -576,7 +629,61 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
|
||||
master = NestedDeviceSerializer()
|
||||
tags = TagListSerializerField(required=False)
|
||||
member_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'master', 'domain', 'tags']
|
||||
fields = ['id', 'master', 'domain', 'tags', 'member_count']
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelSerializer(ValidatedModelSerializer):
|
||||
site = NestedSiteSerializer()
|
||||
rack_group = NestedRackGroupSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
powerfeed_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
|
||||
|
||||
|
||||
class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
power_panel = NestedPowerPanelSerializer()
|
||||
rack = NestedRackSerializer(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=None
|
||||
)
|
||||
type = ChoiceField(
|
||||
choices=POWERFEED_TYPE_CHOICES,
|
||||
default=POWERFEED_TYPE_PRIMARY
|
||||
)
|
||||
status = ChoiceField(
|
||||
choices=POWERFEED_STATUS_CHOICES,
|
||||
default=POWERFEED_STATUS_ACTIVE
|
||||
)
|
||||
supply = ChoiceField(
|
||||
choices=POWERFEED_SUPPLY_CHOICES,
|
||||
default=POWERFEED_SUPPLY_AC
|
||||
)
|
||||
phase = ChoiceField(
|
||||
choices=POWERFEED_PHASE_CHOICES,
|
||||
default=POWERFEED_PHASE_SINGLE
|
||||
)
|
||||
tags = TagListSerializerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'power_factor', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
@@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet)
|
||||
# Virtual chassis
|
||||
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
|
||||
|
||||
# Power
|
||||
router.register(r'power-panels', views.PowerPanelViewSet)
|
||||
router.register(r'power-feeds', views.PowerFeedViewSet)
|
||||
|
||||
# Miscellaneous
|
||||
router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import F, Q
|
||||
from django.db.models import Count, F
|
||||
from django.http import HttpResponseForbidden
|
||||
from django.shortcuts import get_object_or_404
|
||||
from drf_yasg import openapi
|
||||
@@ -12,19 +12,24 @@ from rest_framework.mixins import ListModelMixin
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet, ViewSet
|
||||
|
||||
from circuits.models import Circuit
|
||||
from dcim import filters
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
from extras.api.serializers import RenderedGraphSerializer
|
||||
from extras.api.views import CustomFieldModelViewSet
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
from ipam.models import Prefix, VLAN
|
||||
from utilities.api import (
|
||||
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
|
||||
)
|
||||
from utilities.utils import get_subquery
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import serializers
|
||||
from .exceptions import MissingFilterException
|
||||
|
||||
@@ -35,14 +40,16 @@ from .exceptions import MissingFilterException
|
||||
|
||||
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(Cable, ['length_unit', 'status', 'type']),
|
||||
(Cable, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']),
|
||||
(ConsolePort, ['connection_status']),
|
||||
(Device, ['face', 'status']),
|
||||
(DeviceType, ['subdevice_role']),
|
||||
(FrontPort, ['type']),
|
||||
(FrontPortTemplate, ['type']),
|
||||
(Interface, ['form_factor', 'mode']),
|
||||
(InterfaceTemplate, ['form_factor']),
|
||||
(Interface, ['type', 'mode']),
|
||||
(InterfaceTemplate, ['type']),
|
||||
(PowerOutlet, ['feed_leg']),
|
||||
(PowerOutletTemplate, ['feed_leg']),
|
||||
(PowerPort, ['connection_status']),
|
||||
(Rack, ['outer_unit', 'status', 'type', 'width']),
|
||||
(RearPort, ['type']),
|
||||
@@ -90,7 +97,9 @@ class CableTraceMixin(object):
|
||||
#
|
||||
|
||||
class RegionViewSet(ModelViewSet):
|
||||
queryset = Region.objects.all()
|
||||
queryset = Region.objects.annotate(
|
||||
site_count=Count('sites')
|
||||
)
|
||||
serializer_class = serializers.RegionSerializer
|
||||
filterset_class = filters.RegionFilter
|
||||
|
||||
@@ -100,7 +109,18 @@ class RegionViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class SiteViewSet(CustomFieldModelViewSet):
|
||||
queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
|
||||
queryset = Site.objects.select_related(
|
||||
'region', 'tenant'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'site'),
|
||||
rack_count=get_subquery(Rack, 'site'),
|
||||
prefix_count=get_subquery(Prefix, 'site'),
|
||||
vlan_count=get_subquery(VLAN, 'site'),
|
||||
circuit_count=get_subquery(Circuit, 'terminations__site'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
|
||||
)
|
||||
serializer_class = serializers.SiteSerializer
|
||||
filterset_class = filters.SiteFilter
|
||||
|
||||
@@ -120,7 +140,9 @@ class SiteViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class RackGroupViewSet(ModelViewSet):
|
||||
queryset = RackGroup.objects.select_related('site')
|
||||
queryset = RackGroup.objects.select_related('site').annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
serializer_class = serializers.RackGroupSerializer
|
||||
filterset_class = filters.RackGroupFilter
|
||||
|
||||
@@ -130,7 +152,9 @@ class RackGroupViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class RackRoleViewSet(ModelViewSet):
|
||||
queryset = RackRole.objects.all()
|
||||
queryset = RackRole.objects.annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
serializer_class = serializers.RackRoleSerializer
|
||||
filterset_class = filters.RackRoleFilter
|
||||
|
||||
@@ -140,7 +164,14 @@ class RackRoleViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class RackViewSet(CustomFieldModelViewSet):
|
||||
queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
|
||||
queryset = Rack.objects.select_related(
|
||||
'site', 'group__site', 'role', 'tenant'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
).annotate(
|
||||
device_count=get_subquery(Device, 'rack'),
|
||||
powerfeed_count=get_subquery(PowerFeed, 'rack')
|
||||
)
|
||||
serializer_class = serializers.RackSerializer
|
||||
filterset_class = filters.RackFilter
|
||||
|
||||
@@ -159,6 +190,11 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
exclude_pk = None
|
||||
elevation = rack.get_rack_units(face, exclude_pk)
|
||||
|
||||
# Enable filtering rack units by ID
|
||||
q = request.GET.get('q', None)
|
||||
if q:
|
||||
elevation = [u for u in elevation if q in str(u['id'])]
|
||||
|
||||
page = self.paginate_queryset(elevation)
|
||||
if page is not None:
|
||||
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
|
||||
@@ -184,7 +220,11 @@ class RackReservationViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class ManufacturerViewSet(ModelViewSet):
|
||||
queryset = Manufacturer.objects.all()
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
|
||||
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
|
||||
platform_count=get_subquery(Platform, 'manufacturer')
|
||||
)
|
||||
serializer_class = serializers.ManufacturerSerializer
|
||||
filterset_class = filters.ManufacturerFilter
|
||||
|
||||
@@ -194,7 +234,9 @@ class ManufacturerViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceTypeViewSet(CustomFieldModelViewSet):
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
|
||||
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate(
|
||||
device_count=Count('instances')
|
||||
)
|
||||
serializer_class = serializers.DeviceTypeSerializer
|
||||
filterset_class = filters.DeviceTypeFilter
|
||||
|
||||
@@ -256,7 +298,10 @@ class DeviceBayTemplateViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class DeviceRoleViewSet(ModelViewSet):
|
||||
queryset = DeviceRole.objects.all()
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
device_count=get_subquery(Device, 'device_role'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'role')
|
||||
)
|
||||
serializer_class = serializers.DeviceRoleSerializer
|
||||
filterset_class = filters.DeviceRoleFilter
|
||||
|
||||
@@ -266,7 +311,10 @@ class DeviceRoleViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class PlatformViewSet(ModelViewSet):
|
||||
queryset = Platform.objects.all()
|
||||
queryset = Platform.objects.annotate(
|
||||
device_count=get_subquery(Device, 'platform'),
|
||||
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
|
||||
)
|
||||
serializer_class = serializers.PlatformSerializer
|
||||
filterset_class = filters.PlatformFilter
|
||||
|
||||
@@ -286,16 +334,23 @@ class DeviceViewSet(CustomFieldModelViewSet):
|
||||
|
||||
def get_serializer_class(self):
|
||||
"""
|
||||
Include rendered config context when retrieving a single Device.
|
||||
Select the specific serializer based on the request context.
|
||||
|
||||
If the `brief` query param equates to True, return the NestedDeviceSerializer
|
||||
|
||||
If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer
|
||||
|
||||
Else, return the DeviceWithConfigContextSerializer
|
||||
"""
|
||||
if self.action == 'retrieve':
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
request = self.get_serializer_context()['request']
|
||||
if request.query_params.get('brief', False):
|
||||
return serializers.NestedDeviceSerializer
|
||||
|
||||
return serializers.DeviceSerializer
|
||||
elif 'config_context' in request.query_params.get('exclude', []):
|
||||
return serializers.DeviceSerializer
|
||||
|
||||
return serializers.DeviceWithConfigContextSerializer
|
||||
|
||||
@action(detail=True, url_path='napalm')
|
||||
def napalm(self, request, pk):
|
||||
@@ -395,7 +450,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
|
||||
class PowerPortViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = PowerPort.objects.select_related(
|
||||
'device', 'connected_endpoint__device', 'cable'
|
||||
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
)
|
||||
@@ -414,7 +469,9 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
|
||||
|
||||
|
||||
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
|
||||
queryset = Interface.objects.select_related(
|
||||
queryset = Interface.objects.filter(
|
||||
device__isnull=False
|
||||
).select_related(
|
||||
'device', '_connected_interface', '_connected_circuittermination', 'cable'
|
||||
).prefetch_related(
|
||||
'ip_addresses', 'tags'
|
||||
@@ -483,7 +540,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = PowerPort.objects.select_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
_connected_poweroutlet__isnull=False
|
||||
)
|
||||
serializer_class = serializers.PowerPortSerializer
|
||||
filterset_class = filters.PowerConnectionFilter
|
||||
@@ -491,11 +548,11 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.select_related(
|
||||
'device', '_connected_interface', '_connected_circuittermination'
|
||||
'device', '_connected_interface__device'
|
||||
).filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) |
|
||||
Q(_connected_circuittermination__isnull=False)
|
||||
_connected_interface__isnull=False,
|
||||
pk__lt=F('_connected_interface')
|
||||
)
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
filterset_class = filters.InterfaceConnectionFilter
|
||||
@@ -518,10 +575,40 @@ class CableViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class VirtualChassisViewSet(ModelViewSet):
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags')
|
||||
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
|
||||
member_count=Count('members')
|
||||
)
|
||||
serializer_class = serializers.VirtualChassisSerializer
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelViewSet(ModelViewSet):
|
||||
queryset = PowerPanel.objects.select_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
)
|
||||
serializer_class = serializers.PowerPanelSerializer
|
||||
filterset_class = filters.PowerPanelFilter
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedViewSet(CustomFieldModelViewSet):
|
||||
queryset = PowerFeed.objects.select_related(
|
||||
'power_panel', 'rack'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
)
|
||||
serializer_class = serializers.PowerFeedSerializer
|
||||
filterset_class = filters.PowerFeedFilter
|
||||
|
||||
|
||||
#
|
||||
# Miscellaneous
|
||||
#
|
||||
|
||||
@@ -43,6 +43,12 @@ RACK_STATUS_CHOICES = [
|
||||
[RACK_STATUS_DEPRECATED, 'Deprecated'],
|
||||
]
|
||||
|
||||
# Device rack position
|
||||
DEVICE_POSITION_CHOICES = [
|
||||
# Rack.u_height is limited to 100
|
||||
(i, 'Unit {}'.format(i)) for i in range(1, 101)
|
||||
]
|
||||
|
||||
# Parent/child device roles
|
||||
SUBDEVICE_ROLE_PARENT = True
|
||||
SUBDEVICE_ROLE_CHILD = False
|
||||
@@ -60,186 +66,200 @@ IFACE_ORDERING_CHOICES = [
|
||||
[IFACE_ORDERING_NAME, 'Name (alphabetically)']
|
||||
]
|
||||
|
||||
# Interface form factors
|
||||
# Interface types
|
||||
# Virtual
|
||||
IFACE_FF_VIRTUAL = 0
|
||||
IFACE_FF_LAG = 200
|
||||
IFACE_TYPE_VIRTUAL = 0
|
||||
IFACE_TYPE_LAG = 200
|
||||
# Ethernet
|
||||
IFACE_FF_100ME_FIXED = 800
|
||||
IFACE_FF_1GE_FIXED = 1000
|
||||
IFACE_FF_1GE_GBIC = 1050
|
||||
IFACE_FF_1GE_SFP = 1100
|
||||
IFACE_FF_10GE_FIXED = 1150
|
||||
IFACE_FF_10GE_CX4 = 1170
|
||||
IFACE_FF_10GE_SFP_PLUS = 1200
|
||||
IFACE_FF_10GE_XFP = 1300
|
||||
IFACE_FF_10GE_XENPAK = 1310
|
||||
IFACE_FF_10GE_X2 = 1320
|
||||
IFACE_FF_25GE_SFP28 = 1350
|
||||
IFACE_FF_40GE_QSFP_PLUS = 1400
|
||||
IFACE_FF_100GE_CFP = 1500
|
||||
IFACE_FF_100GE_CFP2 = 1510
|
||||
IFACE_FF_100GE_CFP4 = 1520
|
||||
IFACE_FF_100GE_CPAK = 1550
|
||||
IFACE_FF_100GE_QSFP28 = 1600
|
||||
IFACE_FF_200GE_CFP2 = 1650
|
||||
IFACE_FF_200GE_QSFP56 = 1700
|
||||
IFACE_FF_400GE_QSFP_DD = 1750
|
||||
IFACE_TYPE_100ME_FIXED = 800
|
||||
IFACE_TYPE_1GE_FIXED = 1000
|
||||
IFACE_TYPE_1GE_GBIC = 1050
|
||||
IFACE_TYPE_1GE_SFP = 1100
|
||||
IFACE_TYPE_10GE_FIXED = 1150
|
||||
IFACE_TYPE_10GE_CX4 = 1170
|
||||
IFACE_TYPE_10GE_SFP_PLUS = 1200
|
||||
IFACE_TYPE_10GE_XFP = 1300
|
||||
IFACE_TYPE_10GE_XENPAK = 1310
|
||||
IFACE_TYPE_10GE_X2 = 1320
|
||||
IFACE_TYPE_25GE_SFP28 = 1350
|
||||
IFACE_TYPE_40GE_QSFP_PLUS = 1400
|
||||
IFACE_TYPE_50GE_QSFP28 = 1420
|
||||
IFACE_TYPE_100GE_CFP = 1500
|
||||
IFACE_TYPE_100GE_CFP2 = 1510
|
||||
IFACE_TYPE_100GE_CFP4 = 1520
|
||||
IFACE_TYPE_100GE_CPAK = 1550
|
||||
IFACE_TYPE_100GE_QSFP28 = 1600
|
||||
IFACE_TYPE_200GE_CFP2 = 1650
|
||||
IFACE_TYPE_200GE_QSFP56 = 1700
|
||||
IFACE_TYPE_400GE_QSFP_DD = 1750
|
||||
# Wireless
|
||||
IFACE_FF_80211A = 2600
|
||||
IFACE_FF_80211G = 2610
|
||||
IFACE_FF_80211N = 2620
|
||||
IFACE_FF_80211AC = 2630
|
||||
IFACE_FF_80211AD = 2640
|
||||
IFACE_TYPE_80211A = 2600
|
||||
IFACE_TYPE_80211G = 2610
|
||||
IFACE_TYPE_80211N = 2620
|
||||
IFACE_TYPE_80211AC = 2630
|
||||
IFACE_TYPE_80211AD = 2640
|
||||
# Cellular
|
||||
IFACE_TYPE_GSM = 2810
|
||||
IFACE_TYPE_CDMA = 2820
|
||||
IFACE_TYPE_LTE = 2830
|
||||
# SONET
|
||||
IFACE_FF_SONET_OC3 = 6100
|
||||
IFACE_FF_SONET_OC12 = 6200
|
||||
IFACE_FF_SONET_OC48 = 6300
|
||||
IFACE_FF_SONET_OC192 = 6400
|
||||
IFACE_FF_SONET_OC768 = 6500
|
||||
IFACE_FF_SONET_OC1920 = 6600
|
||||
IFACE_FF_SONET_OC3840 = 6700
|
||||
IFACE_TYPE_SONET_OC3 = 6100
|
||||
IFACE_TYPE_SONET_OC12 = 6200
|
||||
IFACE_TYPE_SONET_OC48 = 6300
|
||||
IFACE_TYPE_SONET_OC192 = 6400
|
||||
IFACE_TYPE_SONET_OC768 = 6500
|
||||
IFACE_TYPE_SONET_OC1920 = 6600
|
||||
IFACE_TYPE_SONET_OC3840 = 6700
|
||||
# Fibrechannel
|
||||
IFACE_FF_1GFC_SFP = 3010
|
||||
IFACE_FF_2GFC_SFP = 3020
|
||||
IFACE_FF_4GFC_SFP = 3040
|
||||
IFACE_FF_8GFC_SFP_PLUS = 3080
|
||||
IFACE_FF_16GFC_SFP_PLUS = 3160
|
||||
IFACE_FF_32GFC_SFP28 = 3320
|
||||
IFACE_FF_128GFC_QSFP28 = 3400
|
||||
IFACE_TYPE_1GFC_SFP = 3010
|
||||
IFACE_TYPE_2GFC_SFP = 3020
|
||||
IFACE_TYPE_4GFC_SFP = 3040
|
||||
IFACE_TYPE_8GFC_SFP_PLUS = 3080
|
||||
IFACE_TYPE_16GFC_SFP_PLUS = 3160
|
||||
IFACE_TYPE_32GFC_SFP28 = 3320
|
||||
IFACE_TYPE_128GFC_QSFP28 = 3400
|
||||
# Serial
|
||||
IFACE_FF_T1 = 4000
|
||||
IFACE_FF_E1 = 4010
|
||||
IFACE_FF_T3 = 4040
|
||||
IFACE_FF_E3 = 4050
|
||||
IFACE_TYPE_T1 = 4000
|
||||
IFACE_TYPE_E1 = 4010
|
||||
IFACE_TYPE_T3 = 4040
|
||||
IFACE_TYPE_E3 = 4050
|
||||
# Stacking
|
||||
IFACE_FF_STACKWISE = 5000
|
||||
IFACE_FF_STACKWISE_PLUS = 5050
|
||||
IFACE_FF_FLEXSTACK = 5100
|
||||
IFACE_FF_FLEXSTACK_PLUS = 5150
|
||||
IFACE_FF_JUNIPER_VCP = 5200
|
||||
IFACE_FF_SUMMITSTACK = 5300
|
||||
IFACE_FF_SUMMITSTACK128 = 5310
|
||||
IFACE_FF_SUMMITSTACK256 = 5320
|
||||
IFACE_FF_SUMMITSTACK512 = 5330
|
||||
IFACE_TYPE_STACKWISE = 5000
|
||||
IFACE_TYPE_STACKWISE_PLUS = 5050
|
||||
IFACE_TYPE_FLEXSTACK = 5100
|
||||
IFACE_TYPE_FLEXSTACK_PLUS = 5150
|
||||
IFACE_TYPE_JUNIPER_VCP = 5200
|
||||
IFACE_TYPE_SUMMITSTACK = 5300
|
||||
IFACE_TYPE_SUMMITSTACK128 = 5310
|
||||
IFACE_TYPE_SUMMITSTACK256 = 5320
|
||||
IFACE_TYPE_SUMMITSTACK512 = 5330
|
||||
|
||||
# Other
|
||||
IFACE_FF_OTHER = 32767
|
||||
IFACE_TYPE_OTHER = 32767
|
||||
|
||||
IFACE_FF_CHOICES = [
|
||||
IFACE_TYPE_CHOICES = [
|
||||
[
|
||||
'Virtual interfaces',
|
||||
[
|
||||
[IFACE_FF_VIRTUAL, 'Virtual'],
|
||||
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
|
||||
[IFACE_TYPE_VIRTUAL, 'Virtual'],
|
||||
[IFACE_TYPE_LAG, 'Link Aggregation Group (LAG)'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'Ethernet (fixed)',
|
||||
[
|
||||
[IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
|
||||
[IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
|
||||
[IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
|
||||
[IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'],
|
||||
[IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
|
||||
[IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
|
||||
[IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
|
||||
[IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Ethernet (modular)',
|
||||
[
|
||||
[IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
|
||||
[IFACE_FF_1GE_SFP, 'SFP (1GE)'],
|
||||
[IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
|
||||
[IFACE_FF_10GE_XFP, 'XFP (10GE)'],
|
||||
[IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
|
||||
[IFACE_FF_10GE_X2, 'X2 (10GE)'],
|
||||
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
|
||||
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
||||
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
|
||||
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
|
||||
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
|
||||
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
|
||||
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
|
||||
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||
[IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
|
||||
[IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
|
||||
[IFACE_TYPE_1GE_GBIC, 'GBIC (1GE)'],
|
||||
[IFACE_TYPE_1GE_SFP, 'SFP (1GE)'],
|
||||
[IFACE_TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'],
|
||||
[IFACE_TYPE_10GE_XFP, 'XFP (10GE)'],
|
||||
[IFACE_TYPE_10GE_XENPAK, 'XENPAK (10GE)'],
|
||||
[IFACE_TYPE_10GE_X2, 'X2 (10GE)'],
|
||||
[IFACE_TYPE_25GE_SFP28, 'SFP28 (25GE)'],
|
||||
[IFACE_TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
|
||||
[IFACE_TYPE_50GE_QSFP28, 'QSFP28 (50GE)'],
|
||||
[IFACE_TYPE_100GE_CFP, 'CFP (100GE)'],
|
||||
[IFACE_TYPE_100GE_CFP2, 'CFP2 (100GE)'],
|
||||
[IFACE_TYPE_200GE_CFP2, 'CFP2 (200GE)'],
|
||||
[IFACE_TYPE_100GE_CFP4, 'CFP4 (100GE)'],
|
||||
[IFACE_TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'],
|
||||
[IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
|
||||
[IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
|
||||
[IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Wireless',
|
||||
[
|
||||
[IFACE_FF_80211A, 'IEEE 802.11a'],
|
||||
[IFACE_FF_80211G, 'IEEE 802.11b/g'],
|
||||
[IFACE_FF_80211N, 'IEEE 802.11n'],
|
||||
[IFACE_FF_80211AC, 'IEEE 802.11ac'],
|
||||
[IFACE_FF_80211AD, 'IEEE 802.11ad'],
|
||||
[IFACE_TYPE_80211A, 'IEEE 802.11a'],
|
||||
[IFACE_TYPE_80211G, 'IEEE 802.11b/g'],
|
||||
[IFACE_TYPE_80211N, 'IEEE 802.11n'],
|
||||
[IFACE_TYPE_80211AC, 'IEEE 802.11ac'],
|
||||
[IFACE_TYPE_80211AD, 'IEEE 802.11ad'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Cellular',
|
||||
[
|
||||
[IFACE_TYPE_GSM, 'GSM'],
|
||||
[IFACE_TYPE_CDMA, 'CDMA'],
|
||||
[IFACE_TYPE_LTE, 'LTE'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'SONET',
|
||||
[
|
||||
[IFACE_FF_SONET_OC3, 'OC-3/STM-1'],
|
||||
[IFACE_FF_SONET_OC12, 'OC-12/STM-4'],
|
||||
[IFACE_FF_SONET_OC48, 'OC-48/STM-16'],
|
||||
[IFACE_FF_SONET_OC192, 'OC-192/STM-64'],
|
||||
[IFACE_FF_SONET_OC768, 'OC-768/STM-256'],
|
||||
[IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'],
|
||||
[IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'],
|
||||
[IFACE_TYPE_SONET_OC3, 'OC-3/STM-1'],
|
||||
[IFACE_TYPE_SONET_OC12, 'OC-12/STM-4'],
|
||||
[IFACE_TYPE_SONET_OC48, 'OC-48/STM-16'],
|
||||
[IFACE_TYPE_SONET_OC192, 'OC-192/STM-64'],
|
||||
[IFACE_TYPE_SONET_OC768, 'OC-768/STM-256'],
|
||||
[IFACE_TYPE_SONET_OC1920, 'OC-1920/STM-640'],
|
||||
[IFACE_TYPE_SONET_OC3840, 'OC-3840/STM-1234'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'FibreChannel',
|
||||
[
|
||||
[IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
|
||||
[IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
|
||||
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
|
||||
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
|
||||
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
|
||||
[IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
|
||||
[IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
|
||||
[IFACE_TYPE_1GFC_SFP, 'SFP (1GFC)'],
|
||||
[IFACE_TYPE_2GFC_SFP, 'SFP (2GFC)'],
|
||||
[IFACE_TYPE_4GFC_SFP, 'SFP (4GFC)'],
|
||||
[IFACE_TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
|
||||
[IFACE_TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
|
||||
[IFACE_TYPE_32GFC_SFP28, 'SFP28 (32GFC)'],
|
||||
[IFACE_TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Serial',
|
||||
[
|
||||
[IFACE_FF_T1, 'T1 (1.544 Mbps)'],
|
||||
[IFACE_FF_E1, 'E1 (2.048 Mbps)'],
|
||||
[IFACE_FF_T3, 'T3 (45 Mbps)'],
|
||||
[IFACE_FF_E3, 'E3 (34 Mbps)'],
|
||||
[IFACE_TYPE_T1, 'T1 (1.544 Mbps)'],
|
||||
[IFACE_TYPE_E1, 'E1 (2.048 Mbps)'],
|
||||
[IFACE_TYPE_T3, 'T3 (45 Mbps)'],
|
||||
[IFACE_TYPE_E3, 'E3 (34 Mbps)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Stacking',
|
||||
[
|
||||
[IFACE_FF_STACKWISE, 'Cisco StackWise'],
|
||||
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
|
||||
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
|
||||
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
|
||||
[IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
|
||||
[IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
|
||||
[IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
|
||||
[IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
|
||||
[IFACE_TYPE_STACKWISE, 'Cisco StackWise'],
|
||||
[IFACE_TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'],
|
||||
[IFACE_TYPE_FLEXSTACK, 'Cisco FlexStack'],
|
||||
[IFACE_TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
|
||||
[IFACE_TYPE_JUNIPER_VCP, 'Juniper VCP'],
|
||||
[IFACE_TYPE_SUMMITSTACK, 'Extreme SummitStack'],
|
||||
[IFACE_TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'],
|
||||
[IFACE_TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'],
|
||||
[IFACE_TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'],
|
||||
]
|
||||
],
|
||||
[
|
||||
'Other',
|
||||
[
|
||||
[IFACE_FF_OTHER, 'Other'],
|
||||
[IFACE_TYPE_OTHER, 'Other'],
|
||||
]
|
||||
],
|
||||
]
|
||||
|
||||
VIRTUAL_IFACE_TYPES = [
|
||||
IFACE_FF_VIRTUAL,
|
||||
IFACE_FF_LAG,
|
||||
IFACE_TYPE_VIRTUAL,
|
||||
IFACE_TYPE_LAG,
|
||||
]
|
||||
|
||||
WIRELESS_IFACE_TYPES = [
|
||||
IFACE_FF_80211A,
|
||||
IFACE_FF_80211G,
|
||||
IFACE_FF_80211N,
|
||||
IFACE_FF_80211AC,
|
||||
IFACE_FF_80211AD,
|
||||
IFACE_TYPE_80211A,
|
||||
IFACE_TYPE_80211G,
|
||||
IFACE_TYPE_80211N,
|
||||
IFACE_TYPE_80211AC,
|
||||
IFACE_TYPE_80211AD,
|
||||
]
|
||||
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
@@ -255,18 +275,23 @@ IFACE_MODE_CHOICES = [
|
||||
|
||||
# Pass-through port types
|
||||
PORT_TYPE_8P8C = 1000
|
||||
PORT_TYPE_110_PUNCH = 1100
|
||||
PORT_TYPE_ST = 2000
|
||||
PORT_TYPE_SC = 2100
|
||||
PORT_TYPE_SC_APC = 2110
|
||||
PORT_TYPE_FC = 2200
|
||||
PORT_TYPE_LC = 2300
|
||||
PORT_TYPE_LC_APC = 2310
|
||||
PORT_TYPE_MTRJ = 2400
|
||||
PORT_TYPE_MPO = 2500
|
||||
PORT_TYPE_LSH = 2600
|
||||
PORT_TYPE_LSH_APC = 2610
|
||||
PORT_TYPE_CHOICES = [
|
||||
[
|
||||
'Copper',
|
||||
[
|
||||
[PORT_TYPE_8P8C, '8P8C'],
|
||||
[PORT_TYPE_110_PUNCH, '110 Punch'],
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -274,10 +299,13 @@ PORT_TYPE_CHOICES = [
|
||||
[
|
||||
[PORT_TYPE_FC, 'FC'],
|
||||
[PORT_TYPE_LC, 'LC'],
|
||||
[PORT_TYPE_LC_APC, 'LC/APC'],
|
||||
[PORT_TYPE_LSH, 'LSH'],
|
||||
[PORT_TYPE_LSH_APC, 'LSH/APC'],
|
||||
[PORT_TYPE_MPO, 'MPO'],
|
||||
[PORT_TYPE_MTRJ, 'MTRJ'],
|
||||
[PORT_TYPE_SC, 'SC'],
|
||||
[PORT_TYPE_SC_APC, 'SC/APC'],
|
||||
[PORT_TYPE_ST, 'ST'],
|
||||
]
|
||||
]
|
||||
@@ -290,6 +318,7 @@ DEVICE_STATUS_PLANNED = 2
|
||||
DEVICE_STATUS_STAGED = 3
|
||||
DEVICE_STATUS_FAILED = 4
|
||||
DEVICE_STATUS_INVENTORY = 5
|
||||
DEVICE_STATUS_DECOMMISSIONING = 6
|
||||
DEVICE_STATUS_CHOICES = [
|
||||
[DEVICE_STATUS_ACTIVE, 'Active'],
|
||||
[DEVICE_STATUS_OFFLINE, 'Offline'],
|
||||
@@ -297,6 +326,7 @@ DEVICE_STATUS_CHOICES = [
|
||||
[DEVICE_STATUS_STAGED, 'Staged'],
|
||||
[DEVICE_STATUS_FAILED, 'Failed'],
|
||||
[DEVICE_STATUS_INVENTORY, 'Inventory'],
|
||||
[DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'],
|
||||
]
|
||||
|
||||
# Site statuses
|
||||
@@ -317,6 +347,7 @@ STATUS_CLASSES = {
|
||||
3: 'primary',
|
||||
4: 'danger',
|
||||
5: 'default',
|
||||
6: 'warning',
|
||||
}
|
||||
|
||||
# Console/power/interface connection statuses
|
||||
@@ -341,11 +372,14 @@ CABLE_TYPE_CAT6A = 1610
|
||||
CABLE_TYPE_CAT7 = 1700
|
||||
CABLE_TYPE_DAC_ACTIVE = 1800
|
||||
CABLE_TYPE_DAC_PASSIVE = 1810
|
||||
CABLE_TYPE_MMF = 3000
|
||||
CABLE_TYPE_MMF_OM1 = 3010
|
||||
CABLE_TYPE_MMF_OM2 = 3020
|
||||
CABLE_TYPE_MMF_OM3 = 3030
|
||||
CABLE_TYPE_MMF_OM4 = 3040
|
||||
CABLE_TYPE_SMF = 3500
|
||||
CABLE_TYPE_SMF_OS1 = 3510
|
||||
CABLE_TYPE_SMF_OS2 = 3520
|
||||
CABLE_TYPE_AOC = 3800
|
||||
CABLE_TYPE_POWER = 5000
|
||||
CABLE_TYPE_CHOICES = (
|
||||
@@ -363,11 +397,14 @@ CABLE_TYPE_CHOICES = (
|
||||
),
|
||||
(
|
||||
'Fiber', (
|
||||
(CABLE_TYPE_MMF, 'Multimode Fiber'),
|
||||
(CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
|
||||
(CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
|
||||
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
|
||||
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
|
||||
(CABLE_TYPE_SMF, 'Singlemode Fiber'),
|
||||
(CABLE_TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
|
||||
(CABLE_TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
|
||||
(CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
|
||||
),
|
||||
),
|
||||
@@ -388,7 +425,7 @@ CABLE_TERMINATION_TYPE_CHOICES = {
|
||||
COMPATIBLE_TERMINATION_TYPES = {
|
||||
'consoleport': ['consoleserverport', 'frontport', 'rearport'],
|
||||
'consoleserverport': ['consoleport', 'frontport', 'rearport'],
|
||||
'powerport': ['poweroutlet'],
|
||||
'powerport': ['poweroutlet', 'powerfeed'],
|
||||
'poweroutlet': ['powerport'],
|
||||
'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
|
||||
'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
|
||||
@@ -411,3 +448,41 @@ RACK_DIMENSION_UNIT_CHOICES = (
|
||||
(LENGTH_UNIT_MILLIMETER, 'Millimeters'),
|
||||
(LENGTH_UNIT_INCH, 'Inches'),
|
||||
)
|
||||
|
||||
# Power feeds
|
||||
POWERFEED_TYPE_PRIMARY = 1
|
||||
POWERFEED_TYPE_REDUNDANT = 2
|
||||
POWERFEED_TYPE_CHOICES = (
|
||||
(POWERFEED_TYPE_PRIMARY, 'Primary'),
|
||||
(POWERFEED_TYPE_REDUNDANT, 'Redundant'),
|
||||
)
|
||||
POWERFEED_SUPPLY_AC = 1
|
||||
POWERFEED_SUPPLY_DC = 2
|
||||
POWERFEED_SUPPLY_CHOICES = (
|
||||
(POWERFEED_SUPPLY_AC, 'AC'),
|
||||
(POWERFEED_SUPPLY_DC, 'DC'),
|
||||
)
|
||||
POWERFEED_PHASE_SINGLE = 1
|
||||
POWERFEED_PHASE_3PHASE = 3
|
||||
POWERFEED_PHASE_CHOICES = (
|
||||
(POWERFEED_PHASE_SINGLE, 'Single phase'),
|
||||
(POWERFEED_PHASE_3PHASE, 'Three-phase'),
|
||||
)
|
||||
POWERFEED_STATUS_OFFLINE = 0
|
||||
POWERFEED_STATUS_ACTIVE = 1
|
||||
POWERFEED_STATUS_PLANNED = 2
|
||||
POWERFEED_STATUS_FAILED = 4
|
||||
POWERFEED_STATUS_CHOICES = (
|
||||
(POWERFEED_STATUS_ACTIVE, 'Active'),
|
||||
(POWERFEED_STATUS_OFFLINE, 'Offline'),
|
||||
(POWERFEED_STATUS_PLANNED, 'Planned'),
|
||||
(POWERFEED_STATUS_FAILED, 'Failed'),
|
||||
)
|
||||
POWERFEED_LEG_A = 1
|
||||
POWERFEED_LEG_B = 2
|
||||
POWERFEED_LEG_C = 3
|
||||
POWERFEED_LEG_CHOICES = (
|
||||
(POWERFEED_LEG_A, 'A'),
|
||||
(POWERFEED_LEG_B, 'B'),
|
||||
(POWERFEED_LEG_C, 'C'),
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ class MACAddressField(models.Field):
|
||||
try:
|
||||
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
|
||||
except AddrFormatError as e:
|
||||
raise ValidationError(e)
|
||||
raise ValidationError("Invalid MAC address format: {}".format(value))
|
||||
|
||||
def db_type(self, connection):
|
||||
return 'macaddr'
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db.models import Q
|
||||
from netaddr import EUI
|
||||
@@ -8,14 +9,17 @@ from netaddr.core import AddrFormatError
|
||||
from extras.filters import CustomFieldFilterSet
|
||||
from tenancy.models import Tenant
|
||||
from utilities.constants import COLOR_CHOICES
|
||||
from utilities.filters import NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter
|
||||
from utilities.filters import (
|
||||
NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
|
||||
)
|
||||
from virtualization.models import Cluster
|
||||
from .constants import *
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,7 +40,7 @@ class RegionFilter(NameSlugSearchFilterSet):
|
||||
fields = ['name', 'slug']
|
||||
|
||||
|
||||
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
class SiteFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -49,14 +53,15 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
region_id = django_filters.NumberFilter(
|
||||
method='filter_region',
|
||||
field_name='pk',
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = django_filters.CharFilter(
|
||||
method='filter_region',
|
||||
field_name='slug',
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -95,16 +100,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def filter_region(self, queryset, name, value):
|
||||
try:
|
||||
region = Region.objects.get(**{name: value})
|
||||
except ObjectDoesNotExist:
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(region=region) |
|
||||
Q(region__in=region.get_descendants())
|
||||
)
|
||||
|
||||
|
||||
class RackGroupFilter(NameSlugSearchFilterSet):
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -130,7 +125,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
|
||||
fields = ['name', 'slug', 'color']
|
||||
|
||||
|
||||
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
class RackFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@@ -407,7 +402,7 @@ class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
fields = ['name', 'form_factor', 'mgmt_only']
|
||||
fields = ['name', 'type', 'mgmt_only']
|
||||
|
||||
|
||||
class FrontPortTemplateFilter(DeviceTypeComponentFilterSet):
|
||||
@@ -513,14 +508,15 @@ class DeviceFilter(CustomFieldFilterSet):
|
||||
)
|
||||
name = NullableCharFieldFilter()
|
||||
asset_tag = NullableCharFieldFilter()
|
||||
region_id = django_filters.NumberFilter(
|
||||
method='filter_region',
|
||||
field_name='pk',
|
||||
region_id = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = django_filters.CharFilter(
|
||||
method='filter_region',
|
||||
field_name='slug',
|
||||
region = TreeNodeMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
field_name='site__region__in',
|
||||
to_field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -543,6 +539,10 @@ class DeviceFilter(CustomFieldFilterSet):
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
position = django_filters.ChoiceFilter(
|
||||
choices=DEVICE_POSITION_CHOICES,
|
||||
null_label='Non-racked'
|
||||
)
|
||||
cluster_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Cluster.objects.all(),
|
||||
label='VM cluster (ID)',
|
||||
@@ -602,7 +602,7 @@ class DeviceFilter(CustomFieldFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = ['serial', 'position']
|
||||
fields = ['serial', 'face']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
@@ -615,16 +615,6 @@ class DeviceFilter(CustomFieldFilterSet):
|
||||
Q(comments__icontains=value)
|
||||
).distinct()
|
||||
|
||||
def filter_region(self, queryset, name, value):
|
||||
try:
|
||||
region = Region.objects.get(**{name: value})
|
||||
except ObjectDoesNotExist:
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(site__region=region) |
|
||||
Q(site__region__in=region.get_descendants())
|
||||
)
|
||||
|
||||
def _mac_address(self, queryset, name, value):
|
||||
value = value.strip()
|
||||
if not value:
|
||||
@@ -689,7 +679,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value)
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
@@ -702,7 +693,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsolePort
|
||||
fields = ['name', 'connection_status']
|
||||
fields = ['name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class ConsoleServerPortFilter(DeviceComponentFilterSet):
|
||||
@@ -714,7 +705,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ConsoleServerPort
|
||||
fields = ['name', 'connection_status']
|
||||
fields = ['name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class PowerPortFilter(DeviceComponentFilterSet):
|
||||
@@ -726,7 +717,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerPort
|
||||
fields = ['name', 'connection_status']
|
||||
fields = ['name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class PowerOutletFilter(DeviceComponentFilterSet):
|
||||
@@ -738,7 +729,7 @@ class PowerOutletFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = PowerOutlet
|
||||
fields = ['name', 'connection_status']
|
||||
fields = ['name', 'description', 'connection_status']
|
||||
|
||||
|
||||
class InterfaceFilter(django_filters.FilterSet):
|
||||
@@ -764,9 +755,9 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
lookup_expr='isnull',
|
||||
exclude=True
|
||||
)
|
||||
type = django_filters.CharFilter(
|
||||
method='filter_type',
|
||||
label='Interface type',
|
||||
kind = django_filters.CharFilter(
|
||||
method='filter_kind',
|
||||
label='Kind of interface',
|
||||
)
|
||||
lag_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='lag',
|
||||
@@ -786,20 +777,21 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
method='filter_vlan',
|
||||
label='Assigned VID'
|
||||
)
|
||||
form_factor = django_filters.MultipleChoiceFilter(
|
||||
choices=IFACE_FF_CHOICES,
|
||||
type = django_filters.MultipleChoiceFilter(
|
||||
choices=IFACE_TYPE_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
|
||||
fields = ['name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'description']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value)
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
).distinct()
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
@@ -828,13 +820,12 @@ class InterfaceFilter(django_filters.FilterSet):
|
||||
Q(tagged_vlans__vid=value)
|
||||
)
|
||||
|
||||
def filter_type(self, queryset, name, value):
|
||||
def filter_kind(self, queryset, name, value):
|
||||
value = value.strip().lower()
|
||||
return {
|
||||
'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES),
|
||||
'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES),
|
||||
'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES),
|
||||
'lag': queryset.filter(form_factor=IFACE_FF_LAG),
|
||||
'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES),
|
||||
'virtual': queryset.filter(type__in=VIRTUAL_IFACE_TYPES),
|
||||
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
|
||||
}.get(value, queryset.none())
|
||||
|
||||
def _mac_address(self, queryset, name, value):
|
||||
@@ -857,7 +848,7 @@ class FrontPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = FrontPort
|
||||
fields = ['name', 'type']
|
||||
fields = ['name', 'type', 'description']
|
||||
|
||||
|
||||
class RearPortFilter(DeviceComponentFilterSet):
|
||||
@@ -869,14 +860,14 @@ class RearPortFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = RearPort
|
||||
fields = ['name', 'type']
|
||||
fields = ['name', 'type', 'description']
|
||||
|
||||
|
||||
class DeviceBayFilter(DeviceComponentFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = DeviceBay
|
||||
fields = ['name']
|
||||
fields = ['name', 'description']
|
||||
|
||||
|
||||
class InventoryItemFilter(DeviceComponentFilterSet):
|
||||
@@ -980,6 +971,14 @@ class CableFilter(django_filters.FilterSet):
|
||||
color = django_filters.MultipleChoiceFilter(
|
||||
choices=COLOR_CHOICES
|
||||
)
|
||||
device = django_filters.CharFilter(
|
||||
method='filter_connected_device',
|
||||
field_name='name'
|
||||
)
|
||||
device_id = django_filters.CharFilter(
|
||||
method='filter_connected_device',
|
||||
field_name='pk'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
@@ -990,6 +989,16 @@ class CableFilter(django_filters.FilterSet):
|
||||
return queryset
|
||||
return queryset.filter(label__icontains=value)
|
||||
|
||||
def filter_connected_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
try:
|
||||
device = Device.objects.get(**{name: value})
|
||||
except ObjectDoesNotExist:
|
||||
return queryset.none()
|
||||
cable_pks = device.get_cables(pk_list=True)
|
||||
return queryset.filter(pk__in=cable_pks)
|
||||
|
||||
|
||||
class ConsoleConnectionFilter(django_filters.FilterSet):
|
||||
site = django_filters.CharFilter(
|
||||
@@ -1036,14 +1045,14 @@ class PowerConnectionFilter(django_filters.FilterSet):
|
||||
def filter_site(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(connected_endpoint__device__site__slug=value)
|
||||
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
|
||||
|
||||
def filter_device(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(device__name__icontains=value) |
|
||||
Q(connected_endpoint__device__name__icontains=value)
|
||||
Q(_connected_poweroutlet__device__name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
@@ -1076,3 +1085,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
|
||||
Q(device__name__icontains=value) |
|
||||
Q(_connected_interface__device__name__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class PowerPanelFilter(django_filters.FilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
)
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
rack_group_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack_group',
|
||||
queryset=RackGroup.objects.all(),
|
||||
label='Rack group (ID)',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
|
||||
class PowerFeedFilter(CustomFieldFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
)
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
label='Search',
|
||||
)
|
||||
site_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='power_panel__site',
|
||||
queryset=Site.objects.all(),
|
||||
label='Site (ID)',
|
||||
)
|
||||
site = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='power_panel__site__slug',
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug',
|
||||
label='Site name (slug)',
|
||||
)
|
||||
power_panel_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
label='Power panel (ID)',
|
||||
)
|
||||
rack_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='rack',
|
||||
queryset=Rack.objects.all(),
|
||||
label='Rack (ID)',
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'power_factor']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -14,22 +14,6 @@ CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$')
|
||||
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
|
||||
|
||||
|
||||
class DeviceComponentManager(Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
queryset = super().get_queryset()
|
||||
table_name = self.model._meta.db_table
|
||||
sql = r"CONCAT(REGEXP_REPLACE({}.name, '\d+$', ''), LPAD(SUBSTRING({}.name FROM '\d+$'), 8, '0'))"
|
||||
|
||||
# Pad any trailing digits to effect natural sorting
|
||||
return queryset.extra(
|
||||
select={
|
||||
'name_padded': sql.format(table_name, table_name),
|
||||
}
|
||||
).order_by('name_padded')
|
||||
|
||||
|
||||
class InterfaceQuerySet(QuerySet):
|
||||
|
||||
def connectable(self):
|
||||
@@ -37,7 +21,7 @@ class InterfaceQuerySet(QuerySet):
|
||||
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
|
||||
wireless).
|
||||
"""
|
||||
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
|
||||
return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
|
||||
|
||||
|
||||
class InterfaceManager(Manager):
|
||||
@@ -64,11 +48,15 @@ class InterfaceManager(Manager):
|
||||
|
||||
The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not
|
||||
match any of the prescribed fields.
|
||||
|
||||
The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device
|
||||
components.
|
||||
"""
|
||||
|
||||
sql_col = '{}.name'.format(self.model._meta.db_table)
|
||||
ordering = [
|
||||
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name',
|
||||
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk'
|
||||
|
||||
]
|
||||
|
||||
fields = {
|
||||
|
||||
38
netbox/dcim/migrations/0069_deprecate_nullablecharfield.py
Normal file
38
netbox/dcim/migrations/0069_deprecate_nullablecharfield.py
Normal file
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 2.1.5 on 2019-02-14 14:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0068_rack_new_fields'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='asset_tag',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='name',
|
||||
field=models.CharField(blank=True, max_length=64, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='asset_tag',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='asset_tag',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='facility_id',
|
||||
field=models.CharField(blank=True, max_length=50, null=True),
|
||||
),
|
||||
]
|
||||
85
netbox/dcim/migrations/0070_custom_tag_models.py
Normal file
85
netbox/dcim/migrations/0070_custom_tag_models.py
Normal file
@@ -0,0 +1,85 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 06:56
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0069_deprecate_nullablecharfield'),
|
||||
('extras', '0019_tag_taggeditem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='consoleport',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='consoleserverport',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='device',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicebay',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='devicetype',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='frontport',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='interface',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='inventoryitem',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='poweroutlet',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='powerport',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rack',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearport',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='site',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='virtualchassis',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 2.1.7 on 2019-02-20 18:50
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0070_custom_tag_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='consoleport',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='consoleserverport',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicebay',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='description',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
]
|
||||
133
netbox/dcim/migrations/0072_powerfeeds.py
Normal file
133
netbox/dcim/migrations/0072_powerfeeds.py
Normal file
@@ -0,0 +1,133 @@
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0021_add_color_comments_changelog_to_tag'),
|
||||
('dcim', '0071_device_components_add_description'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PowerFeed',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('status', models.PositiveSmallIntegerField(default=1)),
|
||||
('type', models.PositiveSmallIntegerField(default=1)),
|
||||
('supply', models.PositiveSmallIntegerField(default=1)),
|
||||
('phase', models.PositiveSmallIntegerField(default=1)),
|
||||
('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
|
||||
('power_factor', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
|
||||
('comments', models.TextField(blank=True)),
|
||||
('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['power_panel', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PowerPanel',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
('name', models.CharField(max_length=50)),
|
||||
('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')),
|
||||
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['site', 'name'],
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='power_panel',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='rack',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='connected_endpoint',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerfeed',
|
||||
name='connection_status',
|
||||
field=models.NullBooleanField(),
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='powerport',
|
||||
old_name='connected_endpoint',
|
||||
new_name='_connected_poweroutlet',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='_connected_powerfeed',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerport',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerporttemplate',
|
||||
name='allocated_draw',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='powerporttemplate',
|
||||
name='maximum_draw',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='powerpanel',
|
||||
unique_together={('site', 'name')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='powerfeed',
|
||||
unique_together={('power_panel', 'name')},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='feed_leg',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlet',
|
||||
name='power_port',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='feed_leg',
|
||||
field=models.PositiveSmallIntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='poweroutlettemplate',
|
||||
name='power_port',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'),
|
||||
),
|
||||
]
|
||||
23
netbox/dcim/migrations/0073_interface_form_factor_to_type.py
Normal file
23
netbox/dcim/migrations/0073_interface_form_factor_to_type.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.1.7 on 2019-04-12 17:27
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0072_powerfeeds'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='interface',
|
||||
old_name='form_factor',
|
||||
new_name='type',
|
||||
),
|
||||
migrations.RenameField(
|
||||
model_name='interfacetemplate',
|
||||
old_name='form_factor',
|
||||
new_name='type',
|
||||
),
|
||||
]
|
||||
@@ -9,21 +9,21 @@ from django.contrib.postgres.fields import ArrayField, JSONField
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, Q
|
||||
from django.db.models import Count, Q, Sum
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
|
||||
from utilities.fields import ColorField, NullableCharField
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from utilities.fields import ColorField
|
||||
from utilities.managers import NaturalOrderingManager
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
from .constants import *
|
||||
from .exceptions import LoopDetected
|
||||
from .fields import ASNField, MACAddressField
|
||||
from .managers import DeviceComponentManager, InterfaceManager
|
||||
from .managers import InterfaceManager
|
||||
|
||||
|
||||
class ComponentTemplateModel(models.Model):
|
||||
@@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model):
|
||||
|
||||
|
||||
class ComponentModel(models.Model):
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
@@ -169,9 +173,9 @@ class CableTermination(models.Model):
|
||||
def get_cable_peer(self):
|
||||
if self.cable is None:
|
||||
return None
|
||||
if self._cabled_as_a:
|
||||
if self._cabled_as_a.exists():
|
||||
return self.cable.termination_b
|
||||
if self._cabled_as_b:
|
||||
if self._cabled_as_b.exists():
|
||||
return self.cable.termination_a
|
||||
|
||||
|
||||
@@ -217,8 +221,7 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
self.parent.name if self.parent else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def site_count(self):
|
||||
def get_site_count(self):
|
||||
return Site.objects.filter(
|
||||
Q(region=self) |
|
||||
Q(region__in=self.get_descendants())
|
||||
@@ -320,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
|
||||
@@ -360,32 +363,6 @@ class Site(ChangeLoggedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
@property
|
||||
def count_prefixes(self):
|
||||
return self.prefixes.count()
|
||||
|
||||
@property
|
||||
def count_vlans(self):
|
||||
return self.vlans.count()
|
||||
|
||||
@property
|
||||
def count_racks(self):
|
||||
return Rack.objects.filter(site=self).count()
|
||||
|
||||
@property
|
||||
def count_devices(self):
|
||||
return Device.objects.filter(site=self).count()
|
||||
|
||||
@property
|
||||
def count_circuits(self):
|
||||
from circuits.models import Circuit
|
||||
return Circuit.objects.filter(terminations__site=self).count()
|
||||
|
||||
@property
|
||||
def count_vms(self):
|
||||
from virtualization.models import VirtualMachine
|
||||
return VirtualMachine.objects.filter(cluster__site=self).count()
|
||||
|
||||
|
||||
#
|
||||
# Racks
|
||||
@@ -470,7 +447,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
facility_id = NullableCharField(
|
||||
facility_id = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -511,7 +488,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
blank=True,
|
||||
verbose_name='Serial number'
|
||||
)
|
||||
asset_tag = NullableCharField(
|
||||
asset_tag = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -567,7 +544,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
|
||||
@@ -915,7 +892,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
|
||||
@@ -980,7 +957,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
@property
|
||||
def full_name(self):
|
||||
def display_name(self):
|
||||
return '{} {}'.format(self.manufacturer.name, self.model)
|
||||
|
||||
@property
|
||||
@@ -1005,7 +982,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
|
||||
max_length=50
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1028,7 +1005,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
|
||||
max_length=50
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1050,8 +1027,20 @@ class PowerPortTemplate(ComponentTemplateModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum current draw (watts)"
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Allocated current draw (watts)"
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1073,8 +1062,21 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
power_port = models.ForeignKey(
|
||||
to='dcim.PowerPortTemplate',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='poweroutlet_templates'
|
||||
)
|
||||
feed_leg = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_LEG_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1083,6 +1085,14 @@ class PowerOutletTemplate(ComponentTemplateModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device_type != self.device_type:
|
||||
raise ValidationError(
|
||||
"Parent power port ({}) must belong to the same device type".format(self.power_port)
|
||||
)
|
||||
|
||||
|
||||
class InterfaceTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -1096,9 +1106,9 @@ class InterfaceTemplate(ComponentTemplateModel):
|
||||
name = models.CharField(
|
||||
max_length=64
|
||||
)
|
||||
form_factor = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_FF_CHOICES,
|
||||
default=IFACE_FF_10GE_SFP_PLUS
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_TYPE_CHOICES,
|
||||
default=IFACE_TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
mgmt_only = models.BooleanField(
|
||||
default=False,
|
||||
@@ -1114,6 +1124,22 @@ class InterfaceTemplate(ComponentTemplateModel):
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
# TODO: Remove in v2.7
|
||||
@property
|
||||
def form_factor(self):
|
||||
"""
|
||||
Backward-compatibility for form_factor
|
||||
"""
|
||||
return self.type
|
||||
|
||||
# TODO: Remove in v2.7
|
||||
@form_factor.setter
|
||||
def form_factor(self, value):
|
||||
"""
|
||||
Backward-compatibility for form_factor
|
||||
"""
|
||||
self.type = value
|
||||
|
||||
|
||||
class FrontPortTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
@@ -1140,7 +1166,7 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1189,7 +1215,7 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1212,7 +1238,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
max_length=50
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
objects = NaturalOrderingManager()
|
||||
|
||||
class Meta:
|
||||
ordering = ['device_type', 'name']
|
||||
@@ -1354,7 +1380,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = NullableCharField(
|
||||
name = models.CharField(
|
||||
max_length=64,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -1365,7 +1391,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
blank=True,
|
||||
verbose_name='Serial number'
|
||||
)
|
||||
asset_tag = NullableCharField(
|
||||
asset_tag = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
null=True,
|
||||
@@ -1456,7 +1482,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status',
|
||||
@@ -1611,7 +1637,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
self.device_type.poweroutlet_templates.all()]
|
||||
)
|
||||
Interface.objects.bulk_create(
|
||||
[Interface(device=self, name=template.name, form_factor=template.form_factor,
|
||||
[Interface(device=self, name=template.name, type=template.type,
|
||||
mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()]
|
||||
)
|
||||
RearPort.objects.bulk_create([
|
||||
@@ -1705,6 +1731,21 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
|
||||
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
|
||||
return Interface.objects.filter(filter)
|
||||
|
||||
def get_cables(self, pk_list=False):
|
||||
"""
|
||||
Return a QuerySet or PK list matching all Cables connected to a component of this Device.
|
||||
"""
|
||||
cable_pks = []
|
||||
for component_model in [
|
||||
ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
|
||||
]:
|
||||
cable_pks += component_model.objects.filter(
|
||||
device=self, cable__isnull=False
|
||||
).values_list('cable', flat=True)
|
||||
if pk_list:
|
||||
return cable_pks
|
||||
return Cable.objects.filter(pk__in=cable_pks)
|
||||
|
||||
def get_children(self):
|
||||
"""
|
||||
Return the set of child Devices installed in DeviceBays within this Device.
|
||||
@@ -1743,10 +1784,10 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name']
|
||||
csv_headers = ['device', 'name', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'name']
|
||||
@@ -1762,6 +1803,7 @@ class ConsolePort(CableTermination, ComponentModel):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.description,
|
||||
)
|
||||
|
||||
|
||||
@@ -1786,10 +1828,10 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name']
|
||||
csv_headers = ['device', 'name', 'description']
|
||||
|
||||
class Meta:
|
||||
unique_together = ['device', 'name']
|
||||
@@ -1804,6 +1846,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.description,
|
||||
)
|
||||
|
||||
|
||||
@@ -1823,22 +1866,41 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
connected_endpoint = models.OneToOneField(
|
||||
maximum_draw = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Maximum current draw (watts)"
|
||||
)
|
||||
allocated_draw = models.PositiveSmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1)],
|
||||
help_text="Allocated current draw (watts)"
|
||||
)
|
||||
_connected_poweroutlet = models.OneToOneField(
|
||||
to='dcim.PowerOutlet',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='connected_endpoint',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
_connected_powerfeed = models.OneToOneField(
|
||||
to='dcim.PowerFeed',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name']
|
||||
csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'name']
|
||||
@@ -1854,8 +1916,68 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.maximum_draw,
|
||||
self.allocated_draw,
|
||||
self.description,
|
||||
)
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
if self._connected_poweroutlet:
|
||||
return self._connected_poweroutlet
|
||||
return self._connected_powerfeed
|
||||
|
||||
@connected_endpoint.setter
|
||||
def connected_endpoint(self, value):
|
||||
if value is None:
|
||||
self._connected_poweroutlet = None
|
||||
self._connected_powerfeed = None
|
||||
elif isinstance(value, PowerOutlet):
|
||||
self._connected_poweroutlet = value
|
||||
self._connected_powerfeed = None
|
||||
elif isinstance(value, PowerFeed):
|
||||
self._connected_poweroutlet = None
|
||||
self._connected_powerfeed = value
|
||||
else:
|
||||
raise ValueError(
|
||||
"Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value))
|
||||
)
|
||||
|
||||
def get_power_stats(self):
|
||||
"""
|
||||
Return power utilization statistics
|
||||
"""
|
||||
feed = self._connected_powerfeed
|
||||
if not feed or not self.poweroutlets.count():
|
||||
return None
|
||||
|
||||
stats = []
|
||||
powerfeed_available = self._connected_powerfeed.available_power
|
||||
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
|
||||
maximum_draw=Sum('maximum_draw'),
|
||||
allocated_draw=Sum('allocated_draw'),
|
||||
)
|
||||
utilization['outlets'] = len(outlet_ids)
|
||||
utilization['available_power'] = powerfeed_available
|
||||
stats.append(utilization)
|
||||
|
||||
# Per-leg stats for three-phase feeds
|
||||
if feed.phase == POWERFEED_PHASE_3PHASE:
|
||||
for leg, leg_name in POWERFEED_LEG_CHOICES:
|
||||
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
|
||||
utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate(
|
||||
maximum_draw=Sum('maximum_draw'),
|
||||
allocated_draw=Sum('allocated_draw'),
|
||||
)
|
||||
utilization['name'] = 'Leg {}'.format(leg_name)
|
||||
utilization['outlets'] = len(outlet_ids)
|
||||
utilization['available_power'] = powerfeed_available / 3
|
||||
stats.append(utilization)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
#
|
||||
# Power outlets
|
||||
@@ -1873,15 +1995,28 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
power_port = models.ForeignKey(
|
||||
to='dcim.PowerPort',
|
||||
on_delete=models.SET_NULL,
|
||||
blank=True,
|
||||
null=True,
|
||||
related_name='poweroutlets'
|
||||
)
|
||||
feed_leg = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_LEG_CHOICES,
|
||||
blank=True,
|
||||
null=True,
|
||||
help_text="Phase (for three-phase feeds)"
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name']
|
||||
csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description']
|
||||
|
||||
class Meta:
|
||||
unique_together = ['device', 'name']
|
||||
@@ -1896,8 +2031,19 @@ class PowerOutlet(CableTermination, ComponentModel):
|
||||
return (
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.power_port.name if self.power_port else None,
|
||||
self.get_feed_leg_display(),
|
||||
self.description,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate power port assignment
|
||||
if self.power_port and self.power_port.device != self.device:
|
||||
raise ValidationError(
|
||||
"Parent power port ({}) must belong to the same device".format(self.power_port)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Interfaces
|
||||
@@ -1951,9 +2097,9 @@ class Interface(CableTermination, ComponentModel):
|
||||
blank=True,
|
||||
verbose_name='Parent LAG'
|
||||
)
|
||||
form_factor = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_FF_CHOICES,
|
||||
default=IFACE_FF_10GE_SFP_PLUS
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_TYPE_CHOICES,
|
||||
default=IFACE_TYPE_10GE_SFP_PLUS
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
@@ -1974,10 +2120,6 @@ class Interface(CableTermination, ComponentModel):
|
||||
verbose_name='OOB Management',
|
||||
help_text='This interface is used only for out-of-band management'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
mode = models.PositiveSmallIntegerField(
|
||||
choices=IFACE_MODE_CHOICES,
|
||||
blank=True,
|
||||
@@ -1999,10 +2141,10 @@ class Interface(CableTermination, ComponentModel):
|
||||
)
|
||||
|
||||
objects = InterfaceManager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
|
||||
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
|
||||
'description', 'mode',
|
||||
]
|
||||
|
||||
@@ -2022,7 +2164,7 @@ class Interface(CableTermination, ComponentModel):
|
||||
self.virtual_machine.name if self.virtual_machine else None,
|
||||
self.name,
|
||||
self.lag.name if self.lag else None,
|
||||
self.get_form_factor_display(),
|
||||
self.get_type_display(),
|
||||
self.enabled,
|
||||
self.mac_address,
|
||||
self.mtu,
|
||||
@@ -2040,18 +2182,18 @@ class Interface(CableTermination, ComponentModel):
|
||||
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.form_factor is not IFACE_FF_VIRTUAL:
|
||||
if self.virtual_machine and self.type is not IFACE_TYPE_VIRTUAL:
|
||||
raise ValidationError({
|
||||
'form_factor': "Virtual machines can only have virtual interfaces."
|
||||
'type': "Virtual machines can only have virtual interfaces."
|
||||
})
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and (
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and (
|
||||
self.cable or getattr(self, 'circuit_termination', False)
|
||||
):
|
||||
raise ValidationError({
|
||||
'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
|
||||
"Disconnect the interface or choose a suitable form factor."
|
||||
'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. "
|
||||
"Disconnect the interface or choose a suitable type."
|
||||
})
|
||||
|
||||
# An interface's LAG must belong to the same device (or VC master)
|
||||
@@ -2063,15 +2205,15 @@ class Interface(CableTermination, ComponentModel):
|
||||
})
|
||||
|
||||
# A virtual interface cannot have a parent LAG
|
||||
if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
|
||||
if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None:
|
||||
raise ValidationError({
|
||||
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
|
||||
'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display())
|
||||
})
|
||||
|
||||
# Only a LAG can have LAG members
|
||||
if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
|
||||
if self.type != IFACE_TYPE_LAG and self.member_interfaces.exists():
|
||||
raise ValidationError({
|
||||
'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
|
||||
'type': "Cannot change interface type; it has LAG members ({}).".format(
|
||||
", ".join([iface.name for iface in self.member_interfaces.all()])
|
||||
)
|
||||
})
|
||||
@@ -2117,6 +2259,22 @@ class Interface(CableTermination, ComponentModel):
|
||||
object_data=serialize_object(self)
|
||||
).save()
|
||||
|
||||
# TODO: Remove in v2.7
|
||||
@property
|
||||
def form_factor(self):
|
||||
"""
|
||||
Backward-compatibility for form_factor
|
||||
"""
|
||||
return self.type
|
||||
|
||||
# TODO: Remove in v2.7
|
||||
@form_factor.setter
|
||||
def form_factor(self, value):
|
||||
"""
|
||||
Backward-compatibility for form_factor
|
||||
"""
|
||||
self.type = value
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
if self._connected_interface:
|
||||
@@ -2147,19 +2305,19 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
@property
|
||||
def is_connectable(self):
|
||||
return self.form_factor not in NONCONNECTABLE_IFACE_TYPES
|
||||
return self.type not in NONCONNECTABLE_IFACE_TYPES
|
||||
|
||||
@property
|
||||
def is_virtual(self):
|
||||
return self.form_factor in VIRTUAL_IFACE_TYPES
|
||||
return self.type in VIRTUAL_IFACE_TYPES
|
||||
|
||||
@property
|
||||
def is_wireless(self):
|
||||
return self.form_factor in WIRELESS_IFACE_TYPES
|
||||
return self.type in WIRELESS_IFACE_TYPES
|
||||
|
||||
@property
|
||||
def is_lag(self):
|
||||
return self.form_factor == IFACE_FF_LAG
|
||||
return self.type == IFACE_TYPE_LAG
|
||||
|
||||
@property
|
||||
def count_ipaddresses(self):
|
||||
@@ -2194,13 +2352,9 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
|
||||
|
||||
@@ -2260,13 +2414,9 @@ class RearPort(CableTermination, ComponentModel):
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'type', 'positions', 'description']
|
||||
|
||||
@@ -2312,10 +2462,10 @@ class DeviceBay(ComponentModel):
|
||||
null=True
|
||||
)
|
||||
|
||||
objects = DeviceComponentManager()
|
||||
tags = TaggableManager()
|
||||
objects = NaturalOrderingManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'name', 'installed_device']
|
||||
csv_headers = ['device', 'name', 'installed_device', 'description']
|
||||
|
||||
class Meta:
|
||||
ordering = ['device', 'name']
|
||||
@@ -2332,6 +2482,7 @@ class DeviceBay(ComponentModel):
|
||||
self.device.identifier,
|
||||
self.name,
|
||||
self.installed_device.identifier if self.installed_device else None,
|
||||
self.description,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
@@ -2389,7 +2540,7 @@ class InventoryItem(ComponentModel):
|
||||
verbose_name='Serial number',
|
||||
blank=True
|
||||
)
|
||||
asset_tag = NullableCharField(
|
||||
asset_tag = models.CharField(
|
||||
max_length=50,
|
||||
unique=True,
|
||||
blank=True,
|
||||
@@ -2401,12 +2552,8 @@ class InventoryItem(ComponentModel):
|
||||
default=False,
|
||||
verbose_name='Discovered'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
@@ -2424,7 +2571,7 @@ class InventoryItem(ComponentModel):
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.device.name or '{' + self.device.pk + '}',
|
||||
self.device.name or '{{{}}}'.format(self.device.pk),
|
||||
self.name,
|
||||
self.manufacturer.name if self.manufacturer else None,
|
||||
self.part_id,
|
||||
@@ -2453,7 +2600,7 @@ class VirtualChassis(ChangeLoggedModel):
|
||||
blank=True
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['master', 'domain']
|
||||
|
||||
@@ -2558,16 +2705,15 @@ class Cable(ChangeLoggedModel):
|
||||
('termination_b_type', 'termination_b_id'),
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Create an ID string for use by __str__(). We have to save a copy of pk since it's nullified after .delete()
|
||||
# is called.
|
||||
self.id_string = '#{}'.format(self.pk)
|
||||
|
||||
def __str__(self):
|
||||
return self.label or self.id_string
|
||||
if self.label:
|
||||
return self.label
|
||||
|
||||
# Save a copy of the PK on the instance since it's nullified if .delete() is called
|
||||
if not hasattr(self, 'id_string'):
|
||||
self.id_string = '#{}'.format(self.pk)
|
||||
|
||||
return self.id_string
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:cable', args=[self.pk])
|
||||
@@ -2615,11 +2761,11 @@ class Cable(ChangeLoggedModel):
|
||||
if (
|
||||
(
|
||||
isinstance(endpoint_a, Interface) and
|
||||
endpoint_a.form_factor == IFACE_FF_VIRTUAL
|
||||
endpoint_a.type == IFACE_TYPE_VIRTUAL
|
||||
) or
|
||||
(
|
||||
isinstance(endpoint_b, Interface) and
|
||||
endpoint_b.form_factor == IFACE_FF_VIRTUAL
|
||||
endpoint_b.type == IFACE_TYPE_VIRTUAL
|
||||
)
|
||||
):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
@@ -2652,6 +2798,17 @@ class Cable(ChangeLoggedModel):
|
||||
self.length_unit,
|
||||
)
|
||||
|
||||
def get_status_class(self):
|
||||
return 'success' if self.status else 'info'
|
||||
|
||||
def get_compatible_types(self):
|
||||
"""
|
||||
Return all termination types compatible with termination A.
|
||||
"""
|
||||
if self.termination_a is None:
|
||||
return
|
||||
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
|
||||
|
||||
def get_path_endpoints(self):
|
||||
"""
|
||||
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
|
||||
@@ -2674,3 +2831,174 @@ class Cable(ChangeLoggedModel):
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
||||
return a_endpoint, b_endpoint, path_status
|
||||
|
||||
|
||||
#
|
||||
# Power
|
||||
#
|
||||
|
||||
class PowerPanel(ChangeLoggedModel):
|
||||
"""
|
||||
A distribution point for electrical power; e.g. a data center RPP.
|
||||
"""
|
||||
site = models.ForeignKey(
|
||||
to='Site',
|
||||
on_delete=models.PROTECT
|
||||
)
|
||||
rack_group = models.ForeignKey(
|
||||
to='RackGroup',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
|
||||
csv_headers = ['site', 'rack_group_name', 'name']
|
||||
|
||||
class Meta:
|
||||
ordering = ['site', 'name']
|
||||
unique_together = ['site', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerpanel', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.site.name,
|
||||
self.rack_group.name if self.rack_group else None,
|
||||
self.name,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# RackGroup must belong to assigned Site
|
||||
if self.rack_group and self.rack_group.site != self.site:
|
||||
raise ValidationError("Rack group {} ({}) is in a different site than {}".format(
|
||||
self.rack_group, self.rack_group.site, self.site
|
||||
))
|
||||
|
||||
|
||||
class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
"""
|
||||
power_panel = models.ForeignKey(
|
||||
to='PowerPanel',
|
||||
on_delete=models.PROTECT,
|
||||
related_name='powerfeeds'
|
||||
)
|
||||
rack = models.ForeignKey(
|
||||
to='Rack',
|
||||
on_delete=models.PROTECT,
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connected_endpoint = models.OneToOneField(
|
||||
to='dcim.PowerPort',
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='+',
|
||||
blank=True,
|
||||
null=True
|
||||
)
|
||||
connection_status = models.NullBooleanField(
|
||||
choices=CONNECTION_STATUS_CHOICES,
|
||||
blank=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=50
|
||||
)
|
||||
status = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_STATUS_CHOICES,
|
||||
default=POWERFEED_STATUS_ACTIVE
|
||||
)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_TYPE_CHOICES,
|
||||
default=POWERFEED_TYPE_PRIMARY
|
||||
)
|
||||
supply = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_SUPPLY_CHOICES,
|
||||
default=POWERFEED_SUPPLY_AC
|
||||
)
|
||||
phase = models.PositiveSmallIntegerField(
|
||||
choices=POWERFEED_PHASE_CHOICES,
|
||||
default=POWERFEED_PHASE_SINGLE
|
||||
)
|
||||
voltage = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1)],
|
||||
default=120
|
||||
)
|
||||
amperage = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1)],
|
||||
default=20
|
||||
)
|
||||
power_factor = models.PositiveSmallIntegerField(
|
||||
validators=[MinValueValidator(1), MaxValueValidator(100)],
|
||||
default=80,
|
||||
help_text="Maximum permissible draw (percentage)"
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
custom_field_values = GenericRelation(
|
||||
to='extras.CustomFieldValue',
|
||||
content_type_field='obj_type',
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
|
||||
'amperage', 'power_factor', 'comments',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['power_panel', 'name']
|
||||
unique_together = ['power_panel', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:powerfeed', args=[self.pk])
|
||||
|
||||
def to_csv(self):
|
||||
return (
|
||||
self.power_panel.name,
|
||||
self.rack.name if self.rack else None,
|
||||
self.name,
|
||||
self.get_status_display(),
|
||||
self.get_type_display(),
|
||||
self.get_supply_display(),
|
||||
self.get_phase_display(),
|
||||
self.voltage,
|
||||
self.amperage,
|
||||
self.power_factor,
|
||||
self.comments,
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Rack must belong to same Site as PowerPanel
|
||||
if self.rack and self.rack.site != self.power_panel.site:
|
||||
raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format(
|
||||
self.rack, self.rack.site, self.power_panel, self.power_panel.site
|
||||
))
|
||||
|
||||
def get_type_class(self):
|
||||
return STATUS_CLASSES[self.type]
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CLASSES[self.status]
|
||||
|
||||
@property
|
||||
def available_power(self):
|
||||
kva = self.voltage * self.amperage * self.power_factor
|
||||
if self.phase == POWERFEED_PHASE_3PHASE:
|
||||
return kva * 1.732
|
||||
return kva
|
||||
|
||||
@@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
|
||||
REGION_LINK = """
|
||||
@@ -44,7 +45,7 @@ REGION_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_region %}
|
||||
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:region_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -56,7 +57,7 @@ RACKGROUP_ACTIONS = """
|
||||
<i class="fa fa-eye"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackgroup %}
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning" title="Edit">
|
||||
<i class="glyphicon glyphicon-pencil"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -67,7 +68,7 @@ RACKROLE_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackrole %}
|
||||
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -88,7 +89,7 @@ RACKRESERVATION_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -97,7 +98,7 @@ MANUFACTURER_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_manufacturer %}
|
||||
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:manufacturer_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 %}
|
||||
"""
|
||||
|
||||
@@ -106,7 +107,7 @@ DEVICEROLE_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:devicerole_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 %}
|
||||
"""
|
||||
|
||||
@@ -131,18 +132,23 @@ PLATFORM_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_platform %}
|
||||
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:platform_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 %}
|
||||
"""
|
||||
|
||||
DEVICE_ROLE = """
|
||||
<label class="label" style="background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
{% load helpers %}
|
||||
<label class="label" style="color: {{ record.device_role.color|fgcolor }}; background-color: #{{ record.device_role.color }}">{{ value }}</label>
|
||||
"""
|
||||
|
||||
STATUS_LABEL = """
|
||||
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
|
||||
"""
|
||||
|
||||
TYPE_LABEL = """
|
||||
<span class="label label-{{ record.get_type_class }}">{{ record.get_type_display }}</span>
|
||||
"""
|
||||
|
||||
DEVICE_PRIMARY_IP = """
|
||||
{{ record.primary_ip6.address.ip|default:"" }}
|
||||
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
|
||||
@@ -167,7 +173,7 @@ VIRTUALCHASSIS_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -183,6 +189,10 @@ CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
|
||||
"""
|
||||
|
||||
POWERPANEL_POWERFEED_COUNT = """
|
||||
<a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ record.pk }}">{{ value }}</a>
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# Regions
|
||||
@@ -195,7 +205,7 @@ class RegionTable(BaseTable):
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=REGION_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
@@ -238,7 +248,7 @@ class RackGroupTable(BaseTable):
|
||||
slug = tables.Column()
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKGROUP_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
@@ -257,7 +267,7 @@ class RackRoleTable(BaseTable):
|
||||
rack_count = tables.Column(verbose_name='Racks')
|
||||
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -308,7 +318,7 @@ class RackReservationTable(BaseTable):
|
||||
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
|
||||
unit_list = tables.Column(orderable=False, verbose_name='Units')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
|
||||
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -326,7 +336,7 @@ class ManufacturerTable(BaseTable):
|
||||
devicetype_count = tables.Column(verbose_name='Device Types')
|
||||
platform_count = tables.Column(verbose_name='Platforms')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -409,7 +419,7 @@ class InterfaceTemplateTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InterfaceTemplate
|
||||
fields = ('pk', 'name', 'mgmt_only', 'form_factor')
|
||||
fields = ('pk', 'name', 'mgmt_only', 'type')
|
||||
empty_text = "None"
|
||||
|
||||
|
||||
@@ -462,7 +472,7 @@ class DeviceRoleTable(BaseTable):
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=DEVICEROLE_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
@@ -491,7 +501,7 @@ class PlatformTable(BaseTable):
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=PLATFORM_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
@@ -517,7 +527,7 @@ class DeviceTable(BaseTable):
|
||||
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
|
||||
device_type = tables.LinkColumn(
|
||||
'dcim:devicetype', args=[Accessor('device_type.pk')], verbose_name='Type',
|
||||
text=lambda record: record.device_type.full_name
|
||||
text=lambda record: record.device_type.display_name
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -587,7 +597,7 @@ class InterfaceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
|
||||
fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
|
||||
|
||||
|
||||
class FrontPortTable(BaseTable):
|
||||
@@ -646,6 +656,9 @@ class CableTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name=''
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
)
|
||||
length = tables.TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by='_abs_length'
|
||||
@@ -694,7 +707,8 @@ class PowerConnectionTable(BaseTable):
|
||||
args=[Accessor('connected_endpoint.device.pk')],
|
||||
verbose_name='PDU'
|
||||
)
|
||||
connected_endpoint = tables.Column(
|
||||
outlet = tables.Column(
|
||||
accessor=Accessor('_connected_poweroutlet'),
|
||||
verbose_name='Outlet'
|
||||
)
|
||||
device = tables.LinkColumn(
|
||||
@@ -707,7 +721,7 @@ class PowerConnectionTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPort
|
||||
fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status')
|
||||
fields = ('pdu', 'outlet', 'device', 'name', 'connection_status')
|
||||
|
||||
|
||||
class InterfaceConnectionTable(BaseTable):
|
||||
@@ -729,18 +743,18 @@ class InterfaceConnectionTable(BaseTable):
|
||||
)
|
||||
device_b = tables.LinkColumn(
|
||||
viewname='dcim:device',
|
||||
accessor=Accessor('connected_endpoint.device'),
|
||||
args=[Accessor('connected_endpoint.device.pk')],
|
||||
accessor=Accessor('_connected_interface.device'),
|
||||
args=[Accessor('_connected_interface.device.pk')],
|
||||
verbose_name='Device B'
|
||||
)
|
||||
interface_b = tables.LinkColumn(
|
||||
viewname='dcim:interface',
|
||||
accessor=Accessor('connected_endpoint.name'),
|
||||
args=[Accessor('connected_endpoint.pk')],
|
||||
accessor=Accessor('_connected_interface'),
|
||||
args=[Accessor('_connected_interface.pk')],
|
||||
verbose_name='Interface B'
|
||||
)
|
||||
description_b = tables.Column(
|
||||
accessor=Accessor('connected_endpoint.description'),
|
||||
accessor=Accessor('_connected_interface.description'),
|
||||
verbose_name='Description'
|
||||
)
|
||||
|
||||
@@ -775,10 +789,58 @@ class VirtualChassisTable(BaseTable):
|
||||
member_count = tables.Column(verbose_name='Members')
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VIRTUALCHASSIS_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'master', 'domain', 'member_count', 'actions')
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
site = tables.LinkColumn(
|
||||
viewname='dcim:site',
|
||||
args=[Accessor('site.slug')]
|
||||
)
|
||||
powerfeed_count = tables.TemplateColumn(
|
||||
template_code=POWERPANEL_POWERFEED_COUNT,
|
||||
verbose_name='Feeds'
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.LinkColumn()
|
||||
power_panel = tables.LinkColumn(
|
||||
viewname='dcim:powerpanel',
|
||||
args=[Accessor('power_panel.pk')],
|
||||
)
|
||||
rack = tables.LinkColumn(
|
||||
viewname='dcim:rack',
|
||||
args=[Accessor('rack.pk')]
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code=STATUS_LABEL
|
||||
)
|
||||
type = tables.TemplateColumn(
|
||||
template_code=TYPE_LABEL
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerFeed
|
||||
fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase')
|
||||
|
||||
@@ -7,8 +7,8 @@ from dcim.constants import *
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
|
||||
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
|
||||
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
|
||||
InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
|
||||
Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
|
||||
)
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
|
||||
@@ -47,7 +47,7 @@ class RegionTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['id', 'name', 'site_count', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_region(self):
|
||||
@@ -285,7 +285,7 @@ class RackGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['id', 'name', 'rack_count', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rackgroup(self):
|
||||
@@ -393,7 +393,7 @@ class RackRoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['id', 'name', 'rack_count', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rackrole(self):
|
||||
@@ -520,7 +520,7 @@ class RackTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['display_name', 'id', 'name', 'url']
|
||||
['device_count', 'display_name', 'id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_rack(self):
|
||||
@@ -746,7 +746,7 @@ class ManufacturerTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['devicetype_count', 'id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_manufacturer(self):
|
||||
@@ -855,7 +855,7 @@ class DeviceTypeTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'manufacturer', 'model', 'slug', 'url']
|
||||
['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_devicetype(self):
|
||||
@@ -1569,7 +1569,7 @@ class DeviceRoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
)
|
||||
|
||||
def test_create_devicerole(self):
|
||||
@@ -1677,7 +1677,7 @@ class PlatformTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
|
||||
)
|
||||
|
||||
def test_create_platform(self):
|
||||
@@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
|
||||
site=self.site1,
|
||||
cluster=self.cluster1
|
||||
)
|
||||
self.device_with_context_data = Device.objects.create(
|
||||
device_type=self.devicetype1,
|
||||
device_role=self.devicerole1,
|
||||
name='Device with context data',
|
||||
site=self.site1,
|
||||
local_context_data={
|
||||
'A': 1,
|
||||
'B': 2
|
||||
}
|
||||
)
|
||||
|
||||
def test_get_device(self):
|
||||
|
||||
@@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
|
||||
url = reverse('dcim-api:device-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
self.assertEqual(response.data['count'], 4)
|
||||
|
||||
def test_list_devices_brief(self):
|
||||
|
||||
@@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Device.objects.count(), 4)
|
||||
self.assertEqual(Device.objects.count(), 5)
|
||||
device4 = Device.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(device4.device_type_id, data['device_type'])
|
||||
self.assertEqual(device4.device_role_id, data['device_role'])
|
||||
@@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase):
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(Device.objects.count(), 6)
|
||||
self.assertEqual(Device.objects.count(), 7)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
@@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase):
|
||||
response = self.client.put(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(Device.objects.count(), 3)
|
||||
self.assertEqual(Device.objects.count(), 4)
|
||||
device1 = Device.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(device1.device_type_id, data['device_type'])
|
||||
self.assertEqual(device1.device_role_id, data['device_role'])
|
||||
@@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(Device.objects.count(), 2)
|
||||
self.assertEqual(Device.objects.count(), 3)
|
||||
|
||||
def test_config_context_included_by_default_in_list_view(self):
|
||||
|
||||
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
|
||||
|
||||
def test_config_context_excluded(self):
|
||||
|
||||
url = reverse('dcim-api:device-list') + '?exclude=config_context'
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertFalse('config_context' in response.data['results'][0])
|
||||
|
||||
|
||||
class ConsolePortTest(APITestCase):
|
||||
@@ -2529,7 +2553,7 @@ class InterfaceTest(APITestCase):
|
||||
def test_update_interface(self):
|
||||
|
||||
lag_interface = Interface.objects.create(
|
||||
device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG
|
||||
device=self.device, name='Test LAG Interface', type=IFACE_TYPE_LAG
|
||||
)
|
||||
|
||||
data = {
|
||||
@@ -2817,7 +2841,7 @@ class CableTest(APITestCase):
|
||||
)
|
||||
for device in [self.device1, self.device2]:
|
||||
for i in range(0, 10):
|
||||
Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save()
|
||||
Interface(device=device, type=IFACE_TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
|
||||
|
||||
self.cable1 = Cable(
|
||||
termination_a=self.device1.interfaces.get(name='eth0'),
|
||||
@@ -3386,23 +3410,23 @@ class VirtualChassisTest(APITestCase):
|
||||
device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
|
||||
)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
for i in range(0, 13):
|
||||
Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
|
||||
Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
|
||||
|
||||
# Create two VirtualChassis with three members each
|
||||
self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
|
||||
@@ -3433,7 +3457,7 @@ class VirtualChassisTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'master', 'url']
|
||||
['id', 'master', 'member_count', 'url']
|
||||
)
|
||||
|
||||
def test_create_virtualchassis(self):
|
||||
@@ -3508,3 +3532,260 @@ class VirtualChassisTest(APITestCase):
|
||||
self.assertTrue(
|
||||
Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
|
||||
)
|
||||
|
||||
|
||||
class PowerPanelTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
|
||||
self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2')
|
||||
self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3')
|
||||
self.powerpanel1 = PowerPanel.objects.create(
|
||||
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
|
||||
)
|
||||
self.powerpanel2 = PowerPanel.objects.create(
|
||||
site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2'
|
||||
)
|
||||
self.powerpanel3 = PowerPanel.objects.create(
|
||||
site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3'
|
||||
)
|
||||
|
||||
def test_get_powerpanel(self):
|
||||
|
||||
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.powerpanel1.name)
|
||||
|
||||
def test_list_powerpanels(self):
|
||||
|
||||
url = reverse('dcim-api:powerpanel-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 3)
|
||||
|
||||
def test_list_powerpanels_brief(self):
|
||||
|
||||
url = reverse('dcim-api:powerpanel-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'powerfeed_count', 'url']
|
||||
)
|
||||
|
||||
def test_create_powerpanel(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Power Panel 4',
|
||||
'site': self.site1.pk,
|
||||
'rack_group': self.rackgroup1.pk,
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:powerpanel-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(PowerPanel.objects.count(), 4)
|
||||
powerpanel4 = PowerPanel.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(powerpanel4.name, data['name'])
|
||||
self.assertEqual(powerpanel4.site_id, data['site'])
|
||||
self.assertEqual(powerpanel4.rack_group_id, data['rack_group'])
|
||||
|
||||
def test_create_powerpanel_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Power Panel 4',
|
||||
'site': self.site1.pk,
|
||||
'rack_group': self.rackgroup1.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Power Panel 5',
|
||||
'site': self.site1.pk,
|
||||
'rack_group': self.rackgroup2.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Test Power Panel 6',
|
||||
'site': self.site1.pk,
|
||||
'rack_group': self.rackgroup3.pk,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('dcim-api:powerpanel-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(PowerPanel.objects.count(), 6)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
self.assertEqual(response.data[2]['name'], data[2]['name'])
|
||||
|
||||
def test_update_powerpanel(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Power Panel X',
|
||||
'rack_group': self.rackgroup2.pk,
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(PowerPanel.objects.count(), 3)
|
||||
powerpanel1 = PowerPanel.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(powerpanel1.name, data['name'])
|
||||
self.assertEqual(powerpanel1.rack_group_id, data['rack_group'])
|
||||
|
||||
def test_delete_powerpanel(self):
|
||||
|
||||
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(PowerPanel.objects.count(), 2)
|
||||
|
||||
|
||||
class PowerFeedTest(APITestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
|
||||
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
|
||||
self.rack1 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
|
||||
)
|
||||
self.rack2 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
|
||||
)
|
||||
self.rack3 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42,
|
||||
)
|
||||
self.rack4 = Rack.objects.create(
|
||||
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42,
|
||||
)
|
||||
self.powerpanel1 = PowerPanel.objects.create(
|
||||
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
|
||||
)
|
||||
self.powerpanel2 = PowerPanel.objects.create(
|
||||
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2'
|
||||
)
|
||||
self.powerfeed1 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY
|
||||
)
|
||||
self.powerfeed2 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT
|
||||
)
|
||||
self.powerfeed3 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY
|
||||
)
|
||||
self.powerfeed4 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT
|
||||
)
|
||||
self.powerfeed5 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY
|
||||
)
|
||||
self.powerfeed6 = PowerFeed.objects.create(
|
||||
power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT
|
||||
)
|
||||
|
||||
def test_get_powerfeed(self):
|
||||
|
||||
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['name'], self.powerfeed1.name)
|
||||
|
||||
def test_list_powerfeeds(self):
|
||||
|
||||
url = reverse('dcim-api:powerfeed-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 6)
|
||||
|
||||
def test_list_powerfeeds_brief(self):
|
||||
|
||||
url = reverse('dcim-api:powerfeed-list')
|
||||
response = self.client.get('{}?brief=1'.format(url), **self.header)
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'url']
|
||||
)
|
||||
|
||||
def test_create_powerfeed(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Power Feed 4A',
|
||||
'power_panel': self.powerpanel1.pk,
|
||||
'rack': self.rack4.pk,
|
||||
'type': POWERFEED_TYPE_PRIMARY,
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:powerfeed-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(PowerFeed.objects.count(), 7)
|
||||
powerfeed4 = PowerFeed.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(powerfeed4.name, data['name'])
|
||||
self.assertEqual(powerfeed4.power_panel_id, data['power_panel'])
|
||||
self.assertEqual(powerfeed4.rack_id, data['rack'])
|
||||
|
||||
def test_create_powerfeed_bulk(self):
|
||||
|
||||
data = [
|
||||
{
|
||||
'name': 'Test Power Feed 4A',
|
||||
'power_panel': self.powerpanel1.pk,
|
||||
'rack': self.rack4.pk,
|
||||
'type': POWERFEED_TYPE_PRIMARY,
|
||||
},
|
||||
{
|
||||
'name': 'Test Power Feed 4B',
|
||||
'power_panel': self.powerpanel1.pk,
|
||||
'rack': self.rack4.pk,
|
||||
'type': POWERFEED_TYPE_REDUNDANT,
|
||||
},
|
||||
]
|
||||
|
||||
url = reverse('dcim-api:powerfeed-list')
|
||||
response = self.client.post(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(PowerFeed.objects.count(), 8)
|
||||
self.assertEqual(response.data[0]['name'], data[0]['name'])
|
||||
self.assertEqual(response.data[1]['name'], data[1]['name'])
|
||||
|
||||
def test_update_powerfeed(self):
|
||||
|
||||
data = {
|
||||
'name': 'Test Power Feed X',
|
||||
'rack': self.rack4.pk,
|
||||
'type': POWERFEED_TYPE_REDUNDANT,
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(PowerFeed.objects.count(), 6)
|
||||
powerfeed1 = PowerFeed.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(powerfeed1.name, data['name'])
|
||||
self.assertEqual(powerfeed1.rack_id, data['rack'])
|
||||
self.assertEqual(powerfeed1.type, data['type'])
|
||||
|
||||
def test_delete_powerfeed(self):
|
||||
|
||||
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
|
||||
response = self.client.delete(url, **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
|
||||
self.assertEqual(PowerFeed.objects.count(), 5)
|
||||
|
||||
@@ -249,7 +249,7 @@ class CableTestCase(TestCase):
|
||||
"""
|
||||
A cable connection cannot include a virtual interface
|
||||
"""
|
||||
virtual_interface = Interface(device=self.device1, name="V1", form_factor=0)
|
||||
virtual_interface = Interface(device=self.device1, name="V1", type=0)
|
||||
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
456
netbox/dcim/tests/test_views.py
Normal file
456
netbox/dcim/tests/test_views.py
Normal file
@@ -0,0 +1,456 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
|
||||
RackReservation, RackRole, Site, Region, VirtualChassis,
|
||||
)
|
||||
from utilities.testing import create_test_user
|
||||
|
||||
|
||||
class RegionTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_region'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
# Create three Regions
|
||||
for i in range(1, 4):
|
||||
Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save()
|
||||
|
||||
def test_region_list(self):
|
||||
|
||||
url = reverse('dcim:region_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class SiteTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_site'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
region = Region(name='Region 1', slug='region-1')
|
||||
region.save()
|
||||
|
||||
Site.objects.bulk_create([
|
||||
Site(name='Site 1', slug='site-1', region=region),
|
||||
Site(name='Site 2', slug='site-2', region=region),
|
||||
Site(name='Site 3', slug='site-3', region=region),
|
||||
])
|
||||
|
||||
def test_site_list(self):
|
||||
|
||||
url = reverse('dcim:site_list')
|
||||
params = {
|
||||
"region": Region.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_site(self):
|
||||
|
||||
site = Site.objects.first()
|
||||
response = self.client.get(site.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RackGroupTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_rackgroup'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
RackGroup.objects.bulk_create([
|
||||
RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
|
||||
RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
|
||||
RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
|
||||
])
|
||||
|
||||
def test_rackgroup_list(self):
|
||||
|
||||
url = reverse('dcim:rackgroup_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RackRoleTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_rackrole'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
RackRole.objects.bulk_create([
|
||||
RackRole(name='Rack Role 1', slug='rack-role-1'),
|
||||
RackRole(name='Rack Role 2', slug='rack-role-2'),
|
||||
RackRole(name='Rack Role 3', slug='rack-role-3'),
|
||||
])
|
||||
|
||||
def test_rackrole_list(self):
|
||||
|
||||
url = reverse('dcim:rackrole_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RackReservationTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_rackreservation'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
rack = Rack(name='Rack 1', site=site)
|
||||
rack.save()
|
||||
|
||||
RackReservation.objects.bulk_create([
|
||||
RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'),
|
||||
RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'),
|
||||
RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'),
|
||||
])
|
||||
|
||||
def test_rackreservation_list(self):
|
||||
|
||||
url = reverse('dcim:rackreservation_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RackTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_rack'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
Rack.objects.bulk_create([
|
||||
Rack(name='Rack 1', site=site),
|
||||
Rack(name='Rack 2', site=site),
|
||||
Rack(name='Rack 3', site=site),
|
||||
])
|
||||
|
||||
def test_rack_list(self):
|
||||
|
||||
url = reverse('dcim:rack_list')
|
||||
params = {
|
||||
"site": Site.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_rack(self):
|
||||
|
||||
rack = Rack.objects.first()
|
||||
response = self.client.get(rack.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ManufacturerTypeTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_manufacturer'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
Manufacturer.objects.bulk_create([
|
||||
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
|
||||
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
|
||||
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
|
||||
])
|
||||
|
||||
def test_manufacturer_list(self):
|
||||
|
||||
url = reverse('dcim:manufacturer_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DeviceTypeTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_devicetype'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
|
||||
manufacturer.save()
|
||||
|
||||
DeviceType.objects.bulk_create([
|
||||
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer),
|
||||
DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer),
|
||||
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer),
|
||||
])
|
||||
|
||||
def test_devicetype_list(self):
|
||||
|
||||
url = reverse('dcim:devicetype_list')
|
||||
params = {
|
||||
"manufacturer": Manufacturer.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_devicetype(self):
|
||||
|
||||
devicetype = DeviceType.objects.first()
|
||||
response = self.client.get(devicetype.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DeviceRoleTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_devicerole'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
DeviceRole.objects.bulk_create([
|
||||
DeviceRole(name='Device Role 1', slug='device-role-1'),
|
||||
DeviceRole(name='Device Role 2', slug='device-role-2'),
|
||||
DeviceRole(name='Device Role 3', slug='device-role-3'),
|
||||
])
|
||||
|
||||
def test_devicerole_list(self):
|
||||
|
||||
url = reverse('dcim:devicerole_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PlatformTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_platform'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
Platform.objects.bulk_create([
|
||||
Platform(name='Platform 1', slug='platform-1'),
|
||||
Platform(name='Platform 2', slug='platform-2'),
|
||||
Platform(name='Platform 3', slug='platform-3'),
|
||||
])
|
||||
|
||||
def test_platform_list(self):
|
||||
|
||||
url = reverse('dcim:platform_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class DeviceTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_device'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
|
||||
manufacturer.save()
|
||||
|
||||
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicetype.save()
|
||||
|
||||
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
|
||||
devicerole.save()
|
||||
|
||||
Device.objects.bulk_create([
|
||||
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
|
||||
Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
|
||||
Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
|
||||
])
|
||||
|
||||
def test_device_list(self):
|
||||
|
||||
url = reverse('dcim:device_list')
|
||||
params = {
|
||||
"device_type_id": DeviceType.objects.first().pk,
|
||||
"role": DeviceRole.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_device(self):
|
||||
|
||||
device = Device.objects.first()
|
||||
response = self.client.get(device.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class InventoryItemTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_inventoryitem'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
|
||||
manufacturer.save()
|
||||
|
||||
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicetype.save()
|
||||
|
||||
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
|
||||
devicerole.save()
|
||||
|
||||
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
|
||||
device.save()
|
||||
|
||||
InventoryItem.objects.bulk_create([
|
||||
InventoryItem(device=device, name='Inventory Item 1'),
|
||||
InventoryItem(device=device, name='Inventory Item 2'),
|
||||
InventoryItem(device=device, name='Inventory Item 3'),
|
||||
])
|
||||
|
||||
def test_inventoryitem_list(self):
|
||||
|
||||
url = reverse('dcim:inventoryitem_list')
|
||||
params = {
|
||||
"device_id": Device.objects.first().pk,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class CableTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_cable'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
|
||||
manufacturer.save()
|
||||
|
||||
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
|
||||
devicetype.save()
|
||||
|
||||
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
|
||||
devicerole.save()
|
||||
|
||||
device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
|
||||
device1.save()
|
||||
device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
|
||||
device2.save()
|
||||
|
||||
iface1 = Interface(device=device1, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface1.save()
|
||||
iface2 = Interface(device=device1, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface2.save()
|
||||
iface3 = Interface(device=device1, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface3.save()
|
||||
iface4 = Interface(device=device2, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface4.save()
|
||||
iface5 = Interface(device=device2, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface5.save()
|
||||
iface6 = Interface(device=device2, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
|
||||
iface6.save()
|
||||
|
||||
Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
|
||||
Cable(termination_a=iface2, termination_b=iface5, type=CABLE_TYPE_CAT6).save()
|
||||
Cable(termination_a=iface3, termination_b=iface6, type=CABLE_TYPE_CAT6).save()
|
||||
|
||||
def test_cable_list(self):
|
||||
|
||||
url = reverse('dcim:cable_list')
|
||||
params = {
|
||||
"type": CABLE_TYPE_CAT6,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_cable(self):
|
||||
|
||||
cable = Cable.objects.first()
|
||||
response = self.client.get(cable.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class VirtualChassisTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['dcim.view_virtualchassis'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Device Role', 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
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
# 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)
|
||||
|
||||
def test_virtualchassis_list(self):
|
||||
|
||||
url = reverse('dcim:virtualchassis_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -6,7 +6,8 @@ from secrets.views import secret_add
|
||||
from . import views
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
|
||||
PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
|
||||
PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
|
||||
app_name = 'dcim'
|
||||
@@ -161,7 +162,7 @@ urlpatterns = [
|
||||
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
|
||||
url(r'^console-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
|
||||
url(r'^console-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
|
||||
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
|
||||
url(r'^console-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
|
||||
@@ -170,7 +171,7 @@ urlpatterns = [
|
||||
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
|
||||
url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
|
||||
url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
|
||||
url(r'^console-server-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
|
||||
@@ -181,7 +182,7 @@ urlpatterns = [
|
||||
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
|
||||
url(r'^power-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
|
||||
url(r'^power-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
|
||||
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
|
||||
url(r'^power-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
|
||||
@@ -190,7 +191,7 @@ urlpatterns = [
|
||||
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
|
||||
url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
|
||||
url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
|
||||
url(r'^power-outlets/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
|
||||
@@ -202,7 +203,7 @@ urlpatterns = [
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
|
||||
url(r'^interfaces/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
|
||||
url(r'^interfaces/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
|
||||
url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
|
||||
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
|
||||
@@ -215,8 +216,9 @@ urlpatterns = [
|
||||
# Front ports
|
||||
# url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
|
||||
url(r'^devices/(?P<pk>\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
|
||||
url(r'^front-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
|
||||
url(r'^front-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
|
||||
url(r'^front-ports/(?P<pk>\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'),
|
||||
url(r'^front-ports/(?P<pk>\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
|
||||
url(r'^front-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
|
||||
@@ -226,8 +228,9 @@ urlpatterns = [
|
||||
# Rear ports
|
||||
# url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
|
||||
url(r'^devices/(?P<pk>\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'),
|
||||
url(r'^devices/(?P<pk>\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
|
||||
url(r'^devices/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
|
||||
url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
|
||||
url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/(?P<termination_b_type>[\w-]+)/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
|
||||
url(r'^rear-ports/(?P<pk>\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'),
|
||||
url(r'^rear-ports/(?P<pk>\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'),
|
||||
url(r'^rear-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
|
||||
@@ -277,4 +280,25 @@ urlpatterns = [
|
||||
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
|
||||
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
|
||||
|
||||
# Power panels
|
||||
url(r'^power-panels/$', views.PowerPanelListView.as_view(), name='powerpanel_list'),
|
||||
url(r'^power-panels/add/$', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
|
||||
url(r'^power-panels/import/$', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
|
||||
url(r'^power-panels/delete/$', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
|
||||
url(r'^power-panels/(?P<pk>\d+)/$', views.PowerPanelView.as_view(), name='powerpanel'),
|
||||
url(r'^power-panels/(?P<pk>\d+)/edit/$', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
|
||||
url(r'^power-panels/(?P<pk>\d+)/delete/$', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
|
||||
url(r'^power-panels/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
|
||||
|
||||
# Power feeds
|
||||
url(r'^power-feeds/$', views.PowerFeedListView.as_view(), name='powerfeed_list'),
|
||||
url(r'^power-feeds/add/$', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
|
||||
url(r'^power-feeds/import/$', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
|
||||
url(r'^power-feeds/edit/$', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
|
||||
url(r'^power-feeds/delete/$', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
|
||||
url(r'^power-feeds/(?P<pk>\d+)/$', views.PowerFeedView.as_view(), name='powerfeed'),
|
||||
url(r'^power-feeds/(?P<pk>\d+)/edit/$', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
|
||||
url(r'^power-feeds/(?P<pk>\d+)/delete/$', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
|
||||
url(r'^power-feeds/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
|
||||
|
||||
]
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import re
|
||||
|
||||
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.core.paginator import EmptyPage, PageNotAnInteger
|
||||
from django.db import transaction
|
||||
from django.db.models import Count, F
|
||||
@@ -9,6 +11,7 @@ from django.forms import modelformset_factory
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.http import is_safe_url
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.views.generic import View
|
||||
|
||||
@@ -29,8 +32,9 @@ from . import filters, forms, tables
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
|
||||
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
|
||||
VirtualChassis,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,8 +138,15 @@ class BulkDisconnectView(GetReturnURLMixin, View):
|
||||
# Regions
|
||||
#
|
||||
|
||||
class RegionListView(ObjectListView):
|
||||
queryset = Region.objects.all()
|
||||
class RegionListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_region'
|
||||
queryset = Region.objects.add_related_count(
|
||||
Region.objects.all(),
|
||||
Site,
|
||||
'region',
|
||||
'site_count',
|
||||
cumulative=True
|
||||
)
|
||||
filter = filters.RegionFilter
|
||||
filter_form = forms.RegionFilterForm
|
||||
table = tables.RegionTable
|
||||
@@ -172,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Sites
|
||||
#
|
||||
|
||||
class SiteListView(ObjectListView):
|
||||
class SiteListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_site'
|
||||
queryset = Site.objects.select_related('region', 'tenant')
|
||||
filter = filters.SiteFilter
|
||||
filter_form = forms.SiteFilterForm
|
||||
@@ -180,7 +192,8 @@ class SiteListView(ObjectListView):
|
||||
template_name = 'dcim/site_list.html'
|
||||
|
||||
|
||||
class SiteView(View):
|
||||
class SiteView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_site'
|
||||
|
||||
def get(self, request, slug):
|
||||
|
||||
@@ -244,7 +257,8 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
# Rack groups
|
||||
#
|
||||
|
||||
class RackGroupListView(ObjectListView):
|
||||
class RackGroupListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_rackgroup'
|
||||
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
|
||||
filter = filters.RackGroupFilter
|
||||
filter_form = forms.RackGroupFilterForm
|
||||
@@ -282,7 +296,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Rack roles
|
||||
#
|
||||
|
||||
class RackRoleListView(ObjectListView):
|
||||
class RackRoleListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_rackrole'
|
||||
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
|
||||
table = tables.RackRoleTable
|
||||
template_name = 'dcim/rackrole_list.html'
|
||||
@@ -317,7 +332,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackListView(ObjectListView):
|
||||
class RackListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_rack'
|
||||
queryset = Rack.objects.select_related(
|
||||
'site', 'group', 'tenant', 'role'
|
||||
).prefetch_related(
|
||||
@@ -331,10 +347,11 @@ class RackListView(ObjectListView):
|
||||
template_name = 'dcim/rack_list.html'
|
||||
|
||||
|
||||
class RackElevationListView(View):
|
||||
class RackElevationListView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Display a set of rack elevations side-by-side.
|
||||
"""
|
||||
permission_required = 'dcim.view_rack'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@@ -347,8 +364,9 @@ class RackElevationListView(View):
|
||||
total_count = racks.count()
|
||||
|
||||
# Pagination
|
||||
paginator = EnhancedPaginator(racks, 25)
|
||||
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
page_number = request.GET.get('page', 1)
|
||||
paginator = EnhancedPaginator(racks, per_page)
|
||||
try:
|
||||
page = paginator.page(page_number)
|
||||
except PageNotAnInteger:
|
||||
@@ -371,7 +389,8 @@ class RackElevationListView(View):
|
||||
})
|
||||
|
||||
|
||||
class RackView(View):
|
||||
class RackView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_rack'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -383,10 +402,12 @@ class RackView(View):
|
||||
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
|
||||
|
||||
reservations = RackReservation.objects.filter(rack=rack)
|
||||
power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel')
|
||||
|
||||
return render(request, 'dcim/rack.html', {
|
||||
'rack': rack,
|
||||
'reservations': reservations,
|
||||
'power_feeds': power_feeds,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
@@ -441,7 +462,8 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Rack reservations
|
||||
#
|
||||
|
||||
class RackReservationListView(ObjectListView):
|
||||
class RackReservationListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_rackreservation'
|
||||
queryset = RackReservation.objects.all()
|
||||
filter = filters.RackReservationFilter
|
||||
filter_form = forms.RackReservationFilterForm
|
||||
@@ -497,7 +519,8 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Manufacturers
|
||||
#
|
||||
|
||||
class ManufacturerListView(ObjectListView):
|
||||
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_manufacturer'
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
devicetype_count=Count('device_types', distinct=True),
|
||||
platform_count=Count('platforms', distinct=True),
|
||||
@@ -535,7 +558,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Device types
|
||||
#
|
||||
|
||||
class DeviceTypeListView(ObjectListView):
|
||||
class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_devicetype'
|
||||
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
|
||||
filter = filters.DeviceTypeFilter
|
||||
filter_form = forms.DeviceTypeFilterForm
|
||||
@@ -543,7 +567,8 @@ class DeviceTypeListView(ObjectListView):
|
||||
template_name = 'dcim/devicetype_list.html'
|
||||
|
||||
|
||||
class DeviceTypeView(View):
|
||||
class DeviceTypeView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_devicetype'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -799,7 +824,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Device roles
|
||||
#
|
||||
|
||||
class DeviceRoleListView(ObjectListView):
|
||||
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_devicerole'
|
||||
queryset = DeviceRole.objects.all()
|
||||
table = tables.DeviceRoleTable
|
||||
template_name = 'dcim/devicerole_list.html'
|
||||
@@ -834,7 +860,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Platforms
|
||||
#
|
||||
|
||||
class PlatformListView(ObjectListView):
|
||||
class PlatformListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_platform'
|
||||
queryset = Platform.objects.all()
|
||||
table = tables.PlatformTable
|
||||
template_name = 'dcim/platform_list.html'
|
||||
@@ -869,7 +896,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Devices
|
||||
#
|
||||
|
||||
class DeviceListView(ObjectListView):
|
||||
class DeviceListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_device'
|
||||
queryset = Device.objects.select_related(
|
||||
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
|
||||
)
|
||||
@@ -879,7 +907,8 @@ class DeviceListView(ObjectListView):
|
||||
template_name = 'dcim/device_list.html'
|
||||
|
||||
|
||||
class DeviceView(View):
|
||||
class DeviceView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_device'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -902,7 +931,7 @@ class DeviceView(View):
|
||||
consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
|
||||
|
||||
# Power ports
|
||||
power_ports = device.powerports.select_related('connected_endpoint__device', 'cable')
|
||||
power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable')
|
||||
|
||||
# Power outlets
|
||||
poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable')
|
||||
@@ -959,7 +988,8 @@ class DeviceView(View):
|
||||
})
|
||||
|
||||
|
||||
class DeviceInventoryView(View):
|
||||
class DeviceInventoryView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_device'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -980,7 +1010,7 @@ class DeviceInventoryView(View):
|
||||
|
||||
|
||||
class DeviceStatusView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.napalm_read'
|
||||
permission_required = ('dcim.view_device', 'dcim.napalm_read')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -993,7 +1023,7 @@ class DeviceStatusView(PermissionRequiredMixin, View):
|
||||
|
||||
|
||||
class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.napalm_read'
|
||||
permission_required = ('dcim.view_device', 'dcim.napalm_read')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -1010,7 +1040,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
||||
|
||||
|
||||
class DeviceConfigView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.napalm_read'
|
||||
permission_required = ('dcim.view_device', 'dcim.napalm_read')
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -1022,7 +1052,8 @@ class DeviceConfigView(PermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
|
||||
class DeviceConfigContextView(ObjectConfigContextView):
|
||||
class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
|
||||
permission_required = 'dcim.view_device'
|
||||
object_class = Device
|
||||
base_template = 'dcim/device.html'
|
||||
|
||||
@@ -1245,7 +1276,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Interfaces
|
||||
#
|
||||
|
||||
class InterfaceView(View):
|
||||
class InterfaceView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_interface'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -1360,6 +1392,14 @@ class FrontPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
model = FrontPort
|
||||
|
||||
|
||||
class FrontPortBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_frontport'
|
||||
queryset = FrontPort.objects.all()
|
||||
parent_model = Device
|
||||
table = tables.FrontPortTable
|
||||
form = forms.FrontPortBulkEditForm
|
||||
|
||||
|
||||
class FrontPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_frontport'
|
||||
queryset = FrontPort.objects.all()
|
||||
@@ -1404,6 +1444,14 @@ class RearPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
model = RearPort
|
||||
|
||||
|
||||
class RearPortBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_rearport'
|
||||
queryset = RearPort.objects.all()
|
||||
parent_model = Device
|
||||
table = tables.RearPortTable
|
||||
form = forms.RearPortBulkEditForm
|
||||
|
||||
|
||||
class RearPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
|
||||
permission_required = 'dcim.change_rearport'
|
||||
queryset = RearPort.objects.all()
|
||||
@@ -1610,7 +1658,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
|
||||
# Cables
|
||||
#
|
||||
|
||||
class CableListView(ObjectListView):
|
||||
class CableListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_cable'
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
'termination_a', 'termination_b'
|
||||
)
|
||||
@@ -1620,7 +1669,8 @@ class CableListView(ObjectListView):
|
||||
template_name = 'dcim/cable_list.html'
|
||||
|
||||
|
||||
class CableView(View):
|
||||
class CableView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_cable'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -1631,10 +1681,11 @@ class CableView(View):
|
||||
})
|
||||
|
||||
|
||||
class CableTraceView(View):
|
||||
class CableTraceView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Trace a cable path beginning from the given termination.
|
||||
"""
|
||||
permission_required = 'dcim.view_cable'
|
||||
|
||||
def get(self, request, model, pk):
|
||||
|
||||
@@ -1646,20 +1697,79 @@ class CableTraceView(View):
|
||||
})
|
||||
|
||||
|
||||
class CableCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
|
||||
permission_required = 'dcim.add_cable'
|
||||
model = Cable
|
||||
model_form = forms.CableCreateForm
|
||||
template_name = 'dcim/cable_connect.html'
|
||||
|
||||
def alter_obj(self, obj, request, url_args, url_kwargs):
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
# Retrieve endpoint A based on the given type and PK
|
||||
termination_a_type = url_kwargs.get('termination_a_type')
|
||||
termination_a_id = url_kwargs.get('termination_a_id')
|
||||
obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
|
||||
termination_a_type = kwargs.get('termination_a_type')
|
||||
termination_a_id = kwargs.get('termination_a_id')
|
||||
|
||||
return obj
|
||||
termination_b_type_name = kwargs.get('termination_b_type')
|
||||
self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
|
||||
|
||||
self.obj = Cable(
|
||||
termination_a=termination_a_type.objects.get(pk=termination_a_id),
|
||||
termination_b_type=self.termination_b_type
|
||||
)
|
||||
self.form_class = {
|
||||
'console-port': forms.ConnectCableToConsolePortForm,
|
||||
'console-server-port': forms.ConnectCableToConsoleServerPortForm,
|
||||
'power-port': forms.ConnectCableToPowerPortForm,
|
||||
'power-outlet': forms.ConnectCableToPowerOutletForm,
|
||||
'interface': forms.ConnectCableToInterfaceForm,
|
||||
'front-port': forms.ConnectCableToFrontPortForm,
|
||||
'power-feed': forms.ConnectCableToPowerFeedForm,
|
||||
'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
|
||||
}[termination_b_type_name]
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
|
||||
# Parse initial data manually to avoid setting field values as lists
|
||||
initial_data = {k: request.GET[k] for k in request.GET}
|
||||
|
||||
form = self.form_class(instance=self.obj, initial=initial_data)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj': self.obj,
|
||||
'obj_type': Cable._meta.verbose_name,
|
||||
'termination_b_type': self.termination_b_type.name,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request, self.obj),
|
||||
})
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
|
||||
form = self.form_class(request.POST, request.FILES, instance=self.obj)
|
||||
|
||||
if form.is_valid():
|
||||
obj = form.save()
|
||||
|
||||
msg = 'Created cable <a href="{}">{}</a>'.format(
|
||||
obj.get_absolute_url(),
|
||||
escape(obj)
|
||||
)
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
return redirect(request.get_full_path())
|
||||
|
||||
return_url = form.cleaned_data.get('return_url')
|
||||
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
|
||||
return redirect(return_url)
|
||||
else:
|
||||
return redirect(self.get_return_url(request, obj))
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'obj': self.obj,
|
||||
'obj_type': Cable._meta.verbose_name,
|
||||
'termination_b_type': self.termination_b_type.name,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request, self.obj),
|
||||
})
|
||||
|
||||
|
||||
class CableEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
@@ -1704,7 +1814,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Connections
|
||||
#
|
||||
|
||||
class ConsoleConnectionsListView(ObjectListView):
|
||||
class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
|
||||
queryset = ConsolePort.objects.select_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
).filter(
|
||||
@@ -1734,13 +1845,14 @@ class ConsoleConnectionsListView(ObjectListView):
|
||||
return csv_data
|
||||
|
||||
|
||||
class PowerConnectionsListView(ObjectListView):
|
||||
class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
|
||||
queryset = PowerPort.objects.select_related(
|
||||
'device', 'connected_endpoint__device'
|
||||
'device', '_connected_poweroutlet__device'
|
||||
).filter(
|
||||
connected_endpoint__isnull=False
|
||||
_connected_poweroutlet__isnull=False
|
||||
).order_by(
|
||||
'cable', 'connected_endpoint__device__name', 'connected_endpoint__name'
|
||||
'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name'
|
||||
)
|
||||
filter = filters.PowerConnectionFilter
|
||||
filter_form = forms.PowerConnectionFilterForm
|
||||
@@ -1764,7 +1876,8 @@ class PowerConnectionsListView(ObjectListView):
|
||||
return csv_data
|
||||
|
||||
|
||||
class InterfaceConnectionsListView(ObjectListView):
|
||||
class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.interface'
|
||||
queryset = Interface.objects.select_related(
|
||||
'device', 'cable', '_connected_interface__device'
|
||||
).filter(
|
||||
@@ -1806,7 +1919,8 @@ class InterfaceConnectionsListView(ObjectListView):
|
||||
# Inventory items
|
||||
#
|
||||
|
||||
class InventoryItemListView(ObjectListView):
|
||||
class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_inventoryitem'
|
||||
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
|
||||
filter = filters.InventoryItemFilter
|
||||
filter_form = forms.InventoryItemFilterForm
|
||||
@@ -1861,7 +1975,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Virtual chassis
|
||||
#
|
||||
|
||||
class VirtualChassisListView(ObjectListView):
|
||||
class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_virtualchassis'
|
||||
queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
|
||||
table = tables.VirtualChassisTable
|
||||
filter = filters.VirtualChassisFilter
|
||||
@@ -2090,3 +2205,143 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
|
||||
'form': form,
|
||||
'return_url': self.get_return_url(request, device),
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Power panels
|
||||
#
|
||||
|
||||
class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_powerpanel'
|
||||
queryset = PowerPanel.objects.select_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
powerfeed_count=Count('powerfeeds')
|
||||
)
|
||||
filter = filters.PowerPanelFilter
|
||||
filter_form = forms.PowerPanelFilterForm
|
||||
table = tables.PowerPanelTable
|
||||
template_name = 'dcim/powerpanel_list.html'
|
||||
|
||||
|
||||
class PowerPanelView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_powerpanel'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk)
|
||||
powerfeed_table = tables.PowerFeedTable(
|
||||
data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'),
|
||||
orderable=False
|
||||
)
|
||||
powerfeed_table.exclude = ['power_panel']
|
||||
|
||||
return render(request, 'dcim/powerpanel.html', {
|
||||
'powerpanel': powerpanel,
|
||||
'powerfeed_table': powerfeed_table,
|
||||
})
|
||||
|
||||
|
||||
class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_powerpanel'
|
||||
model = PowerPanel
|
||||
model_form = forms.PowerPanelForm
|
||||
default_return_url = 'dcim:powerpanel_list'
|
||||
|
||||
|
||||
class PowerPanelEditView(PowerPanelCreateView):
|
||||
permission_required = 'dcim.change_powerpanel'
|
||||
|
||||
|
||||
class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_powerpanel'
|
||||
model = PowerPanel
|
||||
default_return_url = 'dcim:powerpanel_list'
|
||||
|
||||
|
||||
class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.add_powerpanel'
|
||||
model_form = forms.PowerPanelCSVForm
|
||||
table = tables.PowerPanelTable
|
||||
default_return_url = 'dcim:powerpanel_list'
|
||||
|
||||
|
||||
class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_powerpanel'
|
||||
queryset = PowerPanel.objects.select_related(
|
||||
'site', 'rack_group'
|
||||
).annotate(
|
||||
rack_count=Count('powerfeeds')
|
||||
)
|
||||
filter = filters.PowerPanelFilter
|
||||
table = tables.PowerPanelTable
|
||||
default_return_url = 'dcim:powerpanel_list'
|
||||
|
||||
|
||||
#
|
||||
# Power feeds
|
||||
#
|
||||
|
||||
class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'dcim.view_powerfeed'
|
||||
queryset = PowerFeed.objects.select_related(
|
||||
'power_panel', 'rack'
|
||||
)
|
||||
filter = filters.PowerFeedFilter
|
||||
filter_form = forms.PowerFeedFilterForm
|
||||
table = tables.PowerFeedTable
|
||||
template_name = 'dcim/powerfeed_list.html'
|
||||
|
||||
|
||||
class PowerFeedView(PermissionRequiredMixin, View):
|
||||
permission_required = 'dcim.view_powerfeed'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk)
|
||||
|
||||
return render(request, 'dcim/powerfeed.html', {
|
||||
'powerfeed': powerfeed,
|
||||
})
|
||||
|
||||
|
||||
class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'dcim.add_powerfeed'
|
||||
model = PowerFeed
|
||||
model_form = forms.PowerFeedForm
|
||||
template_name = 'dcim/powerfeed_edit.html'
|
||||
default_return_url = 'dcim:powerfeed_list'
|
||||
|
||||
|
||||
class PowerFeedEditView(PowerFeedCreateView):
|
||||
permission_required = 'dcim.change_powerfeed'
|
||||
|
||||
|
||||
class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'dcim.delete_powerfeed'
|
||||
model = PowerFeed
|
||||
default_return_url = 'dcim:powerfeed_list'
|
||||
|
||||
|
||||
class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
|
||||
permission_required = 'dcim.add_powerfeed'
|
||||
model_form = forms.PowerFeedCSVForm
|
||||
table = tables.PowerFeedTable
|
||||
default_return_url = 'dcim:powerfeed_list'
|
||||
|
||||
|
||||
class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
|
||||
permission_required = 'dcim.change_powerfeed'
|
||||
queryset = PowerFeed.objects.select_related('power_panel', 'rack')
|
||||
filter = filters.PowerFeedFilter
|
||||
table = tables.PowerFeedTable
|
||||
form = forms.PowerFeedBulkEditForm
|
||||
default_return_url = 'dcim:powerfeed_list'
|
||||
|
||||
|
||||
class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'dcim.delete_powerfeed'
|
||||
queryset = PowerFeed.objects.select_related('power_panel', 'rack')
|
||||
filter = filters.PowerFeedFilter
|
||||
table = tables.PowerFeedTable
|
||||
default_return_url = 'dcim:powerfeed_list'
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib import admin
|
||||
|
||||
from netbox.admin import admin_site
|
||||
from utilities.forms import LaxURLField
|
||||
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook
|
||||
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
|
||||
|
||||
|
||||
def order_content_types(field):
|
||||
@@ -77,6 +77,34 @@ class CustomFieldAdmin(admin.ModelAdmin):
|
||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
||||
class CustomLinkForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
exclude = []
|
||||
help_texts = {
|
||||
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>.',
|
||||
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Format ContentType choices
|
||||
order_content_types(self.fields['content_type'])
|
||||
self.fields['content_type'].choices.insert(0, ('', '---------'))
|
||||
|
||||
|
||||
@admin.register(CustomLink, site=admin_site)
|
||||
class CustomLinkAdmin(admin.ModelAdmin):
|
||||
list_display = ['name', 'content_type', 'group_name', 'weight']
|
||||
form = CustomLinkForm
|
||||
|
||||
|
||||
#
|
||||
# Graphs
|
||||
#
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from drf_yasg.utils import swagger_serializer_method
|
||||
from rest_framework import serializers
|
||||
from taggit.models import Tag
|
||||
|
||||
from dcim.api.nested_serializers import (
|
||||
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
|
||||
@@ -10,12 +11,14 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
|
||||
from extras.constants import *
|
||||
from extras.models import (
|
||||
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
|
||||
Tag
|
||||
)
|
||||
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from users.api.nested_serializers import NestedUserSerializer
|
||||
from utilities.api import (
|
||||
ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer,
|
||||
ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
|
||||
ValidatedModelSerializer,
|
||||
)
|
||||
from .nested_serializers import *
|
||||
|
||||
@@ -53,10 +56,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
#
|
||||
|
||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
template_language = ChoiceField(
|
||||
choices=TEMPLATE_LANGUAGE_CHOICES,
|
||||
default=TEMPLATE_LANGUAGE_JINJA2
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
|
||||
fields = [
|
||||
'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
|
||||
'file_extension',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@@ -80,7 +90,7 @@ class TagSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'name', 'slug', 'tagged_items']
|
||||
fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
|
||||
|
||||
|
||||
#
|
||||
@@ -88,7 +98,9 @@ class TagSerializer(ValidatedModelSerializer):
|
||||
#
|
||||
|
||||
class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
content_type = ContentTypeField()
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
parent = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
@@ -112,6 +124,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
|
||||
|
||||
return data
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_parent(self, obj):
|
||||
|
||||
# Static mapping of models to their nested serializers
|
||||
@@ -205,25 +218,42 @@ class ReportDetailSerializer(ReportSerializer):
|
||||
#
|
||||
|
||||
class ObjectChangeSerializer(serializers.ModelSerializer):
|
||||
user = NestedUserSerializer(read_only=True)
|
||||
content_type = ContentTypeField(read_only=True)
|
||||
changed_object = serializers.SerializerMethodField(read_only=True)
|
||||
user = NestedUserSerializer(
|
||||
read_only=True
|
||||
)
|
||||
action = ChoiceField(
|
||||
choices=OBJECTCHANGE_ACTION_CHOICES,
|
||||
read_only=True
|
||||
)
|
||||
changed_object_type = ContentTypeField(
|
||||
read_only=True
|
||||
)
|
||||
changed_object = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
fields = [
|
||||
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data',
|
||||
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object',
|
||||
'object_data',
|
||||
]
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.DictField)
|
||||
def get_changed_object(self, obj):
|
||||
"""
|
||||
Serialize a nested representation of the changed object.
|
||||
"""
|
||||
if obj.changed_object is None:
|
||||
return None
|
||||
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
|
||||
if serializer is None:
|
||||
|
||||
try:
|
||||
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
|
||||
except SerializerNotFound:
|
||||
return obj.object_repr
|
||||
context = {'request': self.context['request']}
|
||||
context = {
|
||||
'request': self.context['request']
|
||||
}
|
||||
data = serializer(obj.changed_object, context=context).data
|
||||
|
||||
return data
|
||||
|
||||
@@ -17,6 +17,9 @@ router.APIRootView = ExtrasRootView
|
||||
# Field choices
|
||||
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
|
||||
|
||||
# Custom field choices
|
||||
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
|
||||
|
||||
# Graphs
|
||||
router.register(r'graphs', views.GraphViewSet)
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count
|
||||
from django.http import Http404, HttpResponse
|
||||
@@ -6,11 +8,11 @@ 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 taggit.models import Tag
|
||||
|
||||
from extras import filters
|
||||
from extras.models import (
|
||||
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
|
||||
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
|
||||
Tag,
|
||||
)
|
||||
from extras.reports import get_report, get_reports
|
||||
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
|
||||
@@ -23,11 +25,42 @@ from . import serializers
|
||||
|
||||
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(CustomField, ['type']),
|
||||
(ExportTemplate, ['template_language']),
|
||||
(Graph, ['type']),
|
||||
(ObjectChange, ['action']),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Custom field choices
|
||||
#
|
||||
|
||||
class CustomFieldChoicesViewSet(ViewSet):
|
||||
"""
|
||||
"""
|
||||
permission_classes = [IsAuthenticatedOrLoginNotRequired]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
|
||||
|
||||
self._fields = OrderedDict()
|
||||
|
||||
for cfc in CustomFieldChoice.objects.all():
|
||||
self._fields.setdefault(cfc.field.name, {})
|
||||
self._fields[cfc.field.name][cfc.value] = cfc.pk
|
||||
|
||||
def list(self, request):
|
||||
return Response(self._fields)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
if pk not in self._fields:
|
||||
raise Http404
|
||||
return Response(self._fields[pk])
|
||||
|
||||
def get_view_name(self):
|
||||
return "Custom Field choices"
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
@@ -115,7 +148,9 @@ class TopologyMapViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class TagViewSet(ModelViewSet):
|
||||
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
|
||||
queryset = Tag.objects.annotate(
|
||||
tagged_items=Count('extras_taggeditem_items', distinct=True)
|
||||
)
|
||||
serializer_class = serializers.TagSerializer
|
||||
filterset_class = filters.TagFilter
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ class ExtrasConfig(AppConfig):
|
||||
name = "extras"
|
||||
|
||||
def ready(self):
|
||||
|
||||
import extras.signals
|
||||
|
||||
# Check that we can connect to the configured Redis database if webhooks are enabled.
|
||||
if settings.WEBHOOKS_ENABLED:
|
||||
try:
|
||||
@@ -22,6 +25,7 @@ class ExtrasConfig(AppConfig):
|
||||
port=settings.REDIS_PORT,
|
||||
db=settings.REDIS_DATABASE,
|
||||
password=settings.REDIS_PASSWORD or None,
|
||||
ssl=settings.REDIS_SSL,
|
||||
)
|
||||
rs.ping()
|
||||
except redis.exceptions.ConnectionError:
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
|
||||
# Models which support custom fields
|
||||
CUSTOMFIELD_MODELS = (
|
||||
'provider', 'circuit', # Circuits
|
||||
'site', 'rack', 'devicetype', 'device', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
|
||||
'secret', # Secrets
|
||||
'tenant', # Tenancy
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
)
|
||||
CUSTOMFIELD_MODELS = [
|
||||
'circuits.circuit',
|
||||
'circuits.provider',
|
||||
'dcim.device',
|
||||
'dcim.devicetype',
|
||||
'dcim.powerfeed',
|
||||
'dcim.rack',
|
||||
'dcim.site',
|
||||
'ipam.aggregate',
|
||||
'ipam.ipaddress',
|
||||
'ipam.prefix',
|
||||
'ipam.service',
|
||||
'ipam.vlan',
|
||||
'ipam.vrf',
|
||||
'secrets.secret',
|
||||
'tenancy.tenant',
|
||||
'virtualization.cluster',
|
||||
'virtualization.virtualmachine',
|
||||
]
|
||||
|
||||
# Custom field types
|
||||
CF_TYPE_TEXT = 100
|
||||
@@ -35,6 +46,46 @@ CF_FILTER_CHOICES = (
|
||||
(CF_FILTER_EXACT, 'Exact'),
|
||||
)
|
||||
|
||||
# Custom links
|
||||
CUSTOMLINK_MODELS = [
|
||||
'circuits.circuit',
|
||||
'circuits.provider',
|
||||
'dcim.cable',
|
||||
'dcim.device',
|
||||
'dcim.devicetype',
|
||||
'dcim.powerpanel',
|
||||
'dcim.powerfeed',
|
||||
'dcim.rack',
|
||||
'dcim.site',
|
||||
'ipam.aggregate',
|
||||
'ipam.ipaddress',
|
||||
'ipam.prefix',
|
||||
'ipam.service',
|
||||
'ipam.vlan',
|
||||
'ipam.vrf',
|
||||
'secrets.secret',
|
||||
'tenancy.tenant',
|
||||
'virtualization.cluster',
|
||||
'virtualization.virtualmachine',
|
||||
]
|
||||
|
||||
BUTTON_CLASS_DEFAULT = 'default'
|
||||
BUTTON_CLASS_PRIMARY = 'primary'
|
||||
BUTTON_CLASS_SUCCESS = 'success'
|
||||
BUTTON_CLASS_INFO = 'info'
|
||||
BUTTON_CLASS_WARNING = 'warning'
|
||||
BUTTON_CLASS_DANGER = 'danger'
|
||||
BUTTON_CLASS_LINK = 'link'
|
||||
BUTTON_CLASS_CHOICES = (
|
||||
(BUTTON_CLASS_DEFAULT, 'Default'),
|
||||
(BUTTON_CLASS_PRIMARY, 'Primary (blue)'),
|
||||
(BUTTON_CLASS_SUCCESS, 'Success (green)'),
|
||||
(BUTTON_CLASS_INFO, 'Info (aqua)'),
|
||||
(BUTTON_CLASS_WARNING, 'Warning (orange)'),
|
||||
(BUTTON_CLASS_DANGER, 'Danger (red)'),
|
||||
(BUTTON_CLASS_LINK, 'None (link)'),
|
||||
)
|
||||
|
||||
# Graph types
|
||||
GRAPH_TYPE_INTERFACE = 100
|
||||
GRAPH_TYPE_PROVIDER = 200
|
||||
@@ -47,15 +98,42 @@ GRAPH_TYPE_CHOICES = (
|
||||
|
||||
# Models which support export templates
|
||||
EXPORTTEMPLATE_MODELS = [
|
||||
'provider', 'circuit', # Circuits
|
||||
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
|
||||
'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
|
||||
'secret', # Secrets
|
||||
'tenant', # Tenancy
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
'circuits.circuit',
|
||||
'circuits.provider',
|
||||
'dcim.cable',
|
||||
'dcim.consoleport',
|
||||
'dcim.device',
|
||||
'dcim.devicetype',
|
||||
'dcim.interface',
|
||||
'dcim.manufacturer',
|
||||
'dcim.powerpanel',
|
||||
'dcim.powerport',
|
||||
'dcim.powerfeed',
|
||||
'dcim.rack',
|
||||
'dcim.rackgroup',
|
||||
'dcim.region',
|
||||
'dcim.site',
|
||||
'dcim.virtualchassis',
|
||||
'ipam.aggregate',
|
||||
'ipam.ipaddress',
|
||||
'ipam.prefix',
|
||||
'ipam.service',
|
||||
'ipam.vlan',
|
||||
'ipam.vrf',
|
||||
'secrets.secret',
|
||||
'tenancy.tenant',
|
||||
'virtualization.cluster',
|
||||
'virtualization.virtualmachine',
|
||||
]
|
||||
|
||||
# ExportTemplate language choices
|
||||
TEMPLATE_LANGUAGE_DJANGO = 10
|
||||
TEMPLATE_LANGUAGE_JINJA2 = 20
|
||||
TEMPLATE_LANGUAGE_CHOICES = (
|
||||
(TEMPLATE_LANGUAGE_DJANGO, 'Django'),
|
||||
(TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
|
||||
)
|
||||
|
||||
# Topology map types
|
||||
TOPOLOGYMAP_TYPE_NETWORK = 1
|
||||
TOPOLOGYMAP_TYPE_CONSOLE = 2
|
||||
@@ -117,13 +195,36 @@ WEBHOOK_CT_CHOICES = (
|
||||
)
|
||||
|
||||
# Models which support registered webhooks
|
||||
WEBHOOK_MODELS = (
|
||||
'provider', 'circuit', # Circuits
|
||||
'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM
|
||||
'consoleport', 'consoleserverport', 'powerport', 'poweroutlet',
|
||||
'interface', 'devicebay', 'inventoryitem',
|
||||
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
|
||||
'secret', # Secrets
|
||||
'tenant', # Tenancy
|
||||
'cluster', 'virtualmachine', # Virtualization
|
||||
)
|
||||
WEBHOOK_MODELS = [
|
||||
'circuits.circuit',
|
||||
'circuits.provider',
|
||||
'dcim.cable',
|
||||
'dcim.consoleport',
|
||||
'dcim.consoleserverport',
|
||||
'dcim.device',
|
||||
'dcim.devicebay',
|
||||
'dcim.devicetype',
|
||||
'dcim.interface',
|
||||
'dcim.inventoryitem',
|
||||
'dcim.frontport',
|
||||
'dcim.manufacturer',
|
||||
'dcim.poweroutlet',
|
||||
'dcim.powerpanel',
|
||||
'dcim.powerport',
|
||||
'dcim.powerfeed',
|
||||
'dcim.rack',
|
||||
'dcim.rearport',
|
||||
'dcim.region',
|
||||
'dcim.site',
|
||||
'dcim.virtualchassis',
|
||||
'ipam.aggregate',
|
||||
'ipam.ipaddress',
|
||||
'ipam.prefix',
|
||||
'ipam.service',
|
||||
'ipam.vlan',
|
||||
'ipam.vrf',
|
||||
'secrets.secret',
|
||||
'tenancy.tenant',
|
||||
'virtualization.cluster',
|
||||
'virtualization.virtualmachine',
|
||||
]
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import django_filters
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
from taggit.models import Tag
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap
|
||||
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
|
||||
|
||||
|
||||
class CustomFieldFilter(django_filters.Filter):
|
||||
@@ -82,7 +81,7 @@ class ExportTemplateFilter(django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = ExportTemplate
|
||||
fields = ['content_type', 'name']
|
||||
fields = ['content_type', 'name', 'template_language']
|
||||
|
||||
|
||||
class TagFilter(django_filters.FilterSet):
|
||||
|
||||
@@ -4,21 +4,19 @@ from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from mptt.forms import TreeNodeMultipleChoiceField
|
||||
from taggit.forms import TagField
|
||||
from taggit.models import Tag
|
||||
|
||||
from dcim.models import DeviceRole, Platform, Region, Site
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
|
||||
FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
|
||||
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField,
|
||||
ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField,
|
||||
)
|
||||
from .constants import (
|
||||
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
|
||||
OBJECTCHANGE_ACTION_CHOICES,
|
||||
)
|
||||
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
|
||||
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
|
||||
|
||||
|
||||
#
|
||||
@@ -190,11 +188,12 @@ class CustomFieldFilterForm(forms.Form):
|
||||
|
||||
class TagForm(BootstrapMixin, forms.ModelForm):
|
||||
slug = SlugField()
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = [
|
||||
'name', 'slug',
|
||||
'name', 'slug', 'color', 'comments'
|
||||
]
|
||||
|
||||
|
||||
@@ -221,10 +220,6 @@ class TagFilterForm(BootstrapMixin, forms.Form):
|
||||
#
|
||||
|
||||
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
regions = TreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
data = JSONField()
|
||||
|
||||
class Meta:
|
||||
@@ -233,6 +228,26 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
|
||||
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
|
||||
'tenants', 'data',
|
||||
]
|
||||
widgets = {
|
||||
'regions': APISelectMultiple(
|
||||
api_url="/api/dcim/regions/"
|
||||
),
|
||||
'sites': APISelectMultiple(
|
||||
api_url="/api/dcim/sites/"
|
||||
),
|
||||
'roles': APISelectMultiple(
|
||||
api_url="/api/dcim/device-roles/"
|
||||
),
|
||||
'platforms': APISelectMultiple(
|
||||
api_url="/api/dcim/platforms/"
|
||||
),
|
||||
'tenant_groups': APISelectMultiple(
|
||||
api_url="/api/tenancy/tenant-groups/"
|
||||
),
|
||||
'tenants': APISelectMultiple(
|
||||
api_url="/api/tenancy/tenants/"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
|
||||
@@ -264,29 +279,53 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
region = FilterChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/regions/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
site = FilterChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/sites/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
role = FilterChoiceField(
|
||||
queryset=DeviceRole.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/device-roles/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
platform = FilterChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/dcim/platforms/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
tenant_group = FilterChoiceField(
|
||||
queryset=TenantGroup.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/tenancy/tenant-groups/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
to_field_name='slug'
|
||||
to_field_name='slug',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/tenancy/tenants/",
|
||||
value_field="slug",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ from django import get_version
|
||||
from django.apps import apps
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Model
|
||||
|
||||
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
|
||||
|
||||
@@ -38,16 +37,10 @@ class Command(BaseCommand):
|
||||
for app in APPS:
|
||||
self.django_models[app] = []
|
||||
|
||||
# Models
|
||||
app_models = sys.modules['{}.models'.format(app)]
|
||||
for name in dir(app_models):
|
||||
model = getattr(app_models, name)
|
||||
try:
|
||||
if issubclass(model, Model) and model._meta.app_label == app:
|
||||
namespace[name] = model
|
||||
self.django_models[app].append(name)
|
||||
except TypeError:
|
||||
pass
|
||||
# Load models from each app
|
||||
for model in apps.get_app_config(app).get_models():
|
||||
namespace[model.__name__] = model
|
||||
self.django_models[app].append(model.__name__)
|
||||
|
||||
# Constants
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,7 @@ from django.conf import settings
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils import timezone
|
||||
from django.utils.functional import curry
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
|
||||
from extras.webhooks import enqueue_webhooks
|
||||
from .constants import (
|
||||
@@ -29,19 +30,28 @@ def cache_changed_object(instance, **kwargs):
|
||||
|
||||
def _record_object_deleted(request, instance, **kwargs):
|
||||
|
||||
# Record that the object was deleted.
|
||||
# Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen
|
||||
# occasionally during tests, but haven't been able to determine why.
|
||||
assert request.user.is_authenticated
|
||||
|
||||
# Record that the object was deleted
|
||||
if hasattr(instance, 'log_change'):
|
||||
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
|
||||
enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE)
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
|
||||
|
||||
# Increment metric counters
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
class ObjectChangeMiddleware(object):
|
||||
"""
|
||||
This middleware performs two functions in response to an object being created, updated, or deleted:
|
||||
This middleware performs three functions in response to an object being created, updated, or deleted:
|
||||
|
||||
1. Create an ObjectChange to reflect the modification to the object in the changelog.
|
||||
2. Enqueue any relevant webhooks.
|
||||
3. Increment metric counter for the event type
|
||||
|
||||
The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit
|
||||
differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
|
||||
@@ -79,7 +89,13 @@ class ObjectChangeMiddleware(object):
|
||||
obj.log_change(request.user, request.id, action)
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(obj, action)
|
||||
enqueue_webhooks(obj, request.user, request.id, action)
|
||||
|
||||
# Increment metric counters
|
||||
if action == OBJECTCHANGE_ACTION_CREATE:
|
||||
model_inserts.labels(obj._meta.model_name).inc()
|
||||
elif action == OBJECTCHANGE_ACTION_UPDATE:
|
||||
model_updates.labels(obj._meta.model_name).inc()
|
||||
|
||||
# Housekeeping: 1% chance of clearing out expired ObjectChanges
|
||||
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.1.7 on 2019-03-05 18:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0016_exporttemplate_add_cable'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='exporttemplate',
|
||||
name='mime_type',
|
||||
field=models.CharField(blank=True, max_length=50),
|
||||
),
|
||||
]
|
||||
27
netbox/extras/migrations/0018_exporttemplate_add_jinja2.py
Normal file
27
netbox/extras/migrations/0018_exporttemplate_add_jinja2.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 2.1.7 on 2019-04-08 14:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def set_template_language(apps, schema_editor):
|
||||
"""
|
||||
Set the language for all existing ExportTemplates to Django (Jinja2 is the default for new ExportTemplates).
|
||||
"""
|
||||
ExportTemplate = apps.get_model('extras', 'ExportTemplate')
|
||||
ExportTemplate.objects.update(template_language=10)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0017_exporttemplate_mime_type_length'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='exporttemplate',
|
||||
name='template_language',
|
||||
field=models.PositiveSmallIntegerField(default=20),
|
||||
),
|
||||
migrations.RunPython(set_template_language),
|
||||
]
|
||||
43
netbox/extras/migrations/0019_tag_taggeditem.py
Normal file
43
netbox/extras/migrations/0019_tag_taggeditem.py
Normal file
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 06:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0018_exporttemplate_add_jinja2'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Tag',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(max_length=100, unique=True)),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TaggedItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('object_id', models.IntegerField(db_index=True)),
|
||||
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')),
|
||||
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AlterIndexTogether(
|
||||
name='taggeditem',
|
||||
index_together={('content_type', 'object_id')},
|
||||
),
|
||||
]
|
||||
65
netbox/extras/migrations/0020_tag_data.py
Normal file
65
netbox/extras/migrations/0020_tag_data.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 06:56
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import utilities.fields
|
||||
|
||||
|
||||
def copy_tags(apps, schema_editor):
|
||||
"""
|
||||
Copy data from taggit_tag to extras_tag
|
||||
"""
|
||||
TaggitTag = apps.get_model('taggit', 'Tag')
|
||||
ExtrasTag = apps.get_model('extras', 'Tag')
|
||||
|
||||
tags_values = TaggitTag.objects.all().values('id', 'name', 'slug')
|
||||
tags = [ExtrasTag(**tag) for tag in tags_values]
|
||||
ExtrasTag.objects.bulk_create(tags)
|
||||
|
||||
|
||||
def copy_taggeditems(apps, schema_editor):
|
||||
"""
|
||||
Copy data from taggit_taggeditem to extras_taggeditem
|
||||
"""
|
||||
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
|
||||
ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem')
|
||||
|
||||
tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id')
|
||||
tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values]
|
||||
ExtrasTaggedItem.objects.bulk_create(tagged_items)
|
||||
|
||||
|
||||
def delete_taggit_taggeditems(apps, schema_editor):
|
||||
"""
|
||||
Delete all TaggedItem instances from taggit_taggeditem
|
||||
"""
|
||||
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
|
||||
TaggitTaggedItem.objects.all().delete()
|
||||
|
||||
|
||||
def delete_taggit_tags(apps, schema_editor):
|
||||
"""
|
||||
Delete all Tag instances from taggit_tag
|
||||
"""
|
||||
TaggitTag = apps.get_model('taggit', 'Tag')
|
||||
TaggitTag.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0019_tag_taggeditem'),
|
||||
('circuits', '0015_custom_tag_models'),
|
||||
('dcim', '0070_custom_tag_models'),
|
||||
('ipam', '0025_custom_tag_models'),
|
||||
('secrets', '0006_custom_tag_models'),
|
||||
('tenancy', '0006_custom_tag_models'),
|
||||
('virtualization', '0009_custom_tag_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_tags),
|
||||
migrations.RunPython(copy_taggeditems),
|
||||
migrations.RunPython(delete_taggit_taggeditems),
|
||||
migrations.RunPython(delete_taggit_tags),
|
||||
]
|
||||
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 07:38
|
||||
|
||||
from django.db import migrations, models
|
||||
import utilities.fields
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0020_tag_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='color',
|
||||
field=utilities.fields.ColorField(max_length=6, default='9e9e9e'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='comments',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='created',
|
||||
field=models.DateField(auto_now_add=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tag',
|
||||
name='last_updated',
|
||||
field=models.DateTimeField(auto_now=True, null=True),
|
||||
),
|
||||
]
|
||||
48
netbox/extras/migrations/0022_custom_links.py
Normal file
48
netbox/extras/migrations/0022_custom_links.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import extras.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('contenttypes', '0002_remove_content_type_name'),
|
||||
('extras', '0021_add_color_comments_changelog_to_tag'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CustomLink',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('text', models.CharField(max_length=500)),
|
||||
('url', models.CharField(max_length=500)),
|
||||
('weight', models.PositiveSmallIntegerField(default=100)),
|
||||
('group_name', models.CharField(blank=True, max_length=50)),
|
||||
('button_class', models.CharField(default='default', max_length=30)),
|
||||
('new_window', models.BooleanField()),
|
||||
('content_type', models.ForeignKey(limit_choices_to=extras.models.get_custom_link_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['group_name', 'weight', 'name'],
|
||||
},
|
||||
),
|
||||
|
||||
# Update limit_choices_to for CustomFields, ExportTemplates, and Webhooks
|
||||
migrations.AlterField(
|
||||
model_name='customfield',
|
||||
name='obj_type',
|
||||
field=models.ManyToManyField(limit_choices_to=extras.models.get_custom_field_models, related_name='custom_fields', to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='exporttemplate',
|
||||
name='content_type',
|
||||
field=models.ForeignKey(limit_choices_to=extras.models.get_export_template_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='obj_type',
|
||||
field=models.ManyToManyField(limit_choices_to=extras.models.get_webhook_models, related_name='webhooks', to='contenttypes.ContentType'),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
import graphviz
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
@@ -12,9 +11,13 @@ from django.db.models import F, Q
|
||||
from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
import graphviz
|
||||
from jinja2 import Environment
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED
|
||||
from utilities.utils import deepmerge, foreground_color
|
||||
from utilities.fields import ColorField
|
||||
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
|
||||
from .constants import *
|
||||
from .querysets import ConfigContextQuerySet
|
||||
|
||||
@@ -23,6 +26,10 @@ from .querysets import ConfigContextQuerySet
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
def get_webhook_models():
|
||||
return model_names_to_filter_dict(WEBHOOK_MODELS)
|
||||
|
||||
|
||||
class Webhook(models.Model):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
@@ -34,7 +41,7 @@ class Webhook(models.Model):
|
||||
to=ContentType,
|
||||
related_name='webhooks',
|
||||
verbose_name='Object types',
|
||||
limit_choices_to={'model__in': WEBHOOK_MODELS},
|
||||
limit_choices_to=get_webhook_models,
|
||||
help_text="The object(s) to which this Webhook applies."
|
||||
)
|
||||
name = models.CharField(
|
||||
@@ -131,12 +138,16 @@ class CustomFieldModel(models.Model):
|
||||
return OrderedDict([(field, None) for field in fields])
|
||||
|
||||
|
||||
def get_custom_field_models():
|
||||
return model_names_to_filter_dict(CUSTOMFIELD_MODELS)
|
||||
|
||||
|
||||
class CustomField(models.Model):
|
||||
obj_type = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
verbose_name='Object(s)',
|
||||
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
|
||||
limit_choices_to=get_custom_field_models,
|
||||
help_text='The object(s) to which this field applies.'
|
||||
)
|
||||
type = models.PositiveSmallIntegerField(
|
||||
@@ -297,6 +308,62 @@ class CustomFieldChoice(models.Model):
|
||||
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
#
|
||||
|
||||
def get_custom_link_models():
|
||||
return model_names_to_filter_dict(CUSTOMLINK_MODELS)
|
||||
|
||||
|
||||
class CustomLink(models.Model):
|
||||
"""
|
||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||
code to be rendered with an object as context.
|
||||
"""
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to=get_custom_link_models
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
text = models.CharField(
|
||||
max_length=500,
|
||||
help_text="Jinja2 template code for link text"
|
||||
)
|
||||
url = models.CharField(
|
||||
max_length=500,
|
||||
verbose_name='URL',
|
||||
help_text="Jinja2 template code for link URL"
|
||||
)
|
||||
weight = models.PositiveSmallIntegerField(
|
||||
default=100
|
||||
)
|
||||
group_name = models.CharField(
|
||||
max_length=50,
|
||||
blank=True,
|
||||
help_text="Links with the same group will appear as a dropdown menu"
|
||||
)
|
||||
button_class = models.CharField(
|
||||
max_length=30,
|
||||
choices=BUTTON_CLASS_CHOICES,
|
||||
default=BUTTON_CLASS_DEFAULT,
|
||||
help_text="The class of the first link in a group will be used for the dropdown button"
|
||||
)
|
||||
new_window = models.BooleanField(
|
||||
help_text="Force link to open in a new window"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['group_name', 'weight', 'name']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
#
|
||||
# Graphs
|
||||
#
|
||||
@@ -342,11 +409,15 @@ class Graph(models.Model):
|
||||
# Export templates
|
||||
#
|
||||
|
||||
def get_export_template_models():
|
||||
return model_names_to_filter_dict(EXPORTTEMPLATE_MODELS)
|
||||
|
||||
|
||||
class ExportTemplate(models.Model):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}
|
||||
limit_choices_to=get_export_template_models
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
@@ -355,9 +426,13 @@ class ExportTemplate(models.Model):
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
template_language = models.PositiveSmallIntegerField(
|
||||
choices=TEMPLATE_LANGUAGE_CHOICES,
|
||||
default=TEMPLATE_LANGUAGE_JINJA2
|
||||
)
|
||||
template_code = models.TextField()
|
||||
mime_type = models.CharField(
|
||||
max_length=15,
|
||||
max_length=50,
|
||||
blank=True
|
||||
)
|
||||
file_extension = models.CharField(
|
||||
@@ -374,16 +449,36 @@ class ExportTemplate(models.Model):
|
||||
def __str__(self):
|
||||
return '{}: {}'.format(self.content_type, self.name)
|
||||
|
||||
def render(self, queryset):
|
||||
"""
|
||||
Render the contents of the template.
|
||||
"""
|
||||
context = {
|
||||
'queryset': queryset
|
||||
}
|
||||
|
||||
if self.template_language == TEMPLATE_LANGUAGE_DJANGO:
|
||||
template = Template(self.template_code)
|
||||
output = template.render(Context(context))
|
||||
|
||||
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
|
||||
template = Environment().from_string(source=self.template_code)
|
||||
output = template.render(**context)
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
output = output.replace('\r\n', '\n')
|
||||
|
||||
return output
|
||||
|
||||
def render_to_response(self, queryset):
|
||||
"""
|
||||
Render the template to an HTTP response, delivered as a named file attachment
|
||||
"""
|
||||
template = Template(self.template_code)
|
||||
output = self.render(queryset)
|
||||
mime_type = 'text/plain' if not self.mime_type else self.mime_type
|
||||
output = template.render(Context({'queryset': queryset}))
|
||||
|
||||
# Replace CRLF-style line terminators
|
||||
output = output.replace('\r\n', '\n')
|
||||
|
||||
# Build the response
|
||||
response = HttpResponse(output, content_type=mime_type)
|
||||
@@ -539,7 +634,7 @@ class TopologyMap(models.Model):
|
||||
from dcim.models import PowerPort
|
||||
|
||||
# Add all power connections to the graph
|
||||
for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices):
|
||||
for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices):
|
||||
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
|
||||
self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
|
||||
|
||||
@@ -720,7 +815,7 @@ class ConfigContextModel(models.Model):
|
||||
data = deepmerge(data, context.data)
|
||||
|
||||
# If the object has local config context data defined, merge it last
|
||||
if self.local_context_data is not None:
|
||||
if self.local_context_data:
|
||||
data = deepmerge(data, self.local_context_data)
|
||||
|
||||
return data
|
||||
@@ -860,3 +955,37 @@ class ObjectChange(models.Model):
|
||||
self.object_repr,
|
||||
self.object_data,
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Tags
|
||||
#
|
||||
|
||||
# TODO: figure out a way around this circular import for ObjectChange
|
||||
from utilities.models import ChangeLoggedModel # noqa: E402
|
||||
|
||||
|
||||
class Tag(TagBase, ChangeLoggedModel):
|
||||
color = ColorField(
|
||||
default='9e9e9e'
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True,
|
||||
default=''
|
||||
)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:tag', args=[self.slug])
|
||||
|
||||
|
||||
class TaggedItem(GenericTaggedItemBase):
|
||||
tag = models.ForeignKey(
|
||||
to=Tag,
|
||||
related_name="%(app_label)s_%(class)s_items",
|
||||
on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
class Meta:
|
||||
index_together = (
|
||||
("content_type", "object_id")
|
||||
)
|
||||
|
||||
22
netbox/extras/signals.py
Normal file
22
netbox/extras/signals.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from cacheops.signals import cache_invalidated, cache_read
|
||||
from prometheus_client import Counter
|
||||
|
||||
|
||||
cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
|
||||
cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
|
||||
cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
|
||||
|
||||
|
||||
def cache_read_collector(sender, func, hit, **kwargs):
|
||||
if hit:
|
||||
cacheops_cache_hit.inc()
|
||||
else:
|
||||
cacheops_cache_miss.inc()
|
||||
|
||||
|
||||
def cache_invalidated_collector(sender, obj_dict, **kwargs):
|
||||
cacheops_cache_invalidated.inc()
|
||||
|
||||
|
||||
cache_read.connect(cache_read_collector)
|
||||
cache_invalidated.connect(cache_invalidated_collector)
|
||||
@@ -1,11 +1,13 @@
|
||||
import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
from taggit.models import Tag, TaggedItem
|
||||
|
||||
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
|
||||
from .models import ConfigContext, ObjectChange
|
||||
from utilities.tables import BaseTable, BooleanColumn, 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="Changelog">
|
||||
<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 %}
|
||||
@@ -68,13 +70,14 @@ class TagTable(BaseTable):
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=TAG_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
color = ColorColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Tag
|
||||
fields = ('pk', 'name', 'items', 'slug', 'actions')
|
||||
fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
|
||||
|
||||
|
||||
class TaggedItemTable(BaseTable):
|
||||
|
||||
0
netbox/extras/templatetags/__init__.py
Normal file
0
netbox/extras/templatetags/__init__.py
Normal file
68
netbox/extras/templatetags/custom_links.py
Normal file
68
netbox/extras/templatetags/custom_links.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import template
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.safestring import mark_safe
|
||||
from jinja2 import Environment
|
||||
|
||||
from extras.models import CustomLink
|
||||
|
||||
|
||||
register = template.Library()
|
||||
|
||||
LINK_BUTTON = '<a href="{}"{} class="btn btn-sm btn-{}">{}</a>\n'
|
||||
GROUP_BUTTON = '<div class="btn-group">\n' \
|
||||
'<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \
|
||||
'{} <span class="caret"></span>\n' \
|
||||
'</button>\n' \
|
||||
'<ul class="dropdown-menu pull-right">\n'
|
||||
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
|
||||
|
||||
|
||||
@register.simple_tag()
|
||||
def custom_links(obj):
|
||||
"""
|
||||
Render all applicable links for the given object.
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
custom_links = CustomLink.objects.filter(content_type=content_type)
|
||||
if not custom_links:
|
||||
return ''
|
||||
|
||||
context = {
|
||||
'obj': obj,
|
||||
}
|
||||
template_code = ''
|
||||
group_names = OrderedDict()
|
||||
|
||||
# Organize custom links by group
|
||||
for cl in custom_links:
|
||||
if cl.group_name and cl.group_name in group_names:
|
||||
group_names[cl.group_name].append(cl)
|
||||
elif cl.group_name:
|
||||
group_names[cl.group_name] = [cl]
|
||||
|
||||
# Add non-grouped links
|
||||
for cl in custom_links:
|
||||
if not cl.group_name:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += LINK_BUTTON.format(
|
||||
cl.url, link_target, cl.button_class, cl.text
|
||||
)
|
||||
|
||||
# Add grouped links to template
|
||||
for group, links in group_names.items():
|
||||
template_code += GROUP_BUTTON.format(
|
||||
links[0].button_class, group
|
||||
)
|
||||
for cl in links:
|
||||
link_target = ' target="_blank"' if cl.new_window else ''
|
||||
template_code += GROUP_LINK.format(
|
||||
cl.url, link_target, cl.text
|
||||
)
|
||||
template_code += '</ul>\n</div>\n'
|
||||
|
||||
# Render template
|
||||
rendered = Environment().from_string(source=template_code).render(**context)
|
||||
|
||||
return mark_safe(rendered)
|
||||
@@ -1,11 +1,10 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
from taggit.models import Tag
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
|
||||
from extras.constants import GRAPH_TYPE_SITE
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate
|
||||
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.testing import APITestCase
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
|
||||
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT
|
||||
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
|
||||
from utilities.testing import APITestCase
|
||||
from virtualization.models import VirtualMachine
|
||||
|
||||
|
||||
class CustomFieldTest(TestCase):
|
||||
@@ -299,3 +300,33 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
|
||||
cfv = self.site.custom_field_values.get(field=self.cf_select)
|
||||
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
|
||||
|
||||
|
||||
class CustomFieldChoiceAPITest(APITestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
|
||||
|
||||
self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT)
|
||||
self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT)
|
||||
|
||||
self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
|
||||
self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
|
||||
self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
|
||||
|
||||
def test_list_cfc(self):
|
||||
url = reverse('extras-api:custom-field-choice-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(len(response.data), 2)
|
||||
self.assertEqual(len(response.data[self.cf_1.name]), 2)
|
||||
self.assertEqual(len(response.data[self.cf_2.name]), 1)
|
||||
|
||||
self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
|
||||
self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
|
||||
self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
|
||||
|
||||
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
|
||||
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
|
||||
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])
|
||||
|
||||
104
netbox/extras/tests/test_views.py
Normal file
104
netbox/extras/tests/test_views.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import urllib.parse
|
||||
import uuid
|
||||
|
||||
from django.contrib.auth.models import User
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Site
|
||||
from extras.models import ConfigContext, ObjectChange, Tag
|
||||
from utilities.testing import create_test_user
|
||||
|
||||
|
||||
class TagTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
|
||||
self.client = Client()
|
||||
|
||||
Tag.objects.bulk_create([
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
Tag(name='Tag 2', slug='tag-2'),
|
||||
Tag(name='Tag 3', slug='tag-3'),
|
||||
])
|
||||
|
||||
def test_tag_list(self):
|
||||
|
||||
url = reverse('extras:tag_list')
|
||||
params = {
|
||||
"q": "tag",
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ConfigContextTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['extras.view_configcontext'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
# Create three ConfigContexts
|
||||
for i in range(1, 4):
|
||||
configcontext = ConfigContext(
|
||||
name='Config Context {}'.format(i),
|
||||
data='{{"foo": {}}}'.format(i)
|
||||
)
|
||||
configcontext.save()
|
||||
configcontext.sites.add(site)
|
||||
|
||||
def test_configcontext_list(self):
|
||||
|
||||
url = reverse('extras:configcontext_list')
|
||||
params = {
|
||||
"q": "foo",
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_configcontext(self):
|
||||
|
||||
configcontext = ConfigContext.objects.first()
|
||||
response = self.client.get(configcontext.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ObjectChangeTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['extras.view_objectchange'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
# Create three ObjectChanges
|
||||
for i in range(1, 4):
|
||||
site.log_change(
|
||||
user=user,
|
||||
request_id=uuid.uuid4(),
|
||||
action=2
|
||||
)
|
||||
|
||||
def test_objectchange_list(self):
|
||||
|
||||
url = reverse('extras:objectchange_list')
|
||||
params = {
|
||||
"user": User.objects.first(),
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_objectchange(self):
|
||||
|
||||
objectchange = ObjectChange.objects.first()
|
||||
response = self.client.get(objectchange.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
@@ -1,6 +1,8 @@
|
||||
from django.conf.urls import url
|
||||
|
||||
from extras import views
|
||||
from extras.models import Tag
|
||||
|
||||
|
||||
app_name = 'extras'
|
||||
urlpatterns = [
|
||||
@@ -11,6 +13,7 @@ urlpatterns = [
|
||||
url(r'^tags/(?P<slug>[\w-]+)/$', views.TagView.as_view(), name='tag'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
|
||||
url(r'^tags/(?P<slug>[\w-]+)/changelog/$', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
|
||||
|
||||
# Config contexts
|
||||
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
|
||||
|
||||
@@ -9,7 +9,6 @@ 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_tables2 import RequestConfig
|
||||
from taggit.models import Tag, TaggedItem
|
||||
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
@@ -19,7 +18,7 @@ from .forms import (
|
||||
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
|
||||
TagFilterForm, TagForm,
|
||||
)
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||
from .reports import get_report, get_reports
|
||||
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
|
||||
|
||||
@@ -30,7 +29,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
|
||||
|
||||
class TagListView(ObjectListView):
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('taggit_taggeditem_items')
|
||||
items=Count('extras_taggeditem_items', distinct=True)
|
||||
).order_by(
|
||||
'name'
|
||||
)
|
||||
@@ -69,22 +68,23 @@ class TagView(View):
|
||||
|
||||
|
||||
class TagEditView(PermissionRequiredMixin, ObjectEditView):
|
||||
permission_required = 'taggit.change_tag'
|
||||
permission_required = 'extras.change_tag'
|
||||
model = Tag
|
||||
model_form = TagForm
|
||||
default_return_url = 'extras:tag_list'
|
||||
template_name = 'extras/tag_edit.html'
|
||||
|
||||
|
||||
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
permission_required = 'taggit.delete_tag'
|
||||
permission_required = 'extras.delete_tag'
|
||||
model = Tag
|
||||
default_return_url = 'extras:tag_list'
|
||||
|
||||
|
||||
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
permission_required = 'taggit.delete_tag'
|
||||
permission_required = 'extras.delete_tag'
|
||||
queryset = Tag.objects.annotate(
|
||||
items=Count('taggit_taggeditem_items')
|
||||
items=Count('extras_taggeditem_items')
|
||||
).order_by(
|
||||
'name'
|
||||
)
|
||||
@@ -96,7 +96,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
class ConfigContextListView(ObjectListView):
|
||||
class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'extras.view_configcontext'
|
||||
queryset = ConfigContext.objects.all()
|
||||
filter = filters.ConfigContextFilter
|
||||
filter_form = ConfigContextFilterForm
|
||||
@@ -104,7 +105,8 @@ class ConfigContextListView(ObjectListView):
|
||||
template_name = 'extras/configcontext_list.html'
|
||||
|
||||
|
||||
class ConfigContextView(View):
|
||||
class ConfigContextView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_configcontext'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -173,7 +175,8 @@ class ObjectConfigContextView(View):
|
||||
# Change logging
|
||||
#
|
||||
|
||||
class ObjectChangeListView(ObjectListView):
|
||||
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'extras.view_objectchange'
|
||||
queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
|
||||
filter = filters.ObjectChangeFilter
|
||||
filter_form = ObjectChangeFilterForm
|
||||
@@ -181,7 +184,8 @@ class ObjectChangeListView(ObjectListView):
|
||||
template_name = 'extras/objectchange_list.html'
|
||||
|
||||
|
||||
class ObjectChangeView(View):
|
||||
class ObjectChangeView(PermissionRequiredMixin, View):
|
||||
permission_required = 'extras.view_objectchange'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -272,10 +276,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
|
||||
# Reports
|
||||
#
|
||||
|
||||
class ReportListView(View):
|
||||
class ReportListView(PermissionRequiredMixin, View):
|
||||
"""
|
||||
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
|
||||
"""
|
||||
permission_required = 'extras.view_reportresult'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@@ -295,10 +300,11 @@ class ReportListView(View):
|
||||
})
|
||||
|
||||
|
||||
class ReportView(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):
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ from utilities.api import get_serializer_for_model
|
||||
from .constants import WEBHOOK_MODELS
|
||||
|
||||
|
||||
def enqueue_webhooks(instance, action):
|
||||
def enqueue_webhooks(instance, user, request_id, action):
|
||||
"""
|
||||
Find Webhook(s) assigned to this instance + action and enqueue them
|
||||
to be processed
|
||||
"""
|
||||
if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
|
||||
if not settings.WEBHOOKS_ENABLED or instance._meta.label.lower() not in WEBHOOK_MODELS:
|
||||
return
|
||||
|
||||
# Retrieve any applicable Webhooks
|
||||
@@ -47,5 +47,7 @@ def enqueue_webhooks(instance, action):
|
||||
serializer.data,
|
||||
instance._meta.model_name,
|
||||
action,
|
||||
str(datetime.datetime.now())
|
||||
str(datetime.datetime.now()),
|
||||
user.username,
|
||||
request_id
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, data, model_name, event, timestamp):
|
||||
def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
@@ -18,6 +18,8 @@ def process_webhook(webhook, data, model_name, event, timestamp):
|
||||
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
|
||||
'timestamp': timestamp,
|
||||
'model': model_name,
|
||||
'username': username,
|
||||
'request_id': request_id,
|
||||
'data': data
|
||||
}
|
||||
headers = {
|
||||
|
||||
@@ -21,10 +21,11 @@ __all__ = [
|
||||
|
||||
class NestedVRFSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VRF
|
||||
fields = ['id', 'url', 'name', 'rd']
|
||||
fields = ['id', 'url', 'name', 'rd', 'prefix_count']
|
||||
|
||||
|
||||
#
|
||||
@@ -33,10 +34,11 @@ class NestedVRFSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedRIRSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
|
||||
|
||||
|
||||
class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
@@ -53,18 +55,21 @@ class NestedAggregateSerializer(WritableNestedSerializer):
|
||||
|
||||
class NestedRoleSerializer(WritableNestedSerializer):
|
||||
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', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
|
||||
|
||||
|
||||
class NestedVLANGroupSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'url', 'name', 'slug']
|
||||
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
|
||||
|
||||
|
||||
class NestedVLANSerializer(WritableNestedSerializer):
|
||||
|
||||
@@ -25,12 +25,14 @@ from .nested_serializers import *
|
||||
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
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',
|
||||
'created', 'last_updated', 'ipaddress_count', 'prefix_count',
|
||||
]
|
||||
|
||||
|
||||
@@ -39,10 +41,11 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class RIRSerializer(ValidatedModelSerializer):
|
||||
aggregate_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = RIR
|
||||
fields = ['id', 'name', 'slug', 'is_private']
|
||||
fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count']
|
||||
|
||||
|
||||
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
@@ -63,18 +66,21 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class RoleSerializer(ValidatedModelSerializer):
|
||||
prefix_count = serializers.IntegerField(read_only=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Role
|
||||
fields = ['id', 'name', 'slug', 'weight']
|
||||
fields = ['id', 'name', 'slug', 'weight', 'prefix_count', 'vlan_count']
|
||||
|
||||
|
||||
class VLANGroupSerializer(ValidatedModelSerializer):
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
vlan_count = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = ['id', 'name', 'slug', 'site']
|
||||
fields = ['id', 'name', 'slug', 'site', 'vlan_count']
|
||||
validators = []
|
||||
|
||||
def validate(self, data):
|
||||
@@ -99,12 +105,13 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
status = ChoiceField(choices=VLAN_STATUS_CHOICES, 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',
|
||||
'custom_fields', 'created', 'last_updated', 'prefix_count',
|
||||
]
|
||||
validators = []
|
||||
|
||||
@@ -128,6 +135,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
#
|
||||
|
||||
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
family = ChoiceField(choices=AF_CHOICES, read_only=True)
|
||||
site = NestedSiteSerializer(required=False, allow_null=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
@@ -189,6 +197,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
|
||||
|
||||
|
||||
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
family = ChoiceField(choices=AF_CHOICES, read_only=True)
|
||||
vrf = NestedVRFSerializer(required=False, allow_null=True)
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
|
||||
@@ -201,8 +210,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
|
||||
'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
|
||||
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
read_only_fields = ['family']
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import Count
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import status
|
||||
from rest_framework.decorators import action
|
||||
@@ -9,6 +10,7 @@ from extras.api.views import CustomFieldModelViewSet
|
||||
from ipam import filters
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from utilities.api import FieldChoicesViewSet, ModelViewSet
|
||||
from utilities.utils import get_subquery
|
||||
from . import serializers
|
||||
|
||||
|
||||
@@ -31,7 +33,10 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
#
|
||||
|
||||
class VRFViewSet(CustomFieldModelViewSet):
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('tags')
|
||||
queryset = VRF.objects.select_related('tenant').prefetch_related('tags').annotate(
|
||||
ipaddress_count=get_subquery(IPAddress, 'vrf'),
|
||||
prefix_count=get_subquery(Prefix, 'vrf')
|
||||
)
|
||||
serializer_class = serializers.VRFSerializer
|
||||
filterset_class = filters.VRFFilter
|
||||
|
||||
@@ -41,7 +46,9 @@ class VRFViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class RIRViewSet(ModelViewSet):
|
||||
queryset = RIR.objects.all()
|
||||
queryset = RIR.objects.annotate(
|
||||
aggregate_count=Count('aggregates')
|
||||
)
|
||||
serializer_class = serializers.RIRSerializer
|
||||
filterset_class = filters.RIRFilter
|
||||
|
||||
@@ -61,7 +68,10 @@ class AggregateViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class RoleViewSet(ModelViewSet):
|
||||
queryset = Role.objects.all()
|
||||
queryset = Role.objects.annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role'),
|
||||
vlan_count=get_subquery(VLAN, 'role')
|
||||
)
|
||||
serializer_class = serializers.RoleSerializer
|
||||
filterset_class = filters.RoleFilter
|
||||
|
||||
@@ -71,7 +81,11 @@ class RoleViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class PrefixViewSet(CustomFieldModelViewSet):
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags')
|
||||
queryset = Prefix.objects.select_related(
|
||||
'site', 'vrf__tenant', 'tenant', 'vlan', 'role'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
)
|
||||
serializer_class = serializers.PrefixSerializer
|
||||
filterset_class = filters.PrefixFilter
|
||||
|
||||
@@ -263,7 +277,9 @@ class IPAddressViewSet(CustomFieldModelViewSet):
|
||||
#
|
||||
|
||||
class VLANGroupViewSet(ModelViewSet):
|
||||
queryset = VLANGroup.objects.select_related('site')
|
||||
queryset = VLANGroup.objects.select_related('site').annotate(
|
||||
vlan_count=Count('vlans')
|
||||
)
|
||||
serializer_class = serializers.VLANGroupSerializer
|
||||
filterset_class = filters.VLANGroupFilter
|
||||
|
||||
@@ -273,7 +289,13 @@ class VLANGroupViewSet(ModelViewSet):
|
||||
#
|
||||
|
||||
class VLANViewSet(CustomFieldModelViewSet):
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags')
|
||||
queryset = VLAN.objects.select_related(
|
||||
'site', 'group', 'tenant', 'role'
|
||||
).prefetch_related(
|
||||
'tags'
|
||||
).annotate(
|
||||
prefix_count=get_subquery(Prefix, 'role')
|
||||
)
|
||||
serializer_class = serializers.VLANSerializer
|
||||
filterset_class = filters.VLANFilter
|
||||
|
||||
|
||||
@@ -331,12 +331,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = ['family']
|
||||
fields = ['family', 'dns_name']
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(dns_name__icontains=value) |
|
||||
Q(description__icontains=value) |
|
||||
Q(address__istartswith=value)
|
||||
)
|
||||
|
||||
@@ -349,11 +349,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
|
||||
class PrefixCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(
|
||||
vrf = FlexibleModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
to_field_name='rd',
|
||||
help_text='Route distinguisher of parent VRF',
|
||||
required=False,
|
||||
help_text='Route distinguisher of parent VRF (or {ID})',
|
||||
error_messages={
|
||||
'invalid_choice': 'VRF not found.',
|
||||
}
|
||||
@@ -524,14 +524,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
label='Mask length',
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
vrf = FilterChoiceField(
|
||||
vrf_id = FilterChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF',
|
||||
null_label='-- Global --',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/ipam/vrfs/",
|
||||
value_field="slug",
|
||||
null_option=True,
|
||||
)
|
||||
)
|
||||
@@ -661,8 +659,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm)
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site',
|
||||
'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
|
||||
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2(),
|
||||
@@ -748,7 +746,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant',
|
||||
]
|
||||
widgets = {
|
||||
'status': StaticSelect2(),
|
||||
@@ -764,11 +762,11 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
|
||||
class IPAddressCSVForm(forms.ModelForm):
|
||||
vrf = forms.ModelChoiceField(
|
||||
vrf = FlexibleModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
to_field_name='rd',
|
||||
help_text='Route distinguisher of the assigned VRF',
|
||||
required=False,
|
||||
help_text='Route distinguisher of parent VRF (or {ID})',
|
||||
error_messages={
|
||||
'invalid_choice': 'VRF not found.',
|
||||
}
|
||||
@@ -921,13 +919,18 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
dns_name = forms.CharField(
|
||||
max_length=255,
|
||||
required=False
|
||||
)
|
||||
description = forms.CharField(
|
||||
max_length=100, required=False
|
||||
max_length=100,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
nullable_fields = [
|
||||
'vrf', 'role', 'tenant', 'description',
|
||||
'vrf', 'role', 'tenant', 'dns_name', 'description',
|
||||
]
|
||||
|
||||
|
||||
@@ -973,14 +976,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
label='Mask length',
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
vrf = FilterChoiceField(
|
||||
vrf_id = FilterChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
to_field_name='rd',
|
||||
label='VRF',
|
||||
null_label='-- Global --',
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/ipam/vrfs/",
|
||||
value_field="slug",
|
||||
null_option=True,
|
||||
)
|
||||
)
|
||||
|
||||
45
netbox/ipam/migrations/0025_custom_tag_models.py
Normal file
45
netbox/ipam/migrations/0025_custom_tag_models.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 2.1.4 on 2019-02-20 06:56
|
||||
|
||||
from django.db import migrations
|
||||
import taggit.managers
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0024_vrf_allow_null_rd'),
|
||||
('extras', '0019_tag_taggeditem'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='aggregate',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='ipaddress',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='prefix',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vlan',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='vrf',
|
||||
name='tags',
|
||||
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 2.2 on 2019-04-20 00:57
|
||||
|
||||
from django.db import migrations
|
||||
import django.db.models.expressions
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0025_custom_tag_models'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='prefix',
|
||||
options={'ordering': [django.db.models.expressions.OrderBy(django.db.models.expressions.F('vrf'), nulls_first=True), 'family', 'prefix'], 'verbose_name_plural': 'prefixes'},
|
||||
),
|
||||
]
|
||||
19
netbox/ipam/migrations/0027_ipaddress_add_dns_name.py
Normal file
19
netbox/ipam/migrations/0027_ipaddress_add_dns_name.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 2.2 on 2019-04-22 21:43
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0026_prefix_ordering_vrf_nulls_first'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='ipaddress',
|
||||
name='dns_name',
|
||||
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]),
|
||||
),
|
||||
]
|
||||
@@ -1,20 +1,22 @@
|
||||
import netaddr
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.exceptions import ValidationError, ObjectDoesNotExist
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
from django.db.models import F, Q
|
||||
from django.db.models.expressions import RawSQL
|
||||
from django.urls import reverse
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from dcim.models import Interface
|
||||
from extras.models import CustomFieldModel
|
||||
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import serialize_object
|
||||
from .constants import *
|
||||
from .fields import IPNetworkField, IPAddressField
|
||||
from .querysets import PrefixQuerySet
|
||||
from .validators import DNSValidator
|
||||
|
||||
|
||||
class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||
@@ -55,7 +57,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description']
|
||||
|
||||
@@ -81,9 +83,9 @@ class VRF(ChangeLoggedModel, CustomFieldModel):
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
if self.name and self.rd:
|
||||
if self.rd:
|
||||
return "{} ({})".format(self.name, self.rd)
|
||||
return None
|
||||
return self.name
|
||||
|
||||
|
||||
class RIR(ChangeLoggedModel):
|
||||
@@ -154,7 +156,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['prefix', 'rir', 'date_added', 'description']
|
||||
|
||||
@@ -324,14 +326,14 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
objects = PrefixQuerySet.as_manager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
ordering = ['vrf', 'family', 'prefix']
|
||||
ordering = [F('vrf').asc(nulls_first=True), 'family', 'prefix']
|
||||
verbose_name_plural = 'prefixes'
|
||||
|
||||
def __str__(self):
|
||||
@@ -366,11 +368,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.prefix:
|
||||
|
||||
if isinstance(self.prefix, netaddr.IPNetwork):
|
||||
|
||||
# Clear host bits from prefix
|
||||
self.prefix = self.prefix.cidr
|
||||
# Infer address family from IPNetwork object
|
||||
|
||||
# Record address family
|
||||
self.family = self.prefix.version
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def to_csv(self):
|
||||
@@ -572,6 +578,13 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
verbose_name='NAT (Inside)',
|
||||
help_text='The IP for which this address is the "outside" IP'
|
||||
)
|
||||
dns_name = models.CharField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
validators=[DNSValidator],
|
||||
verbose_name='DNS Name',
|
||||
help_text='Hostname or FQDN (not case-sensitive)'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=100,
|
||||
blank=True
|
||||
@@ -583,11 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
)
|
||||
|
||||
objects = IPAddressManager()
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary',
|
||||
'description',
|
||||
'dns_name', 'description',
|
||||
]
|
||||
|
||||
class Meta:
|
||||
@@ -624,11 +637,37 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if self.address:
|
||||
# Infer address family from IPAddress object
|
||||
|
||||
# Record address family
|
||||
if isinstance(self.address, netaddr.IPNetwork):
|
||||
self.family = self.address.version
|
||||
|
||||
# Force dns_name to lowercase
|
||||
self.dns_name = self.dns_name.lower()
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def log_change(self, user, request_id, action):
|
||||
"""
|
||||
Include the connected Interface (if any).
|
||||
"""
|
||||
|
||||
# It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve
|
||||
# the interface will raise DoesNotExist.
|
||||
try:
|
||||
parent_obj = self.interface
|
||||
except ObjectDoesNotExist:
|
||||
parent_obj = None
|
||||
|
||||
ObjectChange(
|
||||
user=user,
|
||||
request_id=request_id,
|
||||
changed_object=self,
|
||||
related_object=parent_obj,
|
||||
action=action,
|
||||
object_data=serialize_object(self)
|
||||
).save()
|
||||
|
||||
def to_csv(self):
|
||||
|
||||
# Determine if this IP is primary for a Device
|
||||
@@ -649,6 +688,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
self.virtual_machine.name if self.virtual_machine else None,
|
||||
self.interface.name if self.interface else None,
|
||||
is_primary,
|
||||
self.dns_name,
|
||||
self.description,
|
||||
)
|
||||
|
||||
@@ -790,7 +830,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description']
|
||||
|
||||
@@ -892,7 +932,7 @@ class Service(ChangeLoggedModel, CustomFieldModel):
|
||||
object_id_field='obj_id'
|
||||
)
|
||||
|
||||
tags = TaggableManager()
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description']
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from utilities.sql import NullsFirstQuerySet
|
||||
from django.db.models import QuerySet
|
||||
|
||||
|
||||
class PrefixQuerySet(NullsFirstQuerySet):
|
||||
class PrefixQuerySet(QuerySet):
|
||||
|
||||
def annotate_depth(self, limit=None):
|
||||
"""
|
||||
|
||||
@@ -30,7 +30,7 @@ RIR_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.ipam.change_rir %}
|
||||
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'ipam:rir_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 %}
|
||||
"""
|
||||
|
||||
@@ -52,7 +52,7 @@ ROLE_ACTIONS = """
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.ipam.change_role %}
|
||||
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'ipam:role_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 %}
|
||||
"""
|
||||
|
||||
@@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if perms.ipam.change_vlangroup %}
|
||||
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -203,7 +203,7 @@ class RIRTable(BaseTable):
|
||||
name = tables.LinkColumn(verbose_name='Name')
|
||||
is_private = BooleanColumn(verbose_name='Private')
|
||||
aggregate_count = tables.Column(verbose_name='Aggregates')
|
||||
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
@@ -288,7 +288,7 @@ class RoleTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='VLANs'
|
||||
)
|
||||
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
|
||||
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
@@ -339,7 +339,9 @@ class IPAddressTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, IPAddress) else '',
|
||||
}
|
||||
@@ -352,7 +354,8 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
|
||||
class Meta(IPAddressTable.Meta):
|
||||
fields = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description',
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name',
|
||||
'description',
|
||||
)
|
||||
|
||||
|
||||
@@ -392,7 +395,7 @@ class VLANGroupTable(BaseTable):
|
||||
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
|
||||
vlan_count = tables.Column(verbose_name='VLANs')
|
||||
slug = tables.Column(verbose_name='Slug')
|
||||
actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
|
||||
actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name='')
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
@@ -437,7 +440,7 @@ class VLANMemberTable(BaseTable):
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_ACTIONS,
|
||||
attrs={'td': {'class': 'text-right'}},
|
||||
attrs={'td': {'class': 'text-right noprint'}},
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ class VRFTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'rd', 'url']
|
||||
['id', 'name', 'prefix_count', 'rd', 'url']
|
||||
)
|
||||
|
||||
def test_create_vrf(self):
|
||||
@@ -147,7 +147,7 @@ class RIRTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['aggregate_count', 'id', 'name', 'slug', 'url']
|
||||
)
|
||||
|
||||
def test_create_rir(self):
|
||||
@@ -351,7 +351,7 @@ class RoleTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count']
|
||||
)
|
||||
|
||||
def test_create_role(self):
|
||||
@@ -790,7 +790,7 @@ class VLANGroupTest(APITestCase):
|
||||
|
||||
self.assertEqual(
|
||||
sorted(response.data['results'][0]),
|
||||
['id', 'name', 'slug', 'url']
|
||||
['id', 'name', 'slug', 'url', 'vlan_count']
|
||||
)
|
||||
|
||||
def test_create_vlangroup(self):
|
||||
|
||||
286
netbox/ipam/tests/test_views.py
Normal file
286
netbox/ipam/tests/test_views.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from netaddr import IPNetwork
|
||||
import urllib.parse
|
||||
|
||||
from django.test import Client, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
from ipam.constants import IP_PROTOCOL_TCP
|
||||
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
from utilities.testing import create_test_user
|
||||
|
||||
|
||||
class VRFTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_vrf'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
VRF.objects.bulk_create([
|
||||
VRF(name='VRF 1', rd='65000:1'),
|
||||
VRF(name='VRF 2', rd='65000:2'),
|
||||
VRF(name='VRF 3', rd='65000:3'),
|
||||
])
|
||||
|
||||
def test_vrf_list(self):
|
||||
|
||||
url = reverse('ipam:vrf_list')
|
||||
params = {
|
||||
"q": "65000",
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_configcontext(self):
|
||||
|
||||
vrf = VRF.objects.first()
|
||||
response = self.client.get(vrf.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RIRTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_rir'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
RIR.objects.bulk_create([
|
||||
RIR(name='RIR 1', slug='rir-1'),
|
||||
RIR(name='RIR 2', slug='rir-2'),
|
||||
RIR(name='RIR 3', slug='rir-3'),
|
||||
])
|
||||
|
||||
def test_rir_list(self):
|
||||
|
||||
url = reverse('ipam:rir_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class AggregateTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_aggregate'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
rir = RIR(name='RIR 1', slug='rir-1')
|
||||
rir.save()
|
||||
|
||||
Aggregate.objects.bulk_create([
|
||||
Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
|
||||
Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
|
||||
Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
|
||||
])
|
||||
|
||||
def test_aggregate_list(self):
|
||||
|
||||
url = reverse('ipam:aggregate_list')
|
||||
params = {
|
||||
"rir": RIR.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_aggregate(self):
|
||||
|
||||
aggregate = Aggregate.objects.first()
|
||||
response = self.client.get(aggregate.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class RoleTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_role'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
Role.objects.bulk_create([
|
||||
Role(name='Role 1', slug='role-1'),
|
||||
Role(name='Role 2', slug='role-2'),
|
||||
Role(name='Role 3', slug='role-3'),
|
||||
])
|
||||
|
||||
def test_role_list(self):
|
||||
|
||||
url = reverse('ipam:role_list')
|
||||
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class PrefixTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_prefix'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
Prefix.objects.bulk_create([
|
||||
Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
|
||||
Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
|
||||
Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
|
||||
])
|
||||
|
||||
def test_prefix_list(self):
|
||||
|
||||
url = reverse('ipam:prefix_list')
|
||||
params = {
|
||||
"site": Site.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_prefix(self):
|
||||
|
||||
prefix = Prefix.objects.first()
|
||||
response = self.client.get(prefix.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class IPAddressTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_ipaddress'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
vrf = VRF(name='VRF 1', rd='65000:1')
|
||||
vrf.save()
|
||||
|
||||
IPAddress.objects.bulk_create([
|
||||
IPAddress(family=4, address=IPNetwork('10.1.0.0/16'), vrf=vrf),
|
||||
IPAddress(family=4, address=IPNetwork('10.2.0.0/16'), vrf=vrf),
|
||||
IPAddress(family=4, address=IPNetwork('10.3.0.0/16'), vrf=vrf),
|
||||
])
|
||||
|
||||
def test_ipaddress_list(self):
|
||||
|
||||
url = reverse('ipam:ipaddress_list')
|
||||
params = {
|
||||
"vrf": VRF.objects.first().rd,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_ipaddress(self):
|
||||
|
||||
ipaddress = IPAddress.objects.first()
|
||||
response = self.client.get(ipaddress.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class VLANGroupTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_vlangroup'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
VLANGroup.objects.bulk_create([
|
||||
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
|
||||
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site),
|
||||
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
|
||||
])
|
||||
|
||||
def test_vlangroup_list(self):
|
||||
|
||||
url = reverse('ipam:vlangroup_list')
|
||||
params = {
|
||||
"site": Site.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class VLANTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_vlan'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
|
||||
vlangroup.save()
|
||||
|
||||
VLAN.objects.bulk_create([
|
||||
VLAN(group=vlangroup, vid=101, name='VLAN101'),
|
||||
VLAN(group=vlangroup, vid=102, name='VLAN102'),
|
||||
VLAN(group=vlangroup, vid=103, name='VLAN103'),
|
||||
])
|
||||
|
||||
def test_vlan_list(self):
|
||||
|
||||
url = reverse('ipam:vlan_list')
|
||||
params = {
|
||||
"group": VLANGroup.objects.first().slug,
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_vlan(self):
|
||||
|
||||
vlan = VLAN.objects.first()
|
||||
response = self.client.get(vlan.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ServiceTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
user = create_test_user(permissions=['ipam.view_service'])
|
||||
self.client = Client()
|
||||
self.client.force_login(user)
|
||||
|
||||
site = Site(name='Site 1', slug='site-1')
|
||||
site.save()
|
||||
|
||||
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
|
||||
manufacturer.save()
|
||||
|
||||
devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
|
||||
devicetype.save()
|
||||
|
||||
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
|
||||
devicerole.save()
|
||||
|
||||
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
|
||||
device.save()
|
||||
|
||||
Service.objects.bulk_create([
|
||||
Service(device=device, name='Service 1', protocol=IP_PROTOCOL_TCP, port=101),
|
||||
Service(device=device, name='Service 2', protocol=IP_PROTOCOL_TCP, port=102),
|
||||
Service(device=device, name='Service 3', protocol=IP_PROTOCOL_TCP, port=103),
|
||||
])
|
||||
|
||||
def test_service_list(self):
|
||||
|
||||
url = reverse('ipam:service_list')
|
||||
params = {
|
||||
"device_id": Device.objects.first(),
|
||||
}
|
||||
|
||||
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_service(self):
|
||||
|
||||
service = Service.objects.first()
|
||||
response = self.client.get(service.get_absolute_url())
|
||||
self.assertEqual(response.status_code, 200)
|
||||
8
netbox/ipam/validators.py
Normal file
8
netbox/ipam/validators.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
|
||||
DNSValidator = RegexValidator(
|
||||
regex='^[0-9A-Za-z.-]+$',
|
||||
message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names',
|
||||
code='invalid'
|
||||
)
|
||||
@@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans):
|
||||
# VRFs
|
||||
#
|
||||
|
||||
class VRFListView(ObjectListView):
|
||||
class VRFListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_vrf'
|
||||
queryset = VRF.objects.select_related('tenant')
|
||||
filter = filters.VRFFilter
|
||||
filter_form = forms.VRFFilterForm
|
||||
@@ -121,7 +122,8 @@ class VRFListView(ObjectListView):
|
||||
template_name = 'ipam/vrf_list.html'
|
||||
|
||||
|
||||
class VRFView(View):
|
||||
class VRFView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_vrf'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# RIRs
|
||||
#
|
||||
|
||||
class RIRListView(ObjectListView):
|
||||
class RIRListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_rir'
|
||||
queryset = RIR.objects.annotate(aggregate_count=Count('aggregates'))
|
||||
filter = filters.RIRFilter
|
||||
filter_form = forms.RIRFilterForm
|
||||
@@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Aggregates
|
||||
#
|
||||
|
||||
class AggregateListView(ObjectListView):
|
||||
class AggregateListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_aggregate'
|
||||
queryset = Aggregate.objects.select_related('rir').extra(select={
|
||||
'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix',
|
||||
})
|
||||
@@ -312,7 +316,8 @@ class AggregateListView(ObjectListView):
|
||||
}
|
||||
|
||||
|
||||
class AggregateView(View):
|
||||
class AggregateView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_aggregate'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Prefix/VLAN roles
|
||||
#
|
||||
|
||||
class RoleListView(ObjectListView):
|
||||
class RoleListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_role'
|
||||
queryset = Role.objects.all()
|
||||
table = tables.RoleTable
|
||||
template_name = 'ipam/role_list.html'
|
||||
@@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Prefixes
|
||||
#
|
||||
|
||||
class PrefixListView(ObjectListView):
|
||||
class PrefixListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_prefix'
|
||||
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
|
||||
filter = filters.PrefixFilter
|
||||
filter_form = forms.PrefixFilterForm
|
||||
@@ -446,7 +453,8 @@ class PrefixListView(ObjectListView):
|
||||
return self.queryset.annotate_depth(limit=limit)
|
||||
|
||||
|
||||
class PrefixView(View):
|
||||
class PrefixView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_prefix'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -489,7 +497,8 @@ class PrefixView(View):
|
||||
})
|
||||
|
||||
|
||||
class PrefixPrefixesView(View):
|
||||
class PrefixPrefixesView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_prefix'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -531,7 +540,8 @@ class PrefixPrefixesView(View):
|
||||
})
|
||||
|
||||
|
||||
class PrefixIPAddressesView(View):
|
||||
class PrefixIPAddressesView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_prefix'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressListView(ObjectListView):
|
||||
class IPAddressListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_ipaddress'
|
||||
queryset = IPAddress.objects.select_related(
|
||||
'vrf__tenant', 'tenant', 'nat_inside'
|
||||
).prefetch_related(
|
||||
@@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView):
|
||||
template_name = 'ipam/ipaddress_list.html'
|
||||
|
||||
|
||||
class IPAddressView(View):
|
||||
class IPAddressView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_ipaddress'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# VLAN groups
|
||||
#
|
||||
|
||||
class VLANGroupListView(ObjectListView):
|
||||
class VLANGroupListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_vlangroup'
|
||||
queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans'))
|
||||
filter = filters.VLANGroupFilter
|
||||
filter_form = forms.VLANGroupFilterForm
|
||||
@@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
default_return_url = 'ipam:vlangroup_list'
|
||||
|
||||
|
||||
class VLANGroupVLANsView(View):
|
||||
class VLANGroupVLANsView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_vlangroup'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk)
|
||||
@@ -861,7 +876,8 @@ class VLANGroupVLANsView(View):
|
||||
# VLANs
|
||||
#
|
||||
|
||||
class VLANListView(ObjectListView):
|
||||
class VLANListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_vlan'
|
||||
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes')
|
||||
filter = filters.VLANFilter
|
||||
filter_form = forms.VLANFilterForm
|
||||
@@ -869,7 +885,8 @@ class VLANListView(ObjectListView):
|
||||
template_name = 'ipam/vlan_list.html'
|
||||
|
||||
|
||||
class VLANView(View):
|
||||
class VLANView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_vlan'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -886,7 +903,8 @@ class VLANView(View):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(View):
|
||||
class VLANMembersView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_vlan'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
@@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceListView(ObjectListView):
|
||||
class ServiceListView(PermissionRequiredMixin, ObjectListView):
|
||||
permission_required = 'ipam.view_service'
|
||||
queryset = Service.objects.select_related('device', 'virtual_machine')
|
||||
filter = filters.ServiceFilter
|
||||
filter_form = forms.ServiceFilterForm
|
||||
@@ -962,7 +981,8 @@ class ServiceListView(ObjectListView):
|
||||
template_name = 'ipam/service_list.html'
|
||||
|
||||
|
||||
class ServiceView(View):
|
||||
class ServiceView(PermissionRequiredMixin, View):
|
||||
permission_required = 'ipam.view_service'
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
|
||||
@@ -2,8 +2,6 @@ from django.conf import settings
|
||||
from django.contrib.admin import AdminSite
|
||||
from django.contrib.auth.admin import GroupAdmin, UserAdmin
|
||||
from django.contrib.auth.models import Group, User
|
||||
from taggit.admin import TagAdmin
|
||||
from taggit.models import Tag
|
||||
|
||||
|
||||
class NetBoxAdminSite(AdminSite):
|
||||
@@ -20,7 +18,6 @@ admin_site = NetBoxAdminSite(name='admin')
|
||||
# Register external models
|
||||
admin_site.register(Group, GroupAdmin)
|
||||
admin_site.register(User, UserAdmin)
|
||||
admin_site.register(Tag, TagAdmin)
|
||||
|
||||
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
|
||||
if settings.WEBHOOKS_ENABLED:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from django.conf import settings
|
||||
from django.db.models import QuerySet
|
||||
from rest_framework import authentication, exceptions
|
||||
from rest_framework.pagination import LimitOffsetPagination
|
||||
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
|
||||
@@ -55,16 +56,31 @@ class TokenPermissions(DjangoModelPermissions):
|
||||
Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability
|
||||
for unsafe requests (POST/PUT/PATCH/DELETE).
|
||||
"""
|
||||
# Override the stock perm_map to enforce view permissions
|
||||
perms_map = {
|
||||
'GET': ['%(app_label)s.view_%(model_name)s'],
|
||||
'OPTIONS': [],
|
||||
'HEAD': ['%(app_label)s.view_%(model_name)s'],
|
||||
'POST': ['%(app_label)s.add_%(model_name)s'],
|
||||
'PUT': ['%(app_label)s.change_%(model_name)s'],
|
||||
'PATCH': ['%(app_label)s.change_%(model_name)s'],
|
||||
'DELETE': ['%(app_label)s.delete_%(model_name)s'],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
|
||||
# LOGIN_REQUIRED determines whether read-only access is provided to anonymous users.
|
||||
self.authenticated_users_only = settings.LOGIN_REQUIRED
|
||||
|
||||
super().__init__()
|
||||
|
||||
def has_permission(self, request, view):
|
||||
|
||||
# If token authentication is in use, verify that the token allows write operations (for unsafe methods).
|
||||
if request.method not in SAFE_METHODS and isinstance(request.auth, Token):
|
||||
if not request.auth.write_enabled:
|
||||
return False
|
||||
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
@@ -81,13 +97,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
def paginate_queryset(self, queryset, request, view=None):
|
||||
|
||||
if hasattr(queryset, 'all'):
|
||||
# TODO: This breaks filtering by annotated values
|
||||
# Make a clone of the queryset with any annotations stripped (performance hack)
|
||||
qs = queryset.all()
|
||||
qs.query.annotations.clear()
|
||||
self.count = qs.count()
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
self.count = queryset.count()
|
||||
else:
|
||||
# We're dealing with an iterable, not a QuerySet
|
||||
self.count = len(queryset)
|
||||
@@ -147,18 +158,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
# Miscellaneous
|
||||
#
|
||||
|
||||
def get_view_name(view_cls, suffix=None):
|
||||
def get_view_name(view, suffix=None):
|
||||
"""
|
||||
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
|
||||
"""
|
||||
if hasattr(view_cls, 'queryset'):
|
||||
if hasattr(view, 'queryset'):
|
||||
# Determine the model name from the queryset.
|
||||
name = view_cls.queryset.model._meta.verbose_name
|
||||
name = view.queryset.model._meta.verbose_name
|
||||
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
|
||||
|
||||
else:
|
||||
# Replicate DRF's built-in behavior.
|
||||
name = view_cls.__name__
|
||||
name = view.__class__.__name__
|
||||
name = formatting.remove_trailing_string(name, 'View')
|
||||
name = formatting.remove_trailing_string(name, 'ViewSet')
|
||||
name = formatting.camelcase_to_spaces(name)
|
||||
|
||||
@@ -25,6 +25,17 @@ DATABASE = {
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
|
||||
SECRET_KEY = ''
|
||||
|
||||
# Redis database settings. The Redis database is used for caching and background processing such as webhooks
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'CACHE_DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
|
||||
|
||||
#########################
|
||||
# #
|
||||
@@ -50,6 +61,9 @@ BANNER_LOGIN = ''
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# Cache timeout in seconds. Set to 0 to dissable caching. Defaults to 900 (15 minutes)
|
||||
CACHE_TIMEOUT = 900
|
||||
|
||||
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
|
||||
CHANGELOG_RETENTION = 90
|
||||
|
||||
@@ -83,6 +97,14 @@ EMAIL = {
|
||||
# (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True.
|
||||
ENFORCE_GLOBAL_UNIQUE = False
|
||||
|
||||
# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
|
||||
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
# 'dcim.site',
|
||||
# 'dcim.region',
|
||||
# 'ipam.prefix',
|
||||
]
|
||||
|
||||
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
|
||||
# https://docs.djangoproject.com/en/1.11/topics/logging/
|
||||
LOGGING = {}
|
||||
@@ -107,6 +129,9 @@ MAX_PAGE_SIZE = 1000
|
||||
# the default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
||||
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
|
||||
METRICS_ENABLED = True
|
||||
|
||||
# Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM.
|
||||
NAPALM_USERNAME = ''
|
||||
NAPALM_PASSWORD = ''
|
||||
@@ -125,15 +150,6 @@ PAGINATE_COUNT = 50
|
||||
# prefer IPv4 instead.
|
||||
PREFER_IPV4 = False
|
||||
|
||||
# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled.
|
||||
REDIS = {
|
||||
'HOST': 'localhost',
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
}
|
||||
|
||||
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of
|
||||
# this setting is derived from the installed location.
|
||||
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
|
||||
|
||||
@@ -16,6 +16,7 @@ OBJ_TYPE_CHOICES = (
|
||||
('device', 'Devices'),
|
||||
('virtualchassis', 'Virtual Chassis'),
|
||||
('cable', 'Cables'),
|
||||
('powerfeed', 'Power Feeds'),
|
||||
)),
|
||||
('IPAM', (
|
||||
('vrf', 'VRFs'),
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from django.contrib.messages import constants as messages
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
# Django 2.1 requires Python 3.5+
|
||||
if sys.version_info < (3, 5):
|
||||
|
||||
#
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.6-beta1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
||||
# Set the base directory two levels up
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Django 2.1+ requires Python 3.5+
|
||||
if platform.python_version_tuple() < ('3', '5'):
|
||||
raise RuntimeError(
|
||||
"NetBox requires Python 3.5 or higher (current: Python {})".format(sys.version.split()[0])
|
||||
"NetBox requires Python 3.5 or higher (current: Python {})".format(platform.python_version())
|
||||
)
|
||||
|
||||
# Check for configuration file
|
||||
|
||||
#
|
||||
# Configuration import
|
||||
#
|
||||
|
||||
# Import configuration parameters
|
||||
try:
|
||||
from netbox import configuration
|
||||
except ImportError:
|
||||
@@ -21,22 +39,20 @@ except ImportError:
|
||||
"Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation."
|
||||
)
|
||||
|
||||
|
||||
VERSION = '2.5.5'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Import required configuration parameters
|
||||
ALLOWED_HOSTS = DATABASE = SECRET_KEY = None
|
||||
for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']:
|
||||
try:
|
||||
globals()[setting] = getattr(configuration, setting)
|
||||
except AttributeError:
|
||||
# Enforce required configuration parameters
|
||||
for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']:
|
||||
if not hasattr(configuration, parameter):
|
||||
raise ImproperlyConfigured(
|
||||
"Mandatory setting {} is missing from configuration.py.".format(setting)
|
||||
"Required parameter {} is missing from configuration.py.".format(parameter)
|
||||
)
|
||||
|
||||
# Import optional configuration parameters
|
||||
# Set required parameters
|
||||
ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS')
|
||||
DATABASE = getattr(configuration, 'DATABASE')
|
||||
REDIS = getattr(configuration, 'REDIS')
|
||||
SECRET_KEY = getattr(configuration, 'SECRET_KEY')
|
||||
|
||||
# Set optional parameters
|
||||
ADMINS = getattr(configuration, 'ADMINS', [])
|
||||
BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '')
|
||||
BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '')
|
||||
@@ -44,6 +60,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
|
||||
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900)
|
||||
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
@@ -51,22 +68,23 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
|
||||
DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
|
||||
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
|
||||
DEBUG = getattr(configuration, 'DEBUG', False)
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
EMAIL = getattr(configuration, 'EMAIL', {})
|
||||
ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False)
|
||||
EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', [])
|
||||
LOGGING = getattr(configuration, 'LOGGING', {})
|
||||
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
|
||||
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
|
||||
MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False)
|
||||
MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000)
|
||||
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
|
||||
NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', True)
|
||||
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
|
||||
NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
|
||||
NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
|
||||
NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {})
|
||||
NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
REDIS = getattr(configuration, 'REDIS', {})
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
|
||||
@@ -75,64 +93,54 @@ TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a')
|
||||
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
|
||||
WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False)
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Attempt to import LDAP configuration if it has been defined
|
||||
LDAP_IGNORE_CERT_ERRORS = False
|
||||
try:
|
||||
from netbox.ldap_config import *
|
||||
LDAP_CONFIGURED = True
|
||||
except ImportError:
|
||||
LDAP_CONFIGURED = False
|
||||
|
||||
# LDAP configuration (optional)
|
||||
if LDAP_CONFIGURED:
|
||||
try:
|
||||
import ldap
|
||||
import django_auth_ldap
|
||||
# Prepend LDAPBackend to the default ModelBackend
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'django_auth_ldap.backend.LDAPBackend',
|
||||
'django.contrib.auth.backends.ModelBackend',
|
||||
]
|
||||
# Optionally disable strict certificate checking
|
||||
if LDAP_IGNORE_CERT_ERRORS:
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
# Enable logging for django_auth_ldap
|
||||
ldap_logger = logging.getLogger('django_auth_ldap')
|
||||
ldap_logger.addHandler(logging.StreamHandler())
|
||||
ldap_logger.setLevel(logging.DEBUG)
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove "
|
||||
"netbox/ldap_config.py to disable LDAP."
|
||||
)
|
||||
|
||||
#
|
||||
# Database
|
||||
configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'})
|
||||
#
|
||||
|
||||
# Only PostgreSQL is supported
|
||||
if METRICS_ENABLED:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django_prometheus.db.backends.postgresql'
|
||||
})
|
||||
else:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django.db.backends.postgresql'
|
||||
})
|
||||
|
||||
DATABASES = {
|
||||
'default': configuration.DATABASE,
|
||||
'default': DATABASE,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Redis
|
||||
#
|
||||
|
||||
REDIS_HOST = REDIS.get('HOST', 'localhost')
|
||||
REDIS_PORT = REDIS.get('PORT', 6379)
|
||||
REDIS_PASSWORD = REDIS.get('PASSWORD', '')
|
||||
REDIS_DATABASE = REDIS.get('DATABASE', 0)
|
||||
REDIS_CACHE_DATABASE = REDIS.get('CACHE_DATABASE', 1)
|
||||
REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
REDIS_SSL = REDIS.get('SSL', False)
|
||||
|
||||
|
||||
#
|
||||
# Sessions
|
||||
#
|
||||
|
||||
if LOGIN_TIMEOUT is not None:
|
||||
if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0:
|
||||
raise ImproperlyConfigured(
|
||||
"LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT)
|
||||
)
|
||||
# Django default is 1209600 seconds (14 days)
|
||||
SESSION_COOKIE_AGE = LOGIN_TIMEOUT
|
||||
if SESSION_FILE_PATH is not None:
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.file'
|
||||
|
||||
# Redis
|
||||
REDIS_HOST = REDIS.get('HOST', 'localhost')
|
||||
REDIS_PORT = REDIS.get('PORT', 6379)
|
||||
REDIS_PASSWORD = REDIS.get('PASSWORD', '')
|
||||
REDIS_DATABASE = REDIS.get('DATABASE', 0)
|
||||
REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
|
||||
#
|
||||
# Email
|
||||
#
|
||||
|
||||
EMAIL_HOST = EMAIL.get('SERVER')
|
||||
EMAIL_PORT = EMAIL.get('PORT', 25)
|
||||
EMAIL_HOST_USER = EMAIL.get('USERNAME')
|
||||
@@ -141,7 +149,11 @@ EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10)
|
||||
SERVER_EMAIL = EMAIL.get('FROM_EMAIL')
|
||||
EMAIL_SUBJECT_PREFIX = '[NetBox] '
|
||||
|
||||
# Installed applications
|
||||
|
||||
#
|
||||
# Django
|
||||
#
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
@@ -150,10 +162,12 @@ INSTALLED_APPS = [
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.humanize',
|
||||
'cacheops',
|
||||
'corsheaders',
|
||||
'debug_toolbar',
|
||||
'django_filters',
|
||||
'django_tables2',
|
||||
'django_prometheus',
|
||||
'mptt',
|
||||
'rest_framework',
|
||||
'taggit',
|
||||
@@ -178,6 +192,7 @@ if WEBHOOKS_ENABLED:
|
||||
# Middleware
|
||||
MIDDLEWARE = (
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
'django_prometheus.middleware.PrometheusBeforeMiddleware',
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
@@ -190,14 +205,16 @@ MIDDLEWARE = (
|
||||
'utilities.middleware.LoginRequiredMiddleware',
|
||||
'utilities.middleware.APIVersionMiddleware',
|
||||
'extras.middleware.ObjectChangeMiddleware',
|
||||
'django_prometheus.middleware.PrometheusAfterMiddleware',
|
||||
)
|
||||
|
||||
ROOT_URLCONF = 'netbox.urls'
|
||||
|
||||
TEMPLATES_DIR = BASE_DIR + '/templates'
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [BASE_DIR + '/templates/'],
|
||||
'DIRS': [TEMPLATES_DIR],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
@@ -212,18 +229,23 @@ TEMPLATES = [
|
||||
},
|
||||
]
|
||||
|
||||
# WSGI
|
||||
WSGI_APPLICATION = 'netbox.wsgi.application'
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
USE_X_FORWARDED_HOST = True
|
||||
# Authentication
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
'utilities.auth_backends.ViewExemptModelBackend',
|
||||
]
|
||||
|
||||
# Internationalization
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
USE_I18N = True
|
||||
USE_TZ = True
|
||||
|
||||
# WSGI
|
||||
WSGI_APPLICATION = 'netbox.wsgi.application'
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
USE_X_FORWARDED_HOST = True
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
STATIC_ROOT = BASE_DIR + '/static/'
|
||||
STATIC_ROOT = BASE_DIR + '/static'
|
||||
STATIC_URL = '/{}static/'.format(BASE_PATH)
|
||||
STATICFILES_DIRS = (
|
||||
os.path.join(BASE_DIR, "project-static"),
|
||||
@@ -243,22 +265,133 @@ MESSAGE_TAGS = {
|
||||
# Authentication URLs
|
||||
LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
||||
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS
|
||||
|
||||
# Pagination
|
||||
PER_PAGE_DEFAULTS = [
|
||||
25, 50, 100, 250, 500, 1000
|
||||
]
|
||||
if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
|
||||
PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
|
||||
PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
|
||||
|
||||
#
|
||||
# LDAP authentication (optional)
|
||||
#
|
||||
|
||||
try:
|
||||
from netbox import ldap_config as LDAP_CONFIG
|
||||
except ImportError:
|
||||
LDAP_CONFIG = None
|
||||
|
||||
if LDAP_CONFIG is not None:
|
||||
|
||||
# Check that django_auth_ldap is installed
|
||||
try:
|
||||
import ldap
|
||||
import django_auth_ldap
|
||||
except ImportError:
|
||||
raise ImproperlyConfigured(
|
||||
"LDAP authentication has been configured, but django-auth-ldap is not installed. Remove "
|
||||
"netbox/ldap_config.py to disable LDAP."
|
||||
)
|
||||
|
||||
# Required configuration parameters
|
||||
try:
|
||||
AUTH_LDAP_SERVER_URI = getattr(LDAP_CONFIG, 'AUTH_LDAP_SERVER_URI')
|
||||
except AttributeError:
|
||||
raise ImproperlyConfigured(
|
||||
"Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py."
|
||||
)
|
||||
|
||||
# Optional configuration parameters
|
||||
AUTH_LDAP_ALWAYS_UPDATE_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_ALWAYS_UPDATE_USER', True)
|
||||
AUTH_LDAP_AUTHORIZE_ALL_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_AUTHORIZE_ALL_USERS', False)
|
||||
AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', False)
|
||||
AUTH_LDAP_BIND_DN = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_DN', '')
|
||||
AUTH_LDAP_BIND_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_PASSWORD', '')
|
||||
AUTH_LDAP_CACHE_TIMEOUT = getattr(LDAP_CONFIG, 'AUTH_LDAP_CACHE_TIMEOUT', 0)
|
||||
AUTH_LDAP_CONNECTION_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_CONNECTION_OPTIONS', {})
|
||||
AUTH_LDAP_DENY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_DENY_GROUP', None)
|
||||
AUTH_LDAP_FIND_GROUP_PERMS = getattr(LDAP_CONFIG, 'AUTH_LDAP_FIND_GROUP_PERMS', False)
|
||||
AUTH_LDAP_GLOBAL_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_GLOBAL_OPTIONS', {})
|
||||
AUTH_LDAP_GROUP_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_SEARCH', None)
|
||||
AUTH_LDAP_GROUP_TYPE = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_TYPE', None)
|
||||
AUTH_LDAP_MIRROR_GROUPS = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS', None)
|
||||
AUTH_LDAP_MIRROR_GROUPS_EXCEPT = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS_EXCEPT', None)
|
||||
AUTH_LDAP_PERMIT_EMPTY_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_PERMIT_EMPTY_PASSWORD', False)
|
||||
AUTH_LDAP_REQUIRE_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_REQUIRE_GROUP', None)
|
||||
AUTH_LDAP_NO_NEW_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_NO_NEW_USERS', False)
|
||||
AUTH_LDAP_START_TLS = getattr(LDAP_CONFIG, 'AUTH_LDAP_START_TLS', False)
|
||||
AUTH_LDAP_USER_QUERY_FIELD = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_QUERY_FIELD', None)
|
||||
AUTH_LDAP_USER_ATTRLIST = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTRLIST', None)
|
||||
AUTH_LDAP_USER_ATTR_MAP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTR_MAP', {})
|
||||
AUTH_LDAP_USER_DN_TEMPLATE = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_DN_TEMPLATE', None)
|
||||
AUTH_LDAP_USER_FLAGS_BY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {})
|
||||
AUTH_LDAP_USER_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_SEARCH', None)
|
||||
|
||||
# Optionally disable strict certificate checking
|
||||
if getattr(LDAP_CONFIG, 'LDAP_IGNORE_CERT_ERRORS', False):
|
||||
ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER)
|
||||
|
||||
# Prepend LDAPBackend to the authentication backends list
|
||||
AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend')
|
||||
|
||||
# Enable logging for django_auth_ldap
|
||||
ldap_logger = logging.getLogger('django_auth_ldap')
|
||||
ldap_logger.addHandler(logging.StreamHandler())
|
||||
ldap_logger.setLevel(logging.DEBUG)
|
||||
|
||||
|
||||
#
|
||||
# Caching
|
||||
#
|
||||
|
||||
if REDIS_SSL:
|
||||
REDIS_CACHE_CON_STRING = 'rediss://'
|
||||
else:
|
||||
REDIS_CACHE_CON_STRING = 'redis://'
|
||||
|
||||
if REDIS_PASSWORD:
|
||||
REDIS_CACHE_CON_STRING = '{}{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD)
|
||||
|
||||
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE)
|
||||
|
||||
if not CACHE_TIMEOUT:
|
||||
CACHEOPS_ENABLED = False
|
||||
else:
|
||||
CACHEOPS_ENABLED = True
|
||||
|
||||
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
|
||||
CACHEOPS_DEFAULTS = {
|
||||
'timeout': CACHE_TIMEOUT
|
||||
}
|
||||
CACHEOPS = {
|
||||
'auth.user': {'ops': 'get', 'timeout': 60 * 15},
|
||||
'auth.*': {'ops': ('fetch', 'get')},
|
||||
'auth.permission': {'ops': 'all'},
|
||||
'dcim.*': {'ops': 'all'},
|
||||
'ipam.*': {'ops': 'all'},
|
||||
'extras.*': {'ops': 'all'},
|
||||
'secrets.*': {'ops': 'all'},
|
||||
'users.*': {'ops': 'all'},
|
||||
'tenancy.*': {'ops': 'all'},
|
||||
'virtualization.*': {'ops': 'all'},
|
||||
}
|
||||
CACHEOPS_DEGRADE_ON_FAILURE = True
|
||||
|
||||
|
||||
#
|
||||
# Django Prometheus
|
||||
#
|
||||
PROMETHEUS_EXPORT_MIGRATIONS = False
|
||||
|
||||
|
||||
#
|
||||
# Django filters
|
||||
#
|
||||
|
||||
FILTERS_NULL_CHOICE_LABEL = 'None'
|
||||
FILTERS_NULL_CHOICE_VALUE = 'null'
|
||||
|
||||
|
||||
#
|
||||
# Django REST framework (API)
|
||||
#
|
||||
|
||||
REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version
|
||||
REST_FRAMEWORK = {
|
||||
'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION],
|
||||
@@ -283,18 +416,11 @@ REST_FRAMEWORK = {
|
||||
'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name',
|
||||
}
|
||||
|
||||
# Django RQ (Webhooks backend)
|
||||
RQ_QUEUES = {
|
||||
'default': {
|
||||
'HOST': REDIS_HOST,
|
||||
'PORT': REDIS_PORT,
|
||||
'DB': REDIS_DATABASE,
|
||||
'PASSWORD': REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
|
||||
}
|
||||
}
|
||||
|
||||
# drf_yasg settings for Swagger
|
||||
#
|
||||
# drf_yasg (OpenAPI/Swagger)
|
||||
#
|
||||
|
||||
SWAGGER_SETTINGS = {
|
||||
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
|
||||
'DEFAULT_FIELD_INSPECTORS': [
|
||||
@@ -308,6 +434,7 @@ SWAGGER_SETTINGS = {
|
||||
'drf_yasg.inspectors.ChoiceFieldInspector',
|
||||
'drf_yasg.inspectors.FileFieldInspector',
|
||||
'drf_yasg.inspectors.DictFieldInspector',
|
||||
'drf_yasg.inspectors.SerializerMethodFieldInspector',
|
||||
'drf_yasg.inspectors.SimpleFieldInspector',
|
||||
'drf_yasg.inspectors.StringDefaultFieldInspector',
|
||||
],
|
||||
@@ -315,6 +442,7 @@ SWAGGER_SETTINGS = {
|
||||
'utilities.custom_inspectors.IdInFilterInspector',
|
||||
'drf_yasg.inspectors.CoreAPICompatInspector',
|
||||
],
|
||||
'DEFAULT_MODEL_DEPTH': 1,
|
||||
'DEFAULT_PAGINATOR_INSPECTORS': [
|
||||
'utilities.custom_inspectors.NullablePaginatorInspector',
|
||||
'drf_yasg.inspectors.DjangoRestResponsePagination',
|
||||
@@ -331,14 +459,43 @@ SWAGGER_SETTINGS = {
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Django RQ (Webhooks backend)
|
||||
#
|
||||
|
||||
RQ_QUEUES = {
|
||||
'default': {
|
||||
'HOST': REDIS_HOST,
|
||||
'PORT': REDIS_PORT,
|
||||
'DB': REDIS_DATABASE,
|
||||
'PASSWORD': REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
|
||||
'SSL': REDIS_SSL,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Django debug toolbar
|
||||
#
|
||||
|
||||
INTERNAL_IPS = (
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
HOSTNAME = socket.gethostname()
|
||||
except Exception:
|
||||
HOSTNAME = 'localhost'
|
||||
#
|
||||
# NetBox internal settings
|
||||
#
|
||||
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
|
||||
# Pagination
|
||||
PER_PAGE_DEFAULTS = [
|
||||
25, 50, 100, 250, 500, 1000
|
||||
]
|
||||
if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
|
||||
PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
|
||||
PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
|
||||
|
||||
@@ -73,6 +73,11 @@ if settings.DEBUG:
|
||||
url(r'^__debug__/', include(debug_toolbar.urls)),
|
||||
]
|
||||
|
||||
if settings.METRICS_ENABLED:
|
||||
_patterns += [
|
||||
url('', include('django_prometheus.urls')),
|
||||
]
|
||||
|
||||
# Prepend BASE_PATH
|
||||
urlpatterns = [
|
||||
url(r'^{}'.format(settings.BASE_PATH), include(_patterns))
|
||||
|
||||
@@ -11,13 +11,15 @@ from circuits.filters import CircuitFilter, ProviderFilter
|
||||
from circuits.models import Circuit, Provider
|
||||
from circuits.tables import CircuitTable, ProviderTable
|
||||
from dcim.filters import (
|
||||
CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
|
||||
CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter,
|
||||
VirtualChassisFilter,
|
||||
)
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
|
||||
Cable, ConsolePort, Device, DeviceType, Interface, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis
|
||||
)
|
||||
from dcim.tables import (
|
||||
CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
|
||||
CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable,
|
||||
VirtualChassisTable,
|
||||
)
|
||||
from extras.models import ObjectChange, ReportResult, TopologyMap
|
||||
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
||||
@@ -94,6 +96,12 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
('powerfeed', {
|
||||
'queryset': PowerFeed.objects.all(),
|
||||
'filter': PowerFeedFilter,
|
||||
'table': PowerFeedTable,
|
||||
'url': 'dcim:powerfeed_list',
|
||||
}),
|
||||
# IPAM
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.select_related('tenant'),
|
||||
@@ -166,7 +174,7 @@ class HomeView(View):
|
||||
connected_endpoint__isnull=False
|
||||
)
|
||||
connected_powerports = PowerPort.objects.filter(
|
||||
connected_endpoint__isnull=False
|
||||
_connected_poweroutlet__isnull=False
|
||||
)
|
||||
connected_interfaces = Interface.objects.filter(
|
||||
_connected_interface__isnull=False,
|
||||
@@ -182,11 +190,13 @@ class HomeView(View):
|
||||
|
||||
# DCIM
|
||||
'rack_count': Rack.objects.count(),
|
||||
'devicetype_count': DeviceType.objects.count(),
|
||||
'device_count': Device.objects.count(),
|
||||
'interface_connections_count': connected_interfaces.count(),
|
||||
'cable_count': cables.count(),
|
||||
'console_connections_count': connected_consoleports.count(),
|
||||
'power_connections_count': connected_powerports.count(),
|
||||
'powerfeed_count': PowerFeed.objects.count(),
|
||||
|
||||
# IPAM
|
||||
'vrf_count': VRF.objects.count(),
|
||||
@@ -267,6 +277,7 @@ class SearchView(View):
|
||||
class APIRootView(APIView):
|
||||
_ignore_model_permissions = True
|
||||
exclude_from_schema = True
|
||||
swagger_schema = None
|
||||
|
||||
def get_view_name(self):
|
||||
return "API Root"
|
||||
|
||||
7
netbox/project-static/clipboard-2.0.4.min.js
vendored
Executable file
7
netbox/project-static/clipboard-2.0.4.min.js
vendored
Executable file
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user