Compare commits

...

151 Commits

Author SHA1 Message Date
Jeremy Stretch
bf0083552d Merge pull request #2653 from digitalocean/develop
Release v2.4.9
2018-12-07 10:25:46 -05:00
Jeremy Stretch
869194354c Release v2.4.9 2018-12-07 10:19:57 -05:00
Jeremy Stretch
aa8c836b94 Closes #2611: Fix error handling when assigning a clustered device to a different site 2018-12-07 09:57:55 -05:00
Jeremy Stretch
686a65880e Closes #2495: Enable deep-merging of config context data 2018-12-05 14:34:49 -05:00
Jeremy Stretch
d3d6c83fbb Fixes #2634: Enforce consistent representation of unnamed devices in rack view 2018-12-04 15:29:58 -05:00
Jeremy Stretch
4e3567659a Add reminder to update static field choices 2018-12-04 15:19:38 -05:00
Jeremy Stretch
7bbf33ee39 Don't force the docs to open in a new window 2018-12-04 09:44:25 -05:00
Jeremy Stretch
90e7080b63 Closes #2641: Restored link to NetBox shell documentation 2018-12-04 09:19:32 -05:00
John Anderson
e6ee26cf0e CHANGELOG.md 2018-12-04 00:46:36 -05:00
John Anderson
0dcab07519 fixes #2623 - model class being passed to rqworker 2018-12-04 00:40:54 -05:00
mmahacek
232e6f5076 #2635 - Update documentation for python3 update (#2636)
Add reference to reinstalling the django-rq module
2018-12-03 09:56:43 -05:00
Jeremy Stretch
ca0248c3a2 Closes #2089: Add SONET interface form factors 2018-11-30 09:28:56 -05:00
Jeremy Stretch
a43fc0d3d3 Closes #2560: Add slug to DeviceType UI view 2018-11-28 16:19:05 -05:00
Jeremy Stretch
6c2a9107dd Closes #2597: Add FibreChannel SFP28 (32GFC) interface form factor 2018-11-28 09:56:48 -05:00
Jeremy Stretch
879d879e56 Closes #2617: Explicitly mention that test service runs on port 8000 2018-11-28 09:35:33 -05:00
Jeremy Stretch
c6d048ca51 Fixes #2576: Correct type for count_* fields in site API representation 2018-11-27 16:27:47 -05:00
Jeremy Stretch
112aaea51f Updated changelog for #2400 2018-11-27 16:18:57 -05:00
Tatsushi Demachi
c3cdf8e97e Fix type mismatches in API view (#2429)
* Fix tags field to be shown as array in API view

`tags` field in serializers is defineded as `TagListSerializerField`.
It should be shown as an array value in API view but actually, it is a
simple string value.

This fixes it by introducing a new `FieldInspector` to handle
`TagListSerializerField` type field as an array. It doesn't affects any
other type fields.

* Fix SerializedPKRelatedField type API expression

A field definded as `SerializedPKRelatedField` should be shown as an
array of child serializer objects in a response value definition in API
view but it is shown as an array of primary key values (usually
`integer` type) of a child serializer.

This fixes it by introducing a new `FieldInspector` to handle the field.
It doesn't affect any other type fields.

* Fix request parameter representation in API view

In API view, representation of a parameter defined as a sub class of
`WritableNestedSerializer` should be vary between a request and a
response. For example, `tenant` field in `IPAddressSerializer` should be
shown like following as a request body:

```
tenant: integer ...
```

while it should be shown like following as a response body:

```
tenant: {
    id: integer ...,
    url: string ...,
    name: string ...,
    slug: string ...
}
```

But in both cases, it is shown as a response body type expression. This
causes an error at sending an API request with that type value.

It is only an API view issue, API can handle a request if a request
parameter is structured as an expected request body by ignoring the
wrong expression.

This fixes the issue by replacing an implicitly used default auto schema
generator class by its sub class and returning a pseudo serializer with
'Writable' prefix at generating a request body. The reason to introduce
a new generator class is that there is no other point which can
distinguish a request and a response. It is not enough to distinguish
POST, PUT, PATCH methods from GET because former cases may return a JSON
object as a response but it is also represented as same as a request
body, causes another mismatch.

This also fixes `SerializedPKRelatedField` type field representation. It
should be shown as an array of primary keys in a request body.

Fixed #2400
2018-11-27 16:14:45 -05:00
Jeremy Stretch
d2744700c6 Fixes #2615: Tweak live search widget to use brief format for API requests 2018-11-27 12:41:00 -05:00
Jeremy Stretch
5d07a5a670 Fixes #2613: Decrease live search minimum characters to three 2018-11-27 12:20:52 -05:00
Jeremy Stretch
f3aef37163 Add developer guidance for the introduction of new dependencies 2018-11-27 10:45:10 -05:00
Jeremy Stretch
90a4b62976 Changelog for #2606 2018-11-26 14:41:09 -05:00
Daniel Sheppard
7346083b26 Fixes #2606 - Added MultipleChoiceFilter for form_factor (#2610)
* Fixes #2606 - Added MultipleChoiceFilter for form_factor

* Fixes #2606 - Add MultipleChoiceField for form_factor
Fixes error with too many lines.
2018-11-26 14:19:05 -05:00
Tyler Bigler
f052bbc36e Refactor Extras Migration Version Check (#2604)
* Add constant for DB_MINIMUM_VERSION

* Refactor verify_postgresql_version to use Django connection pg_version method for comparing versions.

* Remove StrictVersion import

* Remove DB_MINIMUM_VERSION as not necessary in constants.

* Define DB_MINIMUM_VERSION locally to freeze to migration.

* Refactor database version verification to use django builtin methods.
2018-11-26 14:16:37 -05:00
Jeremy Stretch
8d4329197a Merge pull request #2600 from digitalocean/develop
Release v2.4.8
2018-11-20 11:58:29 -05:00
Jeremy Stretch
34bfb899d1 Post-release version bump 2018-11-20 11:58:19 -05:00
Jeremy Stretch
55c153c5a9 Release v2.4.8 2018-11-20 11:56:14 -05:00
Jeremy Stretch
3366a6ae3d Closes #2557: Added object view for tags 2018-11-15 16:47:41 -05:00
Jeremy Stretch
23cde65add Fixes #2589: Virtual machine API serializer should require cluster assignment 2018-11-14 10:38:53 -05:00
Jeremy Stretch
408f632636 Fixes #2588: Catch all exceptions from failed NAPALM API Calls 2018-11-14 10:12:35 -05:00
Jeremy Stretch
83be0b5db4 Closes #2490: Added bulk editing for config contexts 2018-11-13 15:08:55 -05:00
Jeremy Stretch
7bed48f5fe Expanded device interfaces display to include MTU, MAC address, and tags 2018-11-13 14:18:00 -05:00
Jeremy Stretch
2fce7ebd8f Fixes #2565: Improved rendering of Markdown tables 2018-11-13 11:02:48 -05:00
Jeremy Stretch
0c33af2140 Fixes #2558: Filter on all tags when multiple are passed 2018-11-12 15:48:58 -05:00
Jeremy Stretch
b6a256dc5d Expanded the development style guide 2018-11-12 14:36:09 -05:00
Jeremy Stretch
5785fb6ba2 Added development docs for extending a model 2018-11-12 13:59:58 -05:00
Jeremy Stretch
845d467fd9 Fixes #2575: Correct model specified for rack roles table 2018-11-09 09:46:30 -05:00
Jeremy Stretch
61ca7ee7c2 Closes #2559: Add a pre-commit git hook to enforce PEP8 validation 2018-11-08 13:52:34 -05:00
Jeremy Stretch
69d829ce8d Fixes #2473: Fix encoding of long (>127 character) secrets 2018-11-07 13:44:16 -05:00
Jeremy Stretch
c1838104ae Add lag description to lag column 2018-11-07 12:20:14 -05:00
Jeremy Stretch
c716ca1e87 Changelog query optimization 2018-11-07 10:42:04 -05:00
Jeremy Stretch
8bad25b860 Post-release version bump 2018-11-06 10:57:38 -05:00
Jeremy Stretch
cb83eb204b Merge pull request #2552 from digitalocean/develop
Release v2.4.7
2018-11-06 10:55:29 -05:00
Jeremy Stretch
99edb8b8d5 Release v2.4.7 2018-11-06 10:49:44 -05:00
Jeremy Stretch
b4998f4b01 Closes #2388: Enable filtering of devices/VMs by region 2018-11-06 10:31:56 -05:00
John Anderson
51295389c6 add temporary support for hyphenated query params for #2549 2018-11-06 10:08:00 -05:00
Ben Bleything
5baf86dc89 fix prefix length for 172.16.0.0/12 (#2548) 2018-11-06 09:26:05 -05:00
Jeremy Stretch
fd4a9db13e Closes #2512: Add device field to inventory item filter form 2018-11-06 09:24:05 -05:00
John Anderson
e243234c4e changelog for #2549 2018-11-06 00:57:09 -05:00
John Anderson
817dc89279 fixed test for #2549 2018-11-06 00:54:57 -05:00
John Anderson
798a87b31e fixed #2549 - incorrect naming of peer-device and peer-interface 2018-11-06 00:51:55 -05:00
Jeremy Stretch
4d47d848c5 Fixed changelog for #2528 2018-11-05 16:10:33 -05:00
Jeremy Stretch
bd3ccfe020 Fixes #2528: Enable creating circuit terminations with interface assignment via API 2018-11-05 16:10:01 -05:00
Jeremy Stretch
ded90df01b Filter cleanup 2018-11-05 15:45:21 -05:00
Jeremy Stretch
ce7930abfd Changelog for #2427 2018-11-05 15:40:48 -05:00
Daniel Sheppard
82b4aad585 Fixes 2427: Added filtering interfaces by vlan id(vlan=#) and vlan pk(vlan_id=#) (#2521) 2018-11-05 15:37:52 -05:00
Jeremy Stretch
f321e2c705 Changelog for #2501 2018-11-05 15:34:39 -05:00
knobix
0c86fd89ca Update models.py (#2502)
Fix the handling of shared IPs (VIP, VRRF, etc.) when unique IP space enforcement is set.

Add parentheses for the logical OR-statement to make the evaluation valid.

Fixes: #2501
2018-11-05 15:33:10 -05:00
Jeremy Stretch
e97708ada0 Fixes #2526: Bump paramiko and pycryptodome requirements due to vulnerability 2018-10-22 11:23:37 -04:00
Jeremy Stretch
0bb5d229e8 Fixes #2514: Prevent new connections to already connected interfaces 2018-10-16 16:42:23 -04:00
mmahacek
409a9256a1 Expand Webhook Documentation #2347 (#2524)
* #2347 - Expand Webhook Documentation

Move "Install Python Packages" section up one header level.  Should make Napalm/Webhook sections appear in table of contents for direct linking.

*  #2347 - Expand Webhook Documentation

Add text for installation to link to other documentation sections with instructions.
2018-10-16 13:19:33 -04:00
Jeremy Stretch
2c37e85a4c Merge pull request #2523 from etcet/patch-1
Fix "cusomizable" typo
2018-10-16 13:16:12 -04:00
Chris James
0ae2dfbff3 Fix "cusomizable" typo 2018-10-16 11:36:32 -05:00
Jeremy Stretch
df5d105f29 Changelog for #2515 2018-10-16 09:42:19 -04:00
Jeremy Stretch
219c9e7d95 Merge pull request #2408 from ScanPlusGmbH/fix-2395
Fix #2515: Modify only when webhooks are enabled
2018-10-16 09:39:28 -04:00
Jeremy Stretch
6832df4699 Fixes #2508: Removed invalid link 2018-10-10 09:49:35 -04:00
Tobias Genannt
c31c7b50b7 Fix #2395: Modify only when webhooks are enabled
This only adds the RQ link when the webhooks setting is enabled.
2018-10-08 07:34:56 +02:00
Jeremy Stretch
7d1f6b7049 Post-release version bump 2018-10-05 15:49:51 -04:00
Jeremy Stretch
74d525364a Merge pull request #2494 from digitalocean/develop
Release v2.4.6
2018-10-05 15:48:11 -04:00
Jeremy Stretch
c2f4cf3407 Release v2.4.6 2018-10-05 15:43:43 -04:00
Jeremy Stretch
83f3dc99ce Changelog entry for #2492 2018-10-05 15:39:30 -04:00
Jeremy Stretch
44d3606ce1 Fixes #2492: Sanitize hostname and port values returned through LLDP 2018-10-05 15:38:32 -04:00
Jeremy Stretch
470d22c835 PEP8 fix 2018-10-05 15:36:48 -04:00
Jeremy Stretch
4c37628784 Fixes #2393: Fix Unicode support for CSV import under Python 2 2018-10-05 15:33:29 -04:00
Marc Heckmann
1daf7f8e2b Sanitize hostname and port values returned through LLDP
If hostname or port are null set to empty string ("").

This avoids breaking the LLDP neighbors (NAPALM) view
2018-10-05 14:30:54 -04:00
Jeremy Stretch
841db3b0c2 Fixes #2491: Fix exception when importing devices with invalid device type 2018-10-05 12:22:46 -04:00
Jeremy Stretch
5d10d8418e Closes #2479: Add user permissions for creating/modifying API tokens 2018-10-05 11:06:59 -04:00
Jeremy Stretch
2fee977b4c Fixes #2485: Fix cancel button when assigning a service to a device/VM 2018-10-05 10:30:13 -04:00
Jeremy Stretch
52f1b1c3bf Changelog entry for #2487 2018-10-04 16:24:09 -04:00
Jeremy Stretch
ab53d0e863 Merge pull request #2488 from digitalocean/2487-api-brief
Closes #2487: Enable brief API output utilizing nested serializers
2018-10-04 16:22:30 -04:00
Jeremy Stretch
259da2d18a #2487: Added API tests 2018-10-04 16:20:01 -04:00
Jeremy Stretch
bf47e7cae3 #2487: Require the 'brief' parameter to evaluate True 2018-10-04 14:50:57 -04:00
Jeremy Stretch
db2721c581 Enable brief API output utilizing nested serializers 2018-10-04 13:43:50 -04:00
John Anderson
1b2e9a6d06 fixes #2484 - Local config context not available on the Virtual Machine Edit Form 2018-10-03 17:16:01 -04:00
Jeremy Stretch
99848aab6a Fixes #2483: Set max item count of API-populated form fields to MAX_PAGE_SIZE 2018-10-03 16:17:17 -04:00
Jeremy Stretch
7583912de7 Post-release version bump 2018-10-02 15:30:28 -04:00
Jeremy Stretch
125975832b Merge pull request #2478 from digitalocean/develop
Release v2.4.5
2018-10-02 15:29:13 -04:00
Jeremy Stretch
20fed375d1 Release v2.4.5 2018-10-02 15:24:42 -04:00
Jeremy Stretch
fc1b3d6927 Fixes #2471: Fix ReadTheDocs theme 2018-10-02 11:51:53 -04:00
Jeremy Stretch
aed2a3cd1b Closes #2438: API optimizations for tagged objects 2018-09-28 16:44:05 -04:00
Jeremy Stretch
15babeb584 Fixes #2414: Tags field missing from device/VM component creation forms 2018-09-28 16:26:08 -04:00
Jeremy Stretch
020b5ea870 Fixes #2470: Log the creation of device/VM components as object changes 2018-09-28 16:04:51 -04:00
Jeremy Stretch
2ee5b2344e Changelog and misc cleanup 2018-09-28 14:21:49 -04:00
Jeremy Stretch
7616bcad3d Merge pull request #2445 from digitalocean/local-config-context
Local config context
2018-09-28 14:03:28 -04:00
John Anderson
f76ce980e3 remove templates no longer needed for local config context 2018-09-26 10:30:34 -04:00
Jeremy Stretch
9440ac7640 Fixes #2455: Ignore unique address enforcement for IPs with a shared/virtual role 2018-09-24 16:59:33 -04:00
Jeremy Stretch
0e18997c79 Merge pull request #2446 from hellerve/patch-1
docs: typo fix in devices
2018-09-19 09:47:46 -04:00
Veit Heller
95464772ac docs: typo fix in devices 2018-09-19 10:57:09 +02:00
Jeremy Stretch
b4445dfdf8 Fixes #2442: Nullify "next" link in API when limit=0 is passed 2018-09-18 13:59:50 -04:00
John Anderson
fb5dca2711 Merge branch 'develop' of github.com:digitalocean/netbox into local-config-context 2018-09-18 12:16:07 -04:00
Jeremy Stretch
6cdff955dc Fixes #2444: Improve validation of interface MAC addresses 2018-09-18 12:02:59 -04:00
John Anderson
4039753b2f refactored UI for local config context 2018-09-18 11:52:12 -04:00
Jeremy Stretch
9df33cef8b Fixes #2443: Enforce JSON object format when creating config contexts 2018-09-18 11:46:22 -04:00
John Anderson
e3e9211e8a PEP8 fix 2018-09-16 00:30:51 -04:00
John Anderson
0da113b723 implemnted #2392 - local config context for devices and VMs 2018-09-16 00:25:20 -04:00
John Anderson
e965adad7c changelog for #2432 2018-09-15 17:25:50 -04:00
John Anderson
57b225b680 fixes #2423 - interface connection links 2018-09-15 17:23:58 -04:00
Jeremy Stretch
b97597c645 Merge pull request #2421 from sieben/docs_community
Add content about related projects
2018-09-13 12:29:17 -04:00
Rémy Léone
162828da90 Add a page related to community related projects 2018-09-13 17:54:13 +02:00
Jeremy Stretch
292647da14 Closes #2402: Order and format JSON data in form fields 2018-09-13 11:31:34 -04:00
Jeremy Stretch
3a88e43103 Fixes #2406: Remove hard-coded limit of 1000 objects from API-populated form fields 2018-09-13 11:21:40 -04:00
Jeremy Stretch
010765e131 Post-release version bump 2018-08-22 11:55:51 -04:00
Jeremy Stretch
bcf22831e2 Merge pull request #2387 from digitalocean/develop
Release v2.4.4
2018-08-22 11:53:56 -04:00
Jeremy Stretch
cde6e9757b Release v2.4.4 2018-08-22 11:51:15 -04:00
Jeremy Stretch
f2d9a3e0a1 Added note about CHANGELOG to release checklist 2018-08-22 11:50:25 -04:00
Jeremy Stretch
b917e8d3b0 #2376: Add libapache2-mod-wsgi-py3 to CentOS installation section 2018-08-22 11:46:13 -04:00
Jeremy Stretch
3b26ce6501 Merge pull request #2386 from digitalocean/revert-2376-patch-1
Revert "Add missing library"
2018-08-22 11:44:31 -04:00
Jeremy Stretch
1b2d3bf08b Revert "Add missing library" 2018-08-22 11:44:07 -04:00
Jeremy Stretch
492bc9f86e Merge pull request #2376 from craig/patch-1
Add missing library
2018-08-22 11:43:46 -04:00
Jeremy Stretch
a457a73826 Merge pull request #2382 from consentfactory/develop
Fixed typo for supervisorctl
2018-08-22 11:41:12 -04:00
Jeremy Stretch
ac36339491 Closes #2168: Added Extreme SummitStack interface form factors 2018-08-22 11:33:43 -04:00
Jeremy Stretch
dbbf7ab664 Fixes #2353: Handle DoesNotExist exception when deleting a device with connected interfaces 2018-08-22 10:35:56 -04:00
Jeremy Stretch
66400a98f1 Fixes #2354: Increased maximum MTU for interfaces to 65536 bytes 2018-08-22 10:25:07 -04:00
Jeremy Stretch
aa50e2e385 Fixes #2378: Corrected "edit" link for virtual machine interfaces 2018-08-22 10:06:01 -04:00
Jimmy Taylor
118b8db209 Fixed typo for supervisorctl 2018-08-21 08:28:23 -06:00
Craig
967feb6931 Add missing library
WSGIPassAuthorization fails if libapache2-mod-wsgi-py3 is missing
2018-08-21 00:41:29 +02:00
Jeremy Stretch
e1e41a768a Fixes #2369: Corrected time zone validation on site API serializer 2018-08-20 16:53:23 -04:00
Jeremy Stretch
c333af33dc Fixes #2370: Redirect to parent device after deleting device bays 2018-08-20 14:40:19 -04:00
Jeremy Stretch
9e5b482b1d Fixes #2374: Fix toggling display of IP addresses in virtual machine interfaces list 2018-08-20 13:49:15 -04:00
John Anderson
771747147c #2254 changelog entry 2018-08-17 18:41:58 -04:00
John Anderson
bc49979243 added rack group search #2254 2018-08-17 18:37:48 -04:00
Jeremy Stretch
d46b3e2446 #2368: Append changelog 2018-08-17 14:32:51 -04:00
Jeremy Stretch
2804d89c5e Fixes #2368: Record change in device changelog when altering cluster assignment 2018-08-17 14:26:50 -04:00
Jeremy Stretch
fd32a71131 Rename changelog 2018-08-16 16:29:12 -04:00
Jeremy Stretch
1556fd0e92 Added a release changelog 2018-08-16 16:27:41 -04:00
Jeremy Stretch
5dce7c4e48 Closes #2356: Include cluster site as read-only field in VirtualMachine serializer 2018-08-16 11:57:20 -04:00
Jeremy Stretch
4bfc32ec99 Closes #2355: Added item count to inventory tab on device view 2018-08-16 10:20:22 -04:00
Jeremy Stretch
ff65f7fd7b Fixes #2362: Implemented custom admin site to properly handle BASE_PATH 2018-08-16 09:44:00 -04:00
Jeremy Stretch
cd2aee3053 Post-release version bump 2018-08-09 16:41:11 -04:00
Jeremy Stretch
f224ad2959 Merge pull request #2346 from digitalocean/develop
Release v2.4.3
2018-08-09 16:39:45 -04:00
Jeremy Stretch
9d9318f38a Corrected typo 2018-08-09 16:37:58 -04:00
Jeremy Stretch
f43d861b50 Release v2.4.3 2018-08-09 16:36:23 -04:00
Jeremy Stretch
17714b0c12 Fixes #2342: IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM 2018-08-09 16:34:17 -04:00
Jeremy Stretch
9914576eaa Fixes #2344: AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site 2018-08-09 15:46:18 -04:00
Jeremy Stretch
bf8eff11ea Closes #2333: Added search filters for ConfigContexts 2018-08-09 12:22:34 -04:00
Jeremy Stretch
a6c78b99c4 Fixes #2340: API requires manufacturer field when creating/updating an inventory item 2018-08-09 09:34:54 -04:00
Jeremy Stretch
6a56ffc650 Fixes #2337: Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError 2018-08-08 16:16:49 -04:00
Jeremy Stretch
05059606c5 Fixes #2336: Bulk deleting power outlets and console server ports from a device redirects to home page 2018-08-08 15:22:26 -04:00
Jeremy Stretch
a2ff21fab9 Fixes #2334: TypeError raised when WritableNestedSerializer receives a non-integer value 2018-08-08 15:09:30 -04:00
Jeremy Stretch
134370f48d Fixes #2335: API requires group field when creating/updating a rack 2018-08-08 14:58:16 -04:00
Jeremy Stretch
c7fa610842 Post-release version bump 2018-08-08 09:19:33 -04:00
116 changed files with 3783 additions and 448 deletions

1750
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -42,3 +42,18 @@ and run `upgrade.sh`.
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
# Related projects
## Supported SDK
- [pynetbox](https://github.com/digitalocean/pynetbox) 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.
## Ansible Inventory
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox.

View File

@@ -1,3 +1,5 @@
# Contextual Configuration Data
Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. For example, you might want to associate a set of syslog servers for all devices at a particular site. Context data enables the association of arbitrary data to devices and virtual machines grouped by region, site, role, platform, and/or tenant. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object.
Devices and Virtual Machines may also have a local config context defined. This local context will always overwrite the rendered config context objects for the Device/VM. This is useful in situations were the device requires a one-off value different from the rest of the environment.

View File

@@ -4,6 +4,14 @@ 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):

View File

@@ -4,6 +4,9 @@ The NetBox API employs token-based authentication. For convenience, cookie authe
A token is a unique identifier that identifies a user to the API. Each user in NetBox may have one or more tokens which he or she can use to authenticate to the API. To create a token, navigate to the API tokens page at `/user/api-tokens/`.
!!! note
The creation and modification of API tokens can be restricted per user by an administrator. If you don't see an option to create an API token, ask an administrator to grant you access.
Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation.
By default, a token can be used for all operations available via the API. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only.

View File

@@ -12,5 +12,5 @@ While NetBox has many configuration settings, only a few of them must be defined
Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
```no-highlight
# sudo supervsiorctl restart netbox
# sudo supervisorctl restart netbox
```

View File

@@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents
## Device Roles
Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches.
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.
---
@@ -99,9 +99,9 @@ Device bays represent the ability of a device to house child devices. For exampl
# Platforms
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration.
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.

View File

@@ -4,7 +4,7 @@ The first step to documenting your IP space is to define its scope by creating a
* 10.0.0.0/8 (RFC 1918)
* 100.64.0.0/10 (RFC 6598)
* 172.16.0.0/20 (RFC 1918)
* 172.16.0.0/12 (RFC 1918)
* 192.168.0.0/16 (RFC 1918)
* One or more /48s within fd00::/8 (IPv6 unique local addressing)

View File

@@ -0,0 +1,74 @@
# Extending Models
Below is a list of items to consider when adding a new field to a model:
### 1. Generate and run database migration
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
```
./manage.py makemigrations <app> -n <name>
./manage.py migrate
```
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
!!! note
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered.
### 2. Add validation logic to `clean()`
If the new field introduces additional validation requirements (beyond what's included with the field itself), implement them in the model's `clean()` method. Remember to call the model's original method using `super()` before or agter your custom validation as appropriate:
```
class Foo(models.Model):
def clean(self):
super(DeviceCSVForm, self).clean()
# Custom validation goes here
if self.bar is None:
raise ValidationError()
```
### 3. Add CSV helpers
Add the name of the new field to `csv_headers` and included a CSV-friendly representation of its data in the model's `to_csv()` method. These will be used when exporting objects in CSV format.
### 4. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `select_related()` or `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
### 5. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
### 6. Add choices to API view
If the new field has static choices, add it to the `FieldChoicesViewSet` for the app.
### 7. Add field to forms
Extend any forms to include the new field as appropriate. Common forms include:
* **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on mnay objects at once
* **CSV import** - The form used when bulk importing objects in CSV format
* **Filter** - Displays the options available for filtering a list of objects (both UI and API)
### 8. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
### 9. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require explicitly declaring a new column.
### 10. Update the UI templates
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
### 11. Adjust API and model tests
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.

View File

@@ -28,10 +28,3 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `tenancy`: Tenants (such as customers) to which NetBox objects may be assigned
* `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters
## Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). The following exceptions are noted:
* [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
* Constants may be imported via wildcard (for example, `from .constants import *`).

View File

@@ -48,9 +48,9 @@ Close the release milestone on GitHub. Ensure that there are no remaining open i
Ensure that continuous integration testing on the `develop` branch is completing successfully.
## Update VERSION
## Update Version and Changelog
Update the `VERSION` constant in `settings.py` to the new release.
Update the `VERSION` constant in `settings.py` to the new release version and add the current date to the release notes in `CHANGELOG.md`.
## Submit a Pull Request

View File

@@ -0,0 +1,54 @@
# Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
## PEP 8 Exceptions
* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions:
* The library being import contains only constant declarations (`constants.py`)
* The library being imported explicitly defines `__all__` (e.g. `<app>.api.nested_serializers`)
* Maximum line length is 120 characters (E501)
* This does not apply to HTML templates or to automatically generated code (e.g. database migrations).
* Line breaks are permitted following binary operators (W504)
## Enforcing Code Style
The `pycodestyle` utility (previously `pep8`) is used by the CI process to enforce code style. It is strongly recommended to include as part of your commit process. A git commit hook is provided in the source at `scripts/git-hooks/pre-commit`. Linking to this script from `.git/hooks/` will invoke `pycodestyle` prior to every commit attempt and abort if the validation fails.
```
$ cd .git/hooks/
$ ln -s ../../scripts/git-hooks/pre-commit
```
To invoke `pycodestyle` manually, run:
```
pycodestyle --ignore=W504,E501 netbox/
```
## Introducing New Dependencies
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
If there's a strong case for introducing a new depdency, it must meet the following criteria:
* Its complete source code must be published and freely accessible without registration.
* Its license must be conducive to inclusion in an open source project.
* It must be actively maintained, with no longer than one year between releases.
* It must be available via the [Python Package Index](https://pypi.org/) (PyPI).
When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts.
## General Guidance
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely.
* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable.
* Every model should have a docstring. Every custom method should include an expalantion of its function.
* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`.

View File

@@ -71,7 +71,7 @@ Checking connectivity... done.
`# chown -R netbox:netbox /opt/netbox/netbox/media/`
## Install Python Packages
# Install Python Packages
Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.)
@@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio
!!! note
If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`.
### NAPALM Automation (Optional)
## NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3:
@@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
# pip3 install napalm
```
### Webhooks (Optional)
## 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.
@@ -246,13 +246,13 @@ At this point, NetBox should be able to run. We can verify this by starting a de
Performing system checks...
System check identified no issues (0 silenced).
June 17, 2016 - 16:17:36
Django version 1.9.7, using settings 'netbox.settings'
November 28, 2018 - 09:33:45
Django version 2.0.9, using settings 'netbox.settings'
Starting development server at http://0.0.0.0:8000/
Quit the server with CONTROL-C.
```
Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.**
!!! warning
If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected.

View File

@@ -56,7 +56,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
## Option B: Apache
```no-highlight
# apt-get install -y apache2
# apt-get install -y apache2 libapache2-mod-wsgi-py3
```
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):

View File

@@ -36,3 +36,9 @@ 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
```

View File

@@ -1,4 +1,5 @@
site_name: NetBox
theme: readthedocs
repo_url: https://github.com/digitalocean/netbox
pages:
@@ -37,6 +38,7 @@ pages:
- Change Logging: 'additional-features/change-logging.md'
- Administration:
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- API:
- Overview: 'api/overview.md'
- Authentication: 'api/authentication.md'
@@ -44,7 +46,9 @@ pages:
- Examples: 'api/examples.md'
- Development:
- Introduction: 'development/index.md'
- Style Guide: 'development/style-guide.md'
- Utility Views: 'development/utility-views.md'
- Extending Models: 'development/extending-models.md'
- Release Checklist: 'development/release-checklist.md'
markdown_extensions:

View File

@@ -4,8 +4,8 @@ from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.constants import CIRCUIT_STATUS_CHOICES
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer
from extras.api.customfields import CustomFieldModelSerializer
from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
@@ -87,7 +87,7 @@ class NestedCircuitSerializer(WritableNestedSerializer):
class CircuitTerminationSerializer(ValidatedModelSerializer):
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
interface = InterfaceSerializer(required=False, allow_null=True)
interface = NestedInterfaceSerializer(required=False, allow_null=True)
class Meta:
model = CircuitTermination

View File

@@ -29,7 +29,7 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
#
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.all()
queryset = Provider.objects.prefetch_related('tags')
serializer_class = serializers.ProviderSerializer
filter_class = filters.ProviderFilter
@@ -59,7 +59,7 @@ class CircuitTypeViewSet(ModelViewSet):
#
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.select_related('type', 'tenant', 'provider')
queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filter_class = filters.CircuitFilter

View File

@@ -6,7 +6,7 @@ from django.db.models import Q
from dcim.models import Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Provider, Circuit, CircuitTermination, CircuitType
@@ -28,9 +28,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Provider
@@ -106,9 +104,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Circuit

View File

@@ -5,7 +5,7 @@ from rest_framework import status
from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Site
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
from extras.constants import GRAPH_TYPE_PROVIDER
from extras.models import Graph
from utilities.testing import APITestCase
@@ -56,6 +56,16 @@ class ProviderTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_providers_brief(self):
url = reverse('circuits-api:provider-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_provider(self):
data = {
@@ -147,6 +157,16 @@ class CircuitTypeTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_circuittypes_brief(self):
url = reverse('circuits-api:circuittype-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_circuittype(self):
data = {
@@ -216,6 +236,16 @@ class CircuitTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_circuits_brief(self):
url = reverse('circuits-api:circuit-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['cid', 'id', 'url']
)
def test_create_circuit(self):
data = {
@@ -300,21 +330,44 @@ class CircuitTerminationTest(APITestCase):
super(CircuitTerminationTest, self).setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True
)
devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
device1 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site1
)
device2 = Device.objects.create(
device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site2
)
self.interface1 = Interface.objects.create(device=device1, name='Test Interface 1')
self.interface2 = Interface.objects.create(device=device2, name='Test Interface 2')
self.interface3 = Interface.objects.create(device=device1, name='Test Interface 3')
self.interface4 = Interface.objects.create(device=device2, name='Test Interface 4')
self.interface5 = Interface.objects.create(device=device1, name='Test Interface 5')
self.interface6 = Interface.objects.create(device=device2, name='Test Interface 6')
provider = Provider.objects.create(name='Test Provider', slug='test-provider')
circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type')
self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype)
self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype)
self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype)
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2')
self.circuittermination1 = CircuitTermination.objects.create(
circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface1, port_speed=1000000
)
self.circuittermination2 = CircuitTermination.objects.create(
circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface2, port_speed=1000000
)
self.circuittermination3 = CircuitTermination.objects.create(
circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000
circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface3, port_speed=1000000
)
self.circuittermination4 = CircuitTermination.objects.create(
circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface4, port_speed=1000000
)
def test_get_circuittermination(self):
@@ -329,14 +382,15 @@ class CircuitTerminationTest(APITestCase):
url = reverse('circuits-api:circuittermination-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
self.assertEqual(response.data['count'], 4)
def test_create_circuittermination(self):
data = {
'circuit': self.circuit1.pk,
'term_side': TERM_SIDE_Z,
'site': self.site2.pk,
'circuit': self.circuit3.pk,
'term_side': TERM_SIDE_A,
'site': self.site1.pk,
'interface': self.interface5.pk,
'port_speed': 1000000,
}
@@ -344,31 +398,37 @@ class CircuitTerminationTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(CircuitTermination.objects.count(), 4)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination4 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination4.circuit_id, data['circuit'])
self.assertEqual(circuittermination4.term_side, data['term_side'])
self.assertEqual(circuittermination4.site_id, data['site'])
self.assertEqual(circuittermination4.interface_id, data['interface'])
self.assertEqual(circuittermination4.port_speed, data['port_speed'])
def test_update_circuittermination(self):
circuittermination5 = CircuitTermination.objects.create(
circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface5, port_speed=1000000
)
data = {
'circuit': self.circuit1.pk,
'circuit': self.circuit3.pk,
'term_side': TERM_SIDE_Z,
'site': self.site2.pk,
'interface': self.interface6.pk,
'port_speed': 1000000,
}
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk})
url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(CircuitTermination.objects.count(), 3)
self.assertEqual(CircuitTermination.objects.count(), 5)
circuittermination1 = CircuitTermination.objects.get(pk=response.data['id'])
self.assertEqual(circuittermination1.circuit_id, data['circuit'])
self.assertEqual(circuittermination1.term_side, data['term_side'])
self.assertEqual(circuittermination1.site_id, data['site'])
self.assertEqual(circuittermination1.interface_id, data['interface'])
self.assertEqual(circuittermination1.port_speed, data['port_speed'])
def test_delete_circuittermination(self):
@@ -377,4 +437,4 @@ class CircuitTerminationTest(APITestCase):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(CircuitTermination.objects.count(), 2)
self.assertEqual(CircuitTermination.objects.count(), 3)

View File

@@ -56,6 +56,11 @@ 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)
class Meta:
model = Site
@@ -120,7 +125,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True)
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
@@ -412,7 +417,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated',
'last_updated', 'local_context_data',
]
validators = []
@@ -448,7 +453,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated',
'config_context', 'created', 'last_updated', 'local_context_data',
]
def get_config_context(self, obj):
@@ -472,10 +477,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedConsoleServerPortSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsoleServerPort
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_console') and obj.connected_console is not None
#
@@ -492,6 +501,19 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
fields = ['id', 'device', 'name', 'cs_port', 'connection_status', 'tags']
class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = ConsolePort
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.cs_port is not None
#
# Power outlets
#
@@ -509,10 +531,14 @@ class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedPowerOutletSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerOutlet
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return hasattr(obj, 'connected_port') and obj.connected_port is not None
#
@@ -529,17 +555,46 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
fields = ['id', 'device', 'name', 'power_outlet', 'connection_status', 'tags']
class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer(read_only=True)
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = PowerPort
fields = ['id', 'url', 'device', 'name', 'is_connected']
def get_is_connected(self, obj):
return obj.power_outlet is not None
#
# Interfaces
#
class NestedInterfaceSerializer(WritableNestedSerializer):
class IsConnectedMixin(object):
"""
Provide a method for setting is_connected on Interface serializers.
"""
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit.
"""
if obj.connection:
return True
if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None:
return True
return False
class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer):
device = NestedDeviceSerializer(read_only=True)
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
is_connected = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Interface
fields = ['id', 'url', 'device', 'name']
fields = ['id', 'url', 'device', 'name', 'is_connected']
class InterfaceNestedCircuitSerializer(serializers.ModelSerializer):
@@ -569,7 +624,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'vid', 'name', 'display_name']
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@@ -613,19 +668,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
return super(InterfaceSerializer, self).validate(data)
def get_is_connected(self, obj):
"""
Return True if the interface has a connected interface or circuit termination.
"""
if obj.connection:
return True
try:
circuit_termination = obj.circuit_termination
return True
except CircuitTermination.DoesNotExist:
pass
return False
def get_interface_connection(self, obj):
if obj.connection:
context = {
@@ -652,10 +694,11 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
model = DeviceBay
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'device', 'name']
#
@@ -666,7 +709,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer()
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:

View File

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
from collections import OrderedDict
from django.conf import settings
from django.http import HttpResponseBadRequest, HttpResponseForbidden
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
from drf_yasg.openapi import Parameter
@@ -60,7 +60,7 @@ class RegionViewSet(ModelViewSet):
#
class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.select_related('region', 'tenant')
queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
serializer_class = serializers.SiteSerializer
filter_class = filters.SiteFilter
@@ -100,7 +100,7 @@ class RackRoleViewSet(ModelViewSet):
#
class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.select_related('site', 'group__site', 'tenant')
queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
serializer_class = serializers.RackSerializer
filter_class = filters.RackFilter
@@ -154,7 +154,7 @@ class ManufacturerViewSet(ModelViewSet):
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer')
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
serializer_class = serializers.DeviceTypeSerializer
filter_class = filters.DeviceTypeFilter
@@ -228,7 +228,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master',
).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside',
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
)
filter_class = filters.DeviceFilter
@@ -238,6 +238,11 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
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
@action(detail=True, url_path='napalm')
@@ -258,9 +263,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
# Check that NAPALM is installed
try:
import napalm
from napalm.base.exceptions import ModuleImportError
except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
from napalm.base.exceptions import ModuleImportError
# Validate the configured driver
try:
@@ -304,7 +309,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
try:
response[method] = getattr(d, method)()
except NotImplementedError:
response[method] = {'error': 'Method not implemented for NAPALM driver {}'.format(driver)}
response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
except Exception as e:
response[method] = {'error': 'Method {} failed: {}'.format(method, e)}
d.close()
return Response(response)
@@ -315,31 +322,31 @@ class DeviceViewSet(CustomFieldModelViewSet):
#
class ConsolePortViewSet(ModelViewSet):
queryset = ConsolePort.objects.select_related('device', 'cs_port__device')
queryset = ConsolePort.objects.select_related('device', 'cs_port__device').prefetch_related('tags')
serializer_class = serializers.ConsolePortSerializer
filter_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(ModelViewSet):
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device')
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device').prefetch_related('tags')
serializer_class = serializers.ConsoleServerPortSerializer
filter_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(ModelViewSet):
queryset = PowerPort.objects.select_related('device', 'power_outlet__device')
queryset = PowerPort.objects.select_related('device', 'power_outlet__device').prefetch_related('tags')
serializer_class = serializers.PowerPortSerializer
filter_class = filters.PowerPortFilter
class PowerOutletViewSet(ModelViewSet):
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device')
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device').prefetch_related('tags')
serializer_class = serializers.PowerOutletSerializer
filter_class = filters.PowerOutletFilter
class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.select_related('device')
queryset = Interface.objects.select_related('device').prefetch_related('tags')
serializer_class = serializers.InterfaceSerializer
filter_class = filters.InterfaceFilter
@@ -355,13 +362,13 @@ class InterfaceViewSet(ModelViewSet):
class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device')
queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer
filter_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer
filter_class = filters.InventoryItemFilter
@@ -393,7 +400,7 @@ class InterfaceConnectionViewSet(ModelViewSet):
#
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.all()
queryset = VirtualChassis.objects.prefetch_related('tags')
serializer_class = serializers.VirtualChassisSerializer
@@ -407,13 +414,13 @@ class ConnectedDeviceViewSet(ViewSet):
interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors
via a protocol such as LLDP. Two query parameters must be included in the request:
* `peer-device`: The name of the peer device
* `peer-interface`: The name of the peer interface
* `peer_device`: The name of the peer device
* `peer_interface`: The name of the peer interface
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
_device_param = Parameter('peer-device', 'query',
_device_param = Parameter('peer_device', 'query',
description='The name of the peer device', required=True, type=openapi.TYPE_STRING)
_interface_param = Parameter('peer-interface', 'query',
_interface_param = Parameter('peer_interface', 'query',
description='The name of the peer interface', required=True, type=openapi.TYPE_STRING)
def get_view_name(self):
@@ -424,9 +431,15 @@ class ConnectedDeviceViewSet(ViewSet):
def list(self, request):
peer_device_name = request.query_params.get(self._device_param.name)
if not peer_device_name:
# TODO: remove this after 2.4 as the switch to using underscores is a breaking change
peer_device_name = request.query_params.get('peer-device')
peer_interface_name = request.query_params.get(self._interface_param.name)
if not peer_interface_name:
# TODO: remove this after 2.4 as the switch to using underscores is a breaking change
peer_interface_name = request.query_params.get('peer-interface')
if not peer_device_name or not peer_interface_name:
raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.')
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)

View File

@@ -76,12 +76,21 @@ IFACE_FF_80211G = 2610
IFACE_FF_80211N = 2620
IFACE_FF_80211AC = 2630
IFACE_FF_80211AD = 2640
# 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
# 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
# Serial
IFACE_FF_T1 = 4000
IFACE_FF_E1 = 4010
@@ -93,6 +102,11 @@ 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
# Other
IFACE_FF_OTHER = 32767
@@ -141,6 +155,18 @@ IFACE_FF_CHOICES = [
[IFACE_FF_80211AD, 'IEEE 802.11ad'],
]
],
[
'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'],
]
],
[
'FibreChannel',
[
@@ -149,6 +175,7 @@ IFACE_FF_CHOICES = [
[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)'],
]
],
[
@@ -168,6 +195,10 @@ IFACE_FF_CHOICES = [
[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'],
]
],
[

View File

@@ -1,13 +1,11 @@
from __future__ import unicode_literals
from netaddr import EUI, mac_unix_expanded
from netaddr import AddrFormatError, EUI, mac_unix_expanded
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from .formfields import MACAddressFormField
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
@@ -35,7 +33,7 @@ class MACAddressField(models.Field):
return value
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except ValueError as e:
except AddrFormatError as e:
raise ValidationError(e)
def db_type(self, connection):
@@ -45,11 +43,3 @@ class MACAddressField(models.Field):
if not value:
return None
return str(self.to_python(value))
def form_class(self):
return MACAddressFormField
def formfield(self, **kwargs):
defaults = {'form_class': self.form_class()}
defaults.update(kwargs)
return super(MACAddressField, self).formfield(**defaults)

View File

@@ -2,17 +2,18 @@ from __future__ import unicode_literals
import django_filters
from django.contrib.auth.models import User
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from netaddr import EUI
from netaddr.core import AddrFormatError
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NullableCharFieldFilter, NumericInFilter
from utilities.filters import NullableCharFieldFilter, NumericInFilter, TagFilter
from virtualization.models import Cluster
from .constants import (
DEVICE_STATUS_CHOICES, IFACE_FF_LAG, NONCONNECTABLE_IFACE_TYPES, SITE_STATUS_CHOICES, VIRTUAL_IFACE_TYPES,
WIRELESS_IFACE_TYPES,
WIRELESS_IFACE_TYPES, IFACE_FF_CHOICES,
)
from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
@@ -82,9 +83,7 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Site
@@ -112,6 +111,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -127,6 +130,15 @@ class RackGroupFilter(django_filters.FilterSet):
model = RackGroup
fields = ['site_id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(slug__icontains=value)
)
return queryset.filter(qs_filter)
class RackRoleFilter(django_filters.FilterSet):
@@ -182,9 +194,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Rack
@@ -292,9 +302,7 @@ class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = DeviceType
@@ -443,6 +451,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
name = NullableCharFieldFilter()
asset_tag = NullableCharFieldFilter()
region_id = django_filters.NumberFilter(
method='filter_region',
name='pk',
label='Region (ID)',
)
region = django_filters.CharFilter(
method='filter_region',
name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -506,9 +524,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Device
@@ -525,6 +541,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
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:
@@ -558,9 +584,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class ConsolePortFilter(DeviceComponentFilterSet):
@@ -619,13 +643,23 @@ class InterfaceFilter(django_filters.FilterSet):
method='_mac_address',
label='MAC address',
)
tag = django_filters.CharFilter(
name='tags__slug',
tag = TagFilter()
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
label='Assigned VLAN'
)
vlan = django_filters.CharFilter(
method='filter_vlan',
label='Assigned VID'
)
form_factor = django_filters.MultipleChoiceFilter(
choices=IFACE_FF_CHOICES,
null_value=None
)
class Meta:
model = Interface
fields = ['name', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
fields = ['name', 'enabled', 'mtu', 'mgmt_only']
def filter_device(self, queryset, name, value):
try:
@@ -636,6 +670,24 @@ class InterfaceFilter(django_filters.FilterSet):
except Device.DoesNotExist:
return queryset.none()
def filter_vlan_id(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id=value) |
Q(tagged_vlans=value)
)
def filter_vlan(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
return queryset.filter(
Q(untagged_vlan_id__vid=value) |
Q(tagged_vlans__vid=value)
)
def filter_type(self, queryset, name, value):
value = value.strip().lower()
return {
@@ -668,6 +720,15 @@ class InventoryItemFilter(DeviceComponentFilterSet):
method='search',
label='Search',
)
device_id = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
)
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=InventoryItem.objects.all(),
label='Parent inventory item (ID)',
@@ -728,9 +789,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = VirtualChassis

View File

@@ -1,27 +0,0 @@
from __future__ import unicode_literals
from django import forms
from django.core.exceptions import ValidationError
from netaddr import EUI, AddrFormatError
#
# Form fields
#
class MACAddressFormField(forms.Field):
default_error_messages = {
'invalid': "Enter a valid MAC address.",
}
def to_python(self, value):
if not value:
return None
if isinstance(value, EUI):
return value
try:
return EUI(value, version=48)
except AddrFormatError:
raise ValidationError("Please specify a valid MAC address.")

View File

@@ -18,16 +18,15 @@ from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
)
from virtualization.models import Cluster
from virtualization.models import Cluster, ClusterGroup
from .constants import (
CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_CONNECTED, DEVICE_STATUS_CHOICES, IFACE_FF_CHOICES, IFACE_FF_LAG,
IFACE_MODE_ACCESS, IFACE_MODE_CHOICES, IFACE_MODE_TAGGED_ALL, IFACE_ORDERING_CHOICES, RACK_FACE_CHOICES,
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
)
from .formfields import MACAddressFormField
from .models import (
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
@@ -821,18 +820,38 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
display_field='model'
)
)
cluster_group = forms.ModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
widget=forms.Select(
attrs={'filter-for': 'cluster', 'nullable': 'true'}
)
)
cluster = ChainedModelChoiceField(
queryset=Cluster.objects.all(),
chains=(
('group', 'cluster_group'),
),
required=False,
widget=APISelect(
api_url='/api/virtualization/clusters/?group_id={{cluster_group}}',
)
)
comments = CommentField()
tags = TagField(required=False)
local_context_data = JSONField(required=False)
class Meta:
model = Device
fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant',
'comments', 'tags', 'local_context_data'
]
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context"
}
widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}),
@@ -1106,6 +1125,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
model = Device
q = forms.CharField(required=False, label='Search')
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
)
site = FilterChoiceField(
queryset=Site.objects.annotate(filter_count=Count('devices')),
to_field_name='slug',
@@ -1192,6 +1216,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class ConsolePortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class ConsoleConnectionCSVForm(forms.ModelForm):
@@ -1325,7 +1350,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF
label='Port',
widget=APISelect(
api_url='/api/dcim/console-server-ports/?device_id={{console_server}}',
disabled_indicator='connected_console',
disabled_indicator='is_connected',
)
)
@@ -1362,6 +1387,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class ConsoleServerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -1415,7 +1441,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.
label='Port',
widget=APISelect(
api_url='/api/dcim/console-ports/?device_id={{device}}',
disabled_indicator='cs_port'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(
@@ -1459,6 +1485,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class PowerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PowerConnectionCSVForm(forms.ModelForm):
@@ -1592,7 +1619,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor
label='Outlet',
widget=APISelect(
api_url='/api/dcim/power-outlets/?device_id={{pdu}}',
disabled_indicator='connected_port'
disabled_indicator='is_connected'
)
)
@@ -1629,6 +1656,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class PowerOutletCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -1682,7 +1710,7 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
label='Port',
widget=APISelect(
api_url='/api/dcim/power-ports/?device_id={{device}}',
disabled_indicator='power_outlet'
disabled_indicator='is_connected'
)
)
connection_status = forms.BooleanField(
@@ -1795,7 +1823,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
# Compile VLAN choices
vlan_choices = []
# Add global VLANs
# Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans])
@@ -1808,16 +1836,15 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
parent = self.instance.parent
if parent is not None:
site = getattr(self.instance.parent, 'site', None)
if site is not None:
# Add site VLANs
if parent.site:
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=parent.site):
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
@@ -1855,7 +1882,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
enabled = forms.BooleanField(required=False)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address')
mac_address = forms.CharField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField(
required=False,
label='OOB Management',
@@ -1863,6 +1890,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
)
description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
tags = TagField(required=False)
def __init__(self, *args, **kwargs):
@@ -2100,6 +2128,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class DeviceBayCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
@@ -2194,6 +2223,7 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
class InventoryItemFilterForm(BootstrapMixin, forms.Form):
model = InventoryItem
q = forms.CharField(required=False, label='Search')
device = forms.CharField(required=False, label='Device name')
manufacturer = FilterChoiceField(
queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')),
to_field_name='slug',

View File

@@ -0,0 +1,29 @@
# Generated by Django 2.0.8 on 2018-08-22 14:23
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0061_platform_napalm_args'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='mtu',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)], verbose_name='MTU'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['SONET', [[6100, 'OC-3/STM-1'], [6200, 'OC-12/STM-4'], [6300, 'OC-48/STM-16'], [6400, 'OC-192/STM-64'], [6500, 'OC-768/STM-256'], [6600, 'OC-1920/STM-640'], [6700, 'OC-3840/STM-1234']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)'], [3320, 'SFP28 (32GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.0.8 on 2018-09-16 02:01
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0062_interface_mtu'),
]
operations = [
migrations.AddField(
model_name='device',
name='local_context_data',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@@ -10,7 +10,7 @@ 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, ObjectDoesNotExist
from django.db.models import Count, Q
from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey
@@ -1400,7 +1400,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
})
# Validate manufacturer/platform
if self.device_type and self.platform:
if hasattr(self, 'device_type') and self.platform:
if self.platform.manufacturer and self.platform.manufacturer != self.device_type.manufacturer:
raise ValidationError({
'platform': "The assigned platform is limited to {} device types, but this device's type belongs "
@@ -1809,9 +1809,10 @@ class Interface(ComponentModel):
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveSmallIntegerField(
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
@@ -2034,25 +2035,44 @@ class InterfaceConnection(models.Model):
csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status']
def clean(self):
try:
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
except ObjectDoesNotExist:
pass
# An interface cannot be connected to itself
if self.interface_a == self.interface_b:
raise ValidationError({
'interface_b': "Cannot connect an interface to itself."
})
# Only connectable interface types are permitted
if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_a': '{} is not a connectable interface type.'.format(
self.interface_a.get_form_factor_display()
)
})
if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'interface_b': '{} is not a connectable interface type.'.format(
self.interface_b.get_form_factor_display()
)
})
# Prevent the A side of one connection from being the B side of another
interface_a_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_a) |
Q(interface_b=self.interface_a)
).exclude(pk=self.pk)
if interface_a_connections.exists():
raise ValidationError({
'interface_a': "This interface is already connected."
})
interface_b_connections = InterfaceConnection.objects.filter(
Q(interface_a=self.interface_b) |
Q(interface_b=self.interface_b)
).exclude(pk=self.pk)
if interface_b_connections.exists():
raise ValidationError({
'interface_b': "This interface is already connected."
})
def to_csv(self):
return (
@@ -2071,6 +2091,7 @@ class InterfaceConnection(models.Model):
(self.interface_a, self.interface_b),
(self.interface_b, self.interface_a),
)
for interface, peer_interface in interfaces:
if action == OBJECTCHANGE_ACTION_DELETE:
connection_data = {
@@ -2081,11 +2102,17 @@ class InterfaceConnection(models.Model):
'connected_interface': peer_interface.pk,
'connection_status': self.connection_status
}
try:
parent_obj = interface.parent
except ObjectDoesNotExist:
parent_obj = None
ObjectChange(
user=user,
request_id=request_id,
changed_object=interface,
related_object=interface.parent,
related_object=parent_obj,
action=OBJECTCHANGE_ACTION_UPDATE,
object_data=serialize_object(interface, extra=connection_data)
).save()

View File

@@ -9,7 +9,7 @@ from .models import (
ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, InventoryItem,
Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup,
RackReservation, Region, Site, VirtualChassis,
RackReservation, RackRole, Region, Site, VirtualChassis,
)
REGION_LINK = """
@@ -250,7 +250,7 @@ class RackRoleTable(BaseTable):
verbose_name='')
class Meta(BaseTable.Meta):
model = RackGroup
model = RackRole
fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions')
@@ -614,10 +614,12 @@ class PowerConnectionTable(BaseTable):
class InterfaceConnectionTable(BaseTable):
device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'),
args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
interface_a = tables.Column(verbose_name='Interface A')
interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'),
args=[Accessor('interface_a.pk')], verbose_name='Interface A')
device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'),
args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
interface_b = tables.Column(verbose_name='Interface B')
interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'),
args=[Accessor('interface_b.pk')], verbose_name='Interface B')
class Meta(BaseTable.Meta):
model = InterfaceConnection

View File

@@ -44,6 +44,16 @@ class RegionTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_regions_brief(self):
url = reverse('dcim-api:region-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_region(self):
data = {
@@ -158,6 +168,16 @@ class SiteTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_sites_brief(self):
url = reverse('dcim-api:site-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_site(self):
data = {
@@ -262,6 +282,16 @@ class RackGroupTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_rackgroups_brief(self):
url = reverse('dcim-api:rackgroup-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_rackgroup(self):
data = {
@@ -360,6 +390,16 @@ class RackRoleTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_rackroles_brief(self):
url = reverse('dcim-api:rackrole-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_rackrole(self):
data = {
@@ -477,6 +517,16 @@ class RackTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_racks_brief(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['display_name', 'id', 'name', 'url']
)
def test_create_rack(self):
data = {
@@ -693,6 +743,16 @@ class ManufacturerTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_manufacturers_brief(self):
url = reverse('dcim-api:manufacturer-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_manufacturer(self):
data = {
@@ -792,6 +852,16 @@ class DeviceTypeTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_devicetypes_brief(self):
url = reverse('dcim-api:devicetype-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'manufacturer', 'model', 'slug', 'url']
)
def test_create_devicetype(self):
data = {
@@ -1496,6 +1566,16 @@ class DeviceRoleTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_deviceroles_brief(self):
url = reverse('dcim-api:devicerole-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_devicerole(self):
data = {
@@ -1594,6 +1674,16 @@ class PlatformTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_platforms_brief(self):
url = reverse('dcim-api:platform-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_platform(self):
data = {
@@ -1722,6 +1812,16 @@ class DeviceTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_devices_brief(self):
url = reverse('dcim-api:device-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['display_name', 'id', 'name', 'url']
)
def test_create_device(self):
data = {
@@ -1848,6 +1948,16 @@ class ConsolePortTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_consoleports_brief(self):
url = reverse('dcim-api:consoleport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_consoleport(self):
data = {
@@ -1953,6 +2063,16 @@ class ConsoleServerPortTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_consoleserverports_brief(self):
url = reverse('dcim-api:consoleserverport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_consoleserverport(self):
data = {
@@ -2054,6 +2174,16 @@ class PowerPortTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_powerports_brief(self):
url = reverse('dcim-api:powerport-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_powerport(self):
data = {
@@ -2159,6 +2289,16 @@ class PowerOutletTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_poweroutlets_brief(self):
url = reverse('dcim-api:poweroutlet-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_poweroutlet(self):
data = {
@@ -2285,6 +2425,16 @@ class InterfaceTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_interfaces_brief(self):
url = reverse('dcim-api:interface-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'is_connected', 'name', 'url']
)
def test_create_interface(self):
data = {
@@ -2456,6 +2606,16 @@ class DeviceBayTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_devicebays_brief(self):
url = reverse('dcim-api:devicebay-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['device', 'id', 'name', 'url']
)
def test_create_devicebay(self):
data = {
@@ -2778,6 +2938,16 @@ class InterfaceConnectionTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_interfaceconnections_brief(self):
url = reverse('dcim-api:interfaceconnection-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['connection_status', 'id', 'url']
)
def test_create_interfaceconnection(self):
data = {
@@ -2883,7 +3053,7 @@ class ConnectedDeviceTest(APITestCase):
def test_get_connected_device(self):
url = reverse('dcim-api:connected-device-list')
response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header)
response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['name'], self.device1.name)
@@ -2973,6 +3143,16 @@ class VirtualChassisTest(APITestCase):
self.assertEqual(response.data['count'], 2)
def test_list_virtualchassis_brief(self):
url = reverse('dcim-api:virtualchassis-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'url']
)
def test_create_virtualchassis(self):
data = {

View File

@@ -858,8 +858,10 @@ class DeviceView(View):
device.device_type.interface_ordering
).select_related(
'connected_as_a__interface_b__device', 'connected_as_b__interface_a__device',
'circuit_termination__circuit'
).prefetch_related('ip_addresses')
'circuit_termination__circuit__provider'
).prefetch_related(
'tags', 'ip_addresses'
)
# Device bays
device_bays = natsorted(

View File

@@ -4,12 +4,9 @@ from django import forms
from django.contrib import admin
from django.utils.safestring import mark_safe
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
from .models import (
ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction,
Webhook,
)
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, Webhook
def order_content_types(field):
@@ -39,7 +36,7 @@ class WebhookForm(forms.ModelForm):
order_content_types(self.fields['obj_type'])
@admin.register(Webhook)
@admin.register(Webhook, site=admin_site)
class WebhookAdmin(admin.ModelAdmin):
list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
@@ -72,7 +69,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
extra = 5
@admin.register(CustomField)
@admin.register(CustomField, site=admin_site)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
@@ -86,7 +83,7 @@ class CustomFieldAdmin(admin.ModelAdmin):
# Graphs
#
@admin.register(Graph)
@admin.register(Graph, site=admin_site)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
@@ -109,7 +106,7 @@ class ExportTemplateForm(forms.ModelForm):
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(ExportTemplate)
@admin.register(ExportTemplate, site=admin_site)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
form = ExportTemplateForm
@@ -119,7 +116,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
# Topology maps
#
@admin.register(TopologyMap)
@admin.register(TopologyMap, site=admin_site)
class TopologyMapAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site']
prepopulated_fields = {
@@ -131,7 +128,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
# User actions
#
@admin.register(UserAction)
@admin.register(UserAction, site=admin_site)
class UserActionAdmin(admin.ModelAdmin):
actions = None
list_display = ['user', 'action', 'content_type', 'object_id', '_message']

View File

@@ -138,8 +138,11 @@ class ImageAttachmentViewSet(ModelViewSet):
#
class ConfigContextViewSet(ModelViewSet):
queryset = ConfigContext.objects.prefetch_related('regions', 'sites', 'roles', 'platforms', 'tenants')
queryset = ConfigContext.objects.prefetch_related(
'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants',
)
serializer_class = serializers.ConfigContextSerializer
filter_class = filters.ConfigContextFilter
#

View File

@@ -6,9 +6,10 @@ from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from taggit.models import Tag
from dcim.models import Site
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 CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction
class CustomFieldFilter(django_filters.Filter):
@@ -124,6 +125,92 @@ class TopologyMapFilter(django_filters.FilterSet):
fields = ['name', 'slug']
class ConfigContextFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
region_id = django_filters.ModelMultipleChoiceFilter(
name='regions',
queryset=Region.objects.all(),
label='Region',
)
region = django_filters.ModelMultipleChoiceFilter(
name='regions__slug',
queryset=Region.objects.all(),
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
name='sites',
queryset=Site.objects.all(),
label='Site',
)
site = django_filters.ModelMultipleChoiceFilter(
name='sites__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site (slug)',
)
role_id = django_filters.ModelMultipleChoiceFilter(
name='roles',
queryset=DeviceRole.objects.all(),
label='Role',
)
role = django_filters.ModelMultipleChoiceFilter(
name='roles__slug',
queryset=DeviceRole.objects.all(),
to_field_name='slug',
label='Role (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
name='platforms',
queryset=Platform.objects.all(),
label='Platform',
)
platform = django_filters.ModelMultipleChoiceFilter(
name='platforms__slug',
queryset=Platform.objects.all(),
to_field_name='slug',
label='Platform (slug)',
)
tenant_group_id = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups',
queryset=TenantGroup.objects.all(),
label='Tenant group',
)
tenant_group = django_filters.ModelMultipleChoiceFilter(
name='tenant_groups__slug',
queryset=TenantGroup.objects.all(),
to_field_name='slug',
label='Tenant group (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
name='tenants',
queryset=Tenant.objects.all(),
label='Tenant',
)
tenant = django_filters.ModelMultipleChoiceFilter(
name='tenants__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
class Meta:
model = ConfigContext
fields = ['name', 'is_active']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(data__icontains=value)
)
class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import Region
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, 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,
@@ -204,6 +208,11 @@ class AddRemoveTagsForm(forms.Form):
self.fields['remove_tags'] = TagField(required=False)
class TagFilterForm(BootstrapMixin, forms.Form):
model = Tag
q = forms.CharField(required=False, label='Search')
#
# Config contexts
#
@@ -223,6 +232,59 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
]
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=ConfigContext.objects.all(),
widget=forms.MultipleHiddenInput
)
weight = forms.IntegerField(
required=False,
min_value=0
)
is_active = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
description = forms.CharField(
required=False,
max_length=100
)
class Meta:
nullable_fields = ['description']
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug'
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug'
)
role = FilterChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug'
)
platform = FilterChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug'
)
tenant_group = FilterChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug'
)
#
# Image attachments
#

View File

@@ -2,9 +2,6 @@
# Generated by Django 1.11.14 on 2018-07-31 02:19
from __future__ import unicode_literals
import re
from distutils.version import StrictVersion
from django.conf import settings
import django.contrib.postgres.fields.jsonb
from django.db import connection, migrations, models
@@ -19,13 +16,14 @@ def verify_postgresql_version(apps, schema_editor):
"""
Verify that PostgreSQL is version 9.4 or higher.
"""
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
DB_MINIMUM_VERSION = 90400 # 9.4.0
try:
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
pg_version = connection.pg_version
if pg_version < DB_MINIMUM_VERSION:
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
except OperationalError:

View File

@@ -1,8 +1,6 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-09-26 21:25
from __future__ import unicode_literals
from distutils.version import StrictVersion
import re
from django.conf import settings
import django.contrib.postgres.fields.jsonb
@@ -15,13 +13,14 @@ def verify_postgresql_version(apps, schema_editor):
"""
Verify that PostgreSQL is version 9.4 or higher.
"""
# https://www.postgresql.org/docs/current/libpq-status.html#LIBPQ-PQSERVERVERSION
DB_MINIMUM_VERSION = 90400 # 9.4.0
try:
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
row = cursor.fetchone()
pg_version = re.match(r'^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))
pg_version = connection.pg_version
if pg_version < DB_MINIMUM_VERSION:
raise Exception("PostgreSQL 9.4.0 ({}) or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(DB_MINIMUM_VERSION, pg_version))
# Skip if the database is missing (e.g. for CI testing) or misconfigured.
except OperationalError:

View File

@@ -18,7 +18,7 @@ from django.utils.encoding import python_2_unicode_compatible
from django.utils.safestring import mark_safe
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import foreground_color
from utilities.utils import deepmerge, foreground_color
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -700,9 +700,22 @@ class ConfigContext(models.Model):
def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
class ConfigContextModel(models.Model):
local_context_data = JSONField(
blank=True,
null=True,
)
class Meta:
abstract = True
@@ -714,7 +727,11 @@ class ConfigContextModel(models.Model):
# Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs
data = OrderedDict()
for context in ConfigContext.objects.get_for_object(self):
data.update(context.data)
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:
data = deepmerge(data, self.local_context_data)
return data

View File

@@ -1,7 +1,8 @@
from __future__ import unicode_literals
import django_tables2 as tables
from taggit.models import Tag
from django_tables2.utils import Accessor
from taggit.models import Tag, TaggedItem
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import ConfigContext, ObjectChange
@@ -15,6 +16,14 @@ TAG_ACTIONS = """
{% endif %}
"""
TAGGED_ITEM = """
{% if value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %}
{{ value }}
{% endif %}
"""
CONFIGCONTEXT_ACTIONS = """
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -55,6 +64,10 @@ OBJECTCHANGE_REQUEST_ID = """
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='extras:tag',
args=[Accessor('slug')]
)
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right'}},
@@ -66,21 +79,31 @@ class TagTable(BaseTable):
fields = ('pk', 'name', 'items', 'slug', 'actions')
class TaggedItemTable(BaseTable):
content_object = tables.TemplateColumn(
template_code=TAGGED_ITEM,
orderable=False,
verbose_name='Object'
)
content_type = tables.Column(
verbose_name='Type'
)
class Meta(BaseTable.Meta):
model = TaggedItem
fields = ('content_object', 'content_type')
class ConfigContextTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
is_active = BooleanColumn(
verbose_name='Active'
)
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions')
fields = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable):

View File

@@ -9,6 +9,7 @@ urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
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/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
@@ -16,6 +17,7 @@ urlpatterns = [
# Config contexts
url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'),
url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
url(r'^config-contexts/(?P<pk>\d+)/$', views.ConfigContextView.as_view(), name='configcontext'),
url(r'^config-contexts/(?P<pk>\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
url(r'^config-contexts/(?P<pk>\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),

View File

@@ -1,6 +1,7 @@
from __future__ import unicode_literals
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
@@ -9,15 +10,20 @@ from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from taggit.models import Tag
from django_tables2 import RequestConfig
from taggit.models import Tag, TaggedItem
from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters
from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .forms import (
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
TagFilterForm, TagForm,
)
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
#
@@ -25,11 +31,45 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable
#
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
).order_by(
'name'
)
filter = filters.TagFilter
filter_form = TagFilterForm
table = TagTable
template_name = 'extras/tag_list.html'
class TagView(View):
def get(self, request, slug):
tag = get_object_or_404(Tag, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).select_related(
'content_type'
).prefetch_related(
'content_object'
)
# Generate a table of all items tagged with this Tag
items_table = TaggedItemTable(tagged_items)
paginate = {
'klass': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(items_table)
return render(request, 'extras/tag.html', {
'tag': tag,
'items_count': tagged_items.count(),
'items_table': items_table,
})
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'taggit.change_tag'
model = Tag
@@ -45,7 +85,11 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
queryset = Tag.objects.annotate(items=Count('taggit_taggeditem_items')).order_by('name')
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
).order_by(
'name'
)
table = TagTable
default_return_url = 'extras:tag_list'
@@ -56,6 +100,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
table = ConfigContextTable
template_name = 'extras/configcontext_list.html'
@@ -83,6 +129,15 @@ class ConfigContextEditView(ConfigContextCreateView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
table = ConfigContextTable
form = ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
model = ConfigContext
@@ -104,9 +159,11 @@ class ObjectConfigContextView(View):
obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj)
model_name = self.object_class._meta.model_name
return render(request, 'extras/object_configcontext.html', {
self.object_class._meta.model_name: obj,
model_name: obj,
'obj': obj,
'rendered_context': obj.get_config_context(),
'source_contexts': source_contexts,
'base_template': self.base_template,

View File

@@ -45,7 +45,7 @@ def enqueue_webhooks(instance, action):
"extras.webhooks_worker.process_webhook",
webhook,
serializer.data,
instance.__class__,
instance._meta.model_name,
action,
str(datetime.datetime.now())
)

View File

@@ -10,14 +10,14 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ
@job('default')
def process_webhook(webhook, data, model_class, event, timestamp):
def process_webhook(webhook, data, model_name, event, timestamp):
"""
Make a POST request to the defined Webhook
"""
payload = {
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
'timestamp': timestamp,
'model': model_class._meta.model_name,
'model': model_name,
'data': data
}
headers = {

View File

@@ -33,7 +33,7 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet):
#
class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.select_related('tenant')
queryset = VRF.objects.select_related('tenant').prefetch_related('tags')
serializer_class = serializers.VRFSerializer
filter_class = filters.VRFFilter
@@ -53,7 +53,7 @@ class RIRViewSet(ModelViewSet):
#
class AggregateViewSet(CustomFieldModelViewSet):
queryset = Aggregate.objects.select_related('rir')
queryset = Aggregate.objects.select_related('rir').prefetch_related('tags')
serializer_class = serializers.AggregateSerializer
filter_class = filters.AggregateFilter
@@ -73,7 +73,7 @@ class RoleViewSet(ModelViewSet):
#
class PrefixViewSet(CustomFieldModelViewSet):
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role')
queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags')
serializer_class = serializers.PrefixSerializer
filter_class = filters.PrefixFilter
@@ -140,10 +140,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
available_prefixes.remove(allocated_prefix)
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True)
serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context)
else:
serializer = serializers.PrefixSerializer(data=requested_prefixes[0])
serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context)
# Create the new Prefix(es)
if serializer.is_valid():
@@ -199,10 +200,11 @@ class PrefixViewSet(CustomFieldModelViewSet):
requested_ip['vrf'] = prefix.vrf.pk if prefix.vrf else None
# Initialize the serializer with a list or a single object depending on what was requested
context = {'request': request}
if isinstance(request.data, list):
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True)
serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context)
else:
serializer = serializers.IPAddressSerializer(data=requested_ips[0])
serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context)
# Create the new IP address(es)
if serializer.is_valid():
@@ -243,7 +245,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.select_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine'
).prefetch_related(
'nat_outside'
'nat_outside', 'tags',
)
serializer_class = serializers.IPAddressSerializer
filter_class = filters.IPAddressFilter
@@ -264,7 +266,7 @@ class VLANGroupViewSet(ModelViewSet):
#
class VLANViewSet(CustomFieldModelViewSet):
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')
queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags')
serializer_class = serializers.VLANSerializer
filter_class = filters.VLANFilter
@@ -274,6 +276,6 @@ class VLANViewSet(CustomFieldModelViewSet):
#
class ServiceViewSet(ModelViewSet):
queryset = Service.objects.select_related('device')
queryset = Service.objects.select_related('device').prefetch_related('tags')
serializer_class = serializers.ServiceSerializer
filter_class = filters.ServiceFilter

View File

@@ -51,6 +51,16 @@ IPADDRESS_ROLE_CHOICES = (
(IPADDRESS_ROLE_CARP, 'CARP'),
)
IPADDRESS_ROLES_NONUNIQUE = (
# IPAddress roles which are exempt from unique address enforcement
IPADDRESS_ROLE_ANYCAST,
IPADDRESS_ROLE_VIP,
IPADDRESS_ROLE_VRRP,
IPADDRESS_ROLE_HSRP,
IPADDRESS_ROLE_GLBP,
IPADDRESS_ROLE_CARP,
)
# VLAN statuses
VLAN_STATUS_ACTIVE = 1
VLAN_STATUS_RESERVED = 2

View File

@@ -9,7 +9,7 @@ from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from virtualization.models import VirtualMachine
from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -31,9 +31,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Tenant (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
def search(self, queryset, name, value):
if not value.strip():
@@ -73,9 +71,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='RIR (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Aggregate
@@ -174,9 +170,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=PREFIX_STATUS_CHOICES,
null_value=None
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Prefix
@@ -303,9 +297,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
role = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_ROLE_CHOICES
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = IPAddress
@@ -422,9 +414,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=VLAN_STATUS_CHOICES,
null_value=None
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = VLAN
@@ -466,9 +456,7 @@ class ServiceFilter(django_filters.FilterSet):
to_field_name='name',
label='Virtual machine (name)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Service

View File

@@ -596,7 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
if self.address:
# Enforce unique IP space (if applicable)
if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique):
if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE
) or (
self.vrf and self.vrf.enforce_unique
)):
duplicate_ips = self.get_duplicates()
if duplicate_ips:
raise ValidationError({

View File

@@ -34,6 +34,16 @@ class VRFTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_vrfs_brief(self):
url = reverse('ipam-api:vrf-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'rd', 'url']
)
def test_create_vrf(self):
data = {
@@ -125,6 +135,16 @@ class RIRTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_rirs_brief(self):
url = reverse('ipam-api:rir-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_rir(self):
data = {
@@ -218,6 +238,16 @@ class AggregateTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_aggregates_brief(self):
url = reverse('ipam-api:aggregate-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['family', 'id', 'prefix', 'url']
)
def test_create_aggregate(self):
data = {
@@ -309,6 +339,16 @@ class RoleTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_roles_brief(self):
url = reverse('ipam-api:role-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_role(self):
data = {
@@ -397,13 +437,23 @@ class PrefixTest(APITestCase):
self.assertEqual(response.data['prefix'], str(self.prefix1.prefix))
def test_list_prefixs(self):
def test_list_prefixes(self):
url = reverse('ipam-api:prefix-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_prefixes_brief(self):
url = reverse('ipam-api:prefix-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['family', 'id', 'prefix', 'url']
)
def test_create_prefix(self):
data = {
@@ -494,7 +544,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_prefix(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True)
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests
@@ -512,6 +563,7 @@ class PrefixTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix
@@ -562,7 +614,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_ip(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True)
vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests
@@ -572,6 +625,7 @@ class PrefixTest(APITestCase):
}
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP
@@ -626,6 +680,16 @@ class IPAddressTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_ipaddresses_brief(self):
url = reverse('ipam-api:ipaddress-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['address', 'family', 'id', 'url']
)
def test_create_ipaddress(self):
data = {
@@ -714,6 +778,16 @@ class VLANGroupTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_vlangroups_brief(self):
url = reverse('ipam-api:vlangroup-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_vlangroup(self):
data = {
@@ -805,6 +879,16 @@ class VLANTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_vlans_brief(self):
url = reverse('ipam-api:vlan-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['display_name', 'id', 'name', 'url', 'vid']
)
def test_create_vlan(self):
data = {

View File

@@ -4,6 +4,7 @@ import netaddr
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from ipam.constants import IPADDRESS_ROLE_VIP
from ipam.models import IPAddress, Prefix, VRF
@@ -59,3 +60,8 @@ class TestIPAddress(TestCase):
IPAddress.objects.create(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24'))
self.assertRaises(ValidationError, duplicate_ip.clean)
@override_settings(ENFORCE_GLOBAL_UNIQUE=True)
def test_duplicate_nonunique_role(self):
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)
IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPADDRESS_ROLE_VIP)

View File

@@ -991,6 +991,9 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView):
obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine'])
return obj
def get_return_url(self, request, service):
return service.parent.get_absolute_url()
class ServiceEditView(ServiceCreateView):
permission_required = 'ipam.change_service'

31
netbox/netbox/admin.py Normal file
View File

@@ -0,0 +1,31 @@
from django.conf import settings
from django.contrib.admin import AdminSite
from django.contrib.auth.models import Group, User
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from taggit.admin import TagAdmin
from taggit.models import Tag
class NetBoxAdminSite(AdminSite):
"""
Custom admin site
"""
site_header = 'NetBox Administration'
site_title = 'NetBox'
site_url = '/{}'.format(settings.BASE_PATH)
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:
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass

View File

@@ -1,5 +1,6 @@
from __future__ import unicode_literals
from django.conf import settings
from rest_framework import authentication, exceptions
from rest_framework.pagination import LimitOffsetPagination
from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS
@@ -104,8 +105,6 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
def get_limit(self, request):
from django.conf import settings
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
@@ -123,6 +122,22 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return self.default_limit
def get_next_link(self):
# Pagination has been disabled
if not self.limit:
return None
return super(OptionalLimitOffsetPagination, self).get_next_link()
def get_previous_link(self):
# Pagination has been disabled
if not self.limit:
return None
return super(OptionalLimitOffsetPagination, self).get_previous_link()
#
# Miscellaneous

View File

@@ -13,6 +13,7 @@ OBJ_TYPE_CHOICES = (
('DCIM', (
('site', 'Sites'),
('rack', 'Racks'),
('rackgroup', 'Rack Groups'),
('devicetype', 'Device types'),
('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'),

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning
)
VERSION = '2.4.2'
VERSION = '2.4.9'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -272,13 +272,15 @@ RQ_QUEUES = {
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
}
}
RQ_SHOW_ADMIN_LINK = True
# drf_yasg settings for Swagger
SWAGGER_SETTINGS = {
'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema',
'DEFAULT_FIELD_INSPECTORS': [
'utilities.custom_inspectors.NullableBooleanFieldInspector',
'utilities.custom_inspectors.CustomChoiceFieldInspector',
'utilities.custom_inspectors.TagListFieldInspector',
'utilities.custom_inspectors.SerializedPKRelatedFieldInspector',
'drf_yasg.inspectors.CamelCaseJSONFilter',
'drf_yasg.inspectors.ReferencingSerializerInspector',
'drf_yasg.inspectors.RelatedFieldInspector',

View File

@@ -2,13 +2,13 @@ from __future__ import unicode_literals
from django.conf import settings
from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve
from drf_yasg.views import get_schema_view
from drf_yasg import openapi
from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView
from .admin import admin_site
schema_view = get_schema_view(
openapi.Info(
@@ -60,7 +60,7 @@ _patterns = [
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
# Admin
url(r'^admin/', admin.site.urls),
url(r'^admin/', admin_site.urls),
]
@@ -69,7 +69,6 @@ if settings.WEBHOOKS_ENABLED:
url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
]
if settings.DEBUG:
import debug_toolbar
_patterns += [

View File

@@ -12,9 +12,16 @@ from rest_framework.views import APIView
from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable
from dcim.filters import (
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
)
from dcim.models import (
ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site,
VirtualChassis
)
from dcim.tables import (
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
)
from extras.models import ObjectChange, ReportResult, TopologyMap
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@@ -58,6 +65,12 @@ SEARCH_TYPES = OrderedDict((
'table': RackTable,
'url': 'dcim:rack_list',
}),
('rackgroup', {
'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')),
'filter': RackGroupFilter,
'table': RackGroupTable,
'url': 'dcim:rackgroup_list',
}),
('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
'filter': DeviceTypeFilter,
@@ -184,7 +197,7 @@ class HomeView(View):
'stats': stats,
'topology_maps': TopologyMap.objects.filter(site__isnull=True),
'report_results': ReportResult.objects.order_by('-created')[:10],
'changelog': ObjectChange.objects.select_related('user')[:50]
'changelog': ObjectChange.objects.select_related('user', 'changed_object_type')[:50]
})

View File

@@ -390,6 +390,19 @@ table.report th a {
top: -51px;
}
/* Rendered Markdown */
.rendered-markdown table {
width: 100%;
}
.rendered-markdown th {
border-bottom: 2px solid #dddddd;
padding: 8px;
}
.rendered-markdown td {
border-top: 1px solid #dddddd;
padding: 8px;
}
/* AJAX loader */
.loading {
position: fixed;

View File

@@ -82,7 +82,7 @@ $(document).ready(function() {
}
if ($(parent).val() || $(parent).attr('nullable') == 'true') {
var api_url = child_field.attr('api-url') + '&limit=1000';
var api_url = child_field.attr('api-url') + '&limit=0&brief=1';
var disabled_indicator = child_field.attr('disabled-indicator');
var initial_value = child_field.attr('initial');
var display_field = child_field.attr('display-field') || 'name';

View File

@@ -24,7 +24,7 @@ $(document).ready(function() {
source: function(request, response) {
$.ajax({
type: 'GET',
url: search_field.attr('data-source'),
url: search_field.attr('data-source') + '?brief=1',
data: search_key + '=' + request.term,
success: function(data) {
var choices = [];
@@ -49,7 +49,7 @@ $(document).ready(function() {
// Disable parent selection fields
// $('select[filter-for="' + real_field.attr('name') + '"]').val('');
},
minLength: 4,
minLength: 3,
delay: 500
});

View File

@@ -3,11 +3,12 @@ from __future__ import unicode_literals
from django.contrib import admin, messages
from django.shortcuts import redirect, render
from netbox.admin import admin_site
from .forms import ActivateUserKeyForm
from .models import UserKey
@admin.register(UserKey)
@admin.register(UserKey, site=admin_site)
class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created']

View File

@@ -48,7 +48,7 @@ class SecretViewSet(ModelViewSet):
queryset = Secret.objects.select_related(
'device__primary_ip4', 'device__primary_ip6', 'role',
).prefetch_related(
'role__users', 'role__groups',
'role__users', 'role__groups', 'tags',
)
serializer_class = serializers.SecretSerializer
filter_class = filters.SecretFilter

View File

@@ -5,7 +5,7 @@ from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .models import Secret, SecretRole
@@ -42,9 +42,7 @@ class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='name',
label='Device (name)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Secret

View File

@@ -1,6 +1,7 @@
from __future__ import unicode_literals
import os
import sys
from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
@@ -392,6 +393,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
s = s.encode('utf8')
if len(s) > 65535:
raise ValueError("Maximum plaintext size is 65535 bytes.")
# Minimum ciphertext size is 64 bytes to conceal the length of short secrets.
if len(s) <= 62:
pad_length = 62 - len(s)
@@ -399,12 +401,14 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
pad_length = 16 - ((len(s) + 2) % 16)
else:
pad_length = 0
return (
chr(len(s) >> 8).encode() +
chr(len(s) % 256).encode() +
s +
os.urandom(pad_length)
)
# Python 2 compatibility
if sys.version_info[0] < 3:
header = chr(len(s) >> 8) + chr(len(s) % 256)
else:
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
return header + s + os.urandom(pad_length)
def _unpad(self, s):
"""

View File

@@ -73,6 +73,16 @@ class SecretRoleTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_secretroles_brief(self):
url = reverse('secrets-api:secretrole-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_secretrole(self):
data = {

View File

@@ -1,4 +1,5 @@
from __future__ import unicode_literals
import string
from Crypto.PublicKey import RSA
from django.conf import settings
@@ -88,7 +89,7 @@ class SecretTestCase(TestCase):
"""
Test basic encryption and decryption functionality using a random master key.
"""
plaintext = "FooBar123"
plaintext = string.printable * 2
secret_key = generate_random_key()
s = Secret(plaintext=plaintext)
s.encrypt(secret_key)

View File

@@ -54,7 +54,7 @@
</div>
<div class="col-xs-4 text-right">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/" target="_blank">Docs</a> &middot;
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
<i class="fa fa-fw fa-code text-primary"></i> <a href="https://github.com/digitalocean/netbox">Code</a> &middot;
<i class="fa fa-fw fa-support text-primary"></i> <a href="https://github.com/digitalocean/netbox/wiki">Help</a>

View File

@@ -131,7 +131,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if circuit.comments %}
{{ circuit.comments|gfm }}
{% else %}

View File

@@ -129,7 +129,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if provider.comments %}
{{ provider.comments|gfm }}
{% else %}

View File

@@ -54,7 +54,9 @@
<a href="{% url 'dcim:device' pk=device.pk %}">Device</a>
</li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventory_items.count }}</span>
</a>
</li>
{% if perms.dcim.napalm_read %}
{% if device.status != 1 %}
@@ -288,7 +290,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if device.comments %}
{{ device.comments|gfm }}
{% else %}
@@ -445,7 +447,7 @@
<div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %}
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -481,7 +483,7 @@
</button>
{% endif %}
{% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button>
{% endif %}
@@ -523,6 +525,7 @@
<th>Name</th>
<th>LAG</th>
<th>Description</th>
<th>MTU</th>
<th>Mode</th>
<th colspan="2">Connection</th>
<th></th>
@@ -573,7 +576,7 @@
{% endif %}
{% if cs_ports or device.device_type.is_console_server %}
{% if perms.dcim.delete_consoleserverport %}
<form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -606,12 +609,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}
@@ -631,7 +634,7 @@
{% endif %}
{% if power_outlets or device.device_type.is_pdu %}
{% if perms.dcim.delete_poweroutlet %}
<form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}">
<form method="post">
{% csrf_token %}
{% endif %}
<div class="panel panel-default">
@@ -664,12 +667,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs">
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button>
{% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs">
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button>
{% endif %}

View File

@@ -62,6 +62,13 @@
{% endif %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Virtualization</strong></div>
<div class="panel-body">
{% render_field form.cluster_group %}
{% render_field form.cluster %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
@@ -77,6 +84,12 @@
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_context_data %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">

View File

@@ -64,8 +64,10 @@ $(document).ready(function() {
}
// Clean up hostnames/interfaces learned via LLDP
var lldp_device = neighbor['hostname'].split(".")[0]; // Strip off any trailing domain name
var lldp_interface = neighbor['port'].split(".")[0]; // Strip off any trailing subinterface ID
var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func
var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func
var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name
var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID
// Add LLDP neighbors to table
row.children('td.device').html(lldp_device);

View File

@@ -55,7 +55,10 @@
</tr>
<tr>
<td>Model Name</td>
<td>{{ devicetype.model }}</td>
<td>
{{ devicetype.model }}<br/>
<small class="text-muted">{{ devicetype.slug }}</small>
</td>
</tr>
<tr>
<td>Part Number</td>
@@ -164,7 +167,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if devicetype.comments %}
{{ devicetype.comments|gfm }}
{% else %}

View File

@@ -1,4 +1,5 @@
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="iface_{{ iface.name }}">
{% load helpers %}
<tr class="interface{% if not iface.enabled %} danger{% elif iface.connection and iface.connection.connection_status or iface.circuit_termination %} success{% elif iface.connection and not iface.connection.connection_status %} info{% elif iface.is_virtual %} warning{% endif %}" id="interface_{{ iface.name }}">
{# Checkbox #}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
@@ -7,32 +8,53 @@
</td>
{% endif %}
{# Icon and name #}
{# Icon/name/MAC #}
<td>
<span title="{{ iface.get_form_factor_display }}">
<i class="fa fa-fw fa-{% if iface.mgmt_only %}wrench{% elif iface.is_lag %}align-justify{% elif iface.is_virtual %}circle{% elif iface.is_wireless %}wifi{% else %}exchange{% endif %}"></i>
<a href="{{ iface.get_absolute_url }}">{{ iface }}</a>
</span>
{% if iface.mac_address %}
<br/><small class="text-muted">{{ iface.mac_address }}</small>
{% endif %}
</td>
{# LAG #}
<td>
{% if iface.lag %}
<a href="#iface_{{ iface.lag }}" class="label label-default">{{ iface.lag }}</a>
<a href="#interface_{{ iface.lag }}" class="label label-primary" title="{{ iface.lag.description }}">{{ iface.lag }}</a>
{% endif %}
</td>
{# Description #}
<td>{{ iface.description|default:"&mdash;" }}</td>
{# Description/tags #}
<td>
{% if iface.description %}
{{ iface.description }}<br/>
{% endif %}
{% for tag in iface.tags.all %}
{% tag tag %}
{% empty %}
{% if not iface.description %}&mdash;{% endif %}
{% endfor %}
</td>
{# MTU #}
<td>{{ iface.mtu|default:"&mdash;" }}</td>
{# 802.1Q mode #}
<td>{{ iface.get_mode_display }}</td>
<td>{{ iface.get_mode_display|default:"&mdash;" }}</td>
{# Connection or type #}
{% if iface.is_lag %}
<td colspan="2" class="text-muted">
LAG interface<br />
<small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
<small class="text-muted">
{% for member in iface.member_interfaces.all %}
<a href="#interface_{{ member.name }}">{{ member }}</a>{% if not forloop.last %}, {% endif %}
{% empty %}
No members
{% endfor %}
</small>
</td>
{% elif iface.is_virtual %}
<td colspan="2" class="text-muted">Virtual interface</td>
@@ -44,7 +66,7 @@
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
</td>
<td>
<span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span>
<a href="{% url 'dcim:interface' pk=connected_iface.pk %}"><span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span></a>
</td>
{% endwith %}
{% elif iface.circuit_termination %}
@@ -138,7 +160,7 @@
{% endif %}
{# IP addresses table #}
<td colspan="7" style="padding: 0">
<td colspan="8" style="padding: 0">
<table class="table table-condensed interface-ips">
<thead>
<tr class="text-muted">

View File

@@ -27,13 +27,13 @@
{% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.full_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
{{ u.device.name|default:u.device.device_role }}
{{ u.device }}
{% if u.device.devicebay_count %}
({{ u.device.get_children.count }}/{{ u.device.devicebay_count }})
{% endif %}
</a>
{% else %}
<span>{{ u.device.name|default:u.device.device_role }}</span>
<span>{{ u.device }}</span>
{% endifequal %}
</li>
{% else %}

View File

@@ -17,12 +17,12 @@
</div>
<div class="pull-right">
{% if perms.dcim.change_interface %}
<a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning">
<a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface
</a>
{% endif %}
{% if perms.dcim.delete_interface %}
<a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger">
<a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete this interface
</a>
{% endif %}
@@ -134,7 +134,9 @@
</tr>
<tr>
<td>Name</td>
<td>{{ connected_interface.name }}</td>
<td>
<a href="{{ connected_interface.get_absolute_url }}">{{ connected_interface.name }}</a>
</td>
</tr>
<tr>
<td>Type</td>

View File

@@ -14,11 +14,6 @@
{% render_field form.mgmt_only %}
{% render_field form.description %}
{% render_field form.mode %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>

View File

@@ -164,7 +164,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if rack.comments %}
{{ rack.comments|gfm }}
{% else %}

View File

@@ -230,7 +230,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if site.comments %}
{{ site.comments|gfm }}
{% else %}

View File

@@ -140,6 +140,20 @@
{% endif %}
</td>
</tr>
<tr>
<td>Tenant Groups</td>
<td>
{% if configcontext.tenant_groups.all %}
<ul>
{% for tenant_group in configcontext.tenant_groups.all %}
<li><a href="{{ tenant_group.get_absolute_url }}">{{ tenant_group }}</a></li>
{% endfor %}
</ul>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Tenants</td>
<td>

View File

@@ -9,8 +9,11 @@
</div>
<h1>{% block title %}Config Contexts{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:configcontext_bulk_delete' %}
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -16,6 +16,24 @@
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Local Context</strong>
</div>
<div class="panel-body">
{% if obj.local_context_data %}
<pre>{{ obj.local_context_data|render_json }}</pre>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
<div class="panel-footer">
<span class="help-block">
<i class="fa fa-info-circle"></i>
The local config context overwrites all source contexts.
</span>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Source Contexts</strong>

View File

@@ -0,0 +1,69 @@
{% extends '_base.html' %}
{% load helpers %}
{% block header %}
<div class="row">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'extras:tag_list' %}">Tags</a></li>
<li>{{ tag }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'extras:tag_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right">
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=tag.slug %}?return_url={% url 'extras:tag' slug=tag.slug %}" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>
Edit this tag
</a>
{% endif %}
</div>
<h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Tag</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Name</td>
<td>
{{ tag.name }}
</td>
</tr>
<tr>
<td>Slug</td>
<td>
{{ tag.slug }}
</td>
</tr>
<tr>
<td>Tagged Items</td>
<td>
{{ items_count }}
</td>
</tr>
</table>
</div>
</div>
<div class="col-md-6">
{% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
</div>
</div>
{% endblock %}

View File

@@ -4,8 +4,11 @@
{% block content %}
<h1>{% block title %}Tags{% endblock %}</h1>
<div class="row">
<div class="col-md-12">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='extras:tag_bulk_delete' %}
</div>
<div class="col-md-3">
{% include 'inc/search_panel.html' %}
</div>
</div>
{% endblock %}

View File

@@ -87,7 +87,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if tenant.comments %}
{{ tenant.comments|gfm }}
{% else %}

View File

@@ -10,8 +10,12 @@
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
<div class="panel-heading">
<div class="pull-right">
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% if perms.users.change_token %}
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
{% endif %}
{% if perms.users.delete_token %}
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% endif %}
</div>
<i class="fa fa-key"></i> {{ token.key }}
{% if token.is_expired %}
@@ -49,10 +53,16 @@
{% empty %}
<p>You do not have any API tokens.</p>
{% endfor %}
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a token
</a>
{% if perms.users.add_token %}
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a token
</a>
{% else %}
<div class="alert alert-info text-center" role="alert">
You do not have permission to create new API tokens. If needed, ask an administrator to enable token creation for your account or an assigned group.
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -99,7 +99,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if cluster.comments %}
{{ cluster.comments|gfm }}
{% else %}

View File

@@ -11,6 +11,7 @@
{% render_field form.mtu %}
{% render_field form.description %}
{% render_field form.mode %}
{% render_field form.tags %}
</div>
</div>
{% if obj.mode %}

View File

@@ -143,7 +143,7 @@
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body">
<div class="panel-body rendered-markdown">
{% if virtualmachine.comments %}
{{ virtualmachine.comments|gfm }}
{% else %}
@@ -315,9 +315,9 @@
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddress').hide();
$('#interfaces_table tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddress').show();
$('#interfaces_table tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');

View File

@@ -48,6 +48,12 @@
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Local Config Context Data</strong></div>
<div class="panel-body">
{% render_field form.local_context_data %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">

View File

@@ -30,6 +30,6 @@ class TenantGroupViewSet(ModelViewSet):
#
class TenantViewSet(CustomFieldModelViewSet):
queryset = Tenant.objects.select_related('group')
queryset = Tenant.objects.select_related('group').prefetch_related('tags')
serializer_class = serializers.TenantSerializer
filter_class = filters.TenantFilter

View File

@@ -4,7 +4,7 @@ import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from utilities.filters import NumericInFilter
from utilities.filters import NumericInFilter, TagFilter
from .models import Tenant, TenantGroup
@@ -31,9 +31,7 @@ class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group (slug)',
)
tag = django_filters.CharFilter(
name='tags__slug',
)
tag = TagFilter()
class Meta:
model = Tenant

View File

@@ -31,6 +31,16 @@ class TenantGroupTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_tenantgroups_brief(self):
url = reverse('tenancy-api:tenantgroup-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_tenantgroup(self):
data = {
@@ -124,6 +134,16 @@ class TenantTest(APITestCase):
self.assertEqual(response.data['count'], 3)
def test_list_tenants_brief(self):
url = reverse('tenancy-api:tenant-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
)
def test_create_tenant(self):
data = {

View File

@@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms
from django.contrib import admin
from netbox.admin import admin_site
from .models import Token
@@ -14,7 +15,7 @@ class TokenAdminForm(forms.ModelForm):
model = Token
@admin.register(Token)
@admin.register(Token, site=admin_site)
class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm
list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description']

View File

@@ -0,0 +1,17 @@
# Generated by Django 2.0.8 on 2018-10-05 14:32
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('users', '0001_api_tokens_squashed_0002_unicode_literals'),
]
operations = [
migrations.AlterModelOptions(
name='token',
options={},
),
]

View File

@@ -43,7 +43,7 @@ class Token(models.Model):
)
class Meta:
default_permissions = []
pass
def __str__(self):
# Only display the last 24 bits of the token to avoid accidental exposure.

View File

@@ -3,8 +3,8 @@ from __future__ import unicode_literals
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponseRedirect
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.http import HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
@@ -231,8 +231,12 @@ class TokenEditView(LoginRequiredMixin, View):
def get(self, request, pk=None):
if pk is not None:
if not request.user.has_perm('users.change_token'):
return HttpResponseForbidden()
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
else:
if not request.user.has_perm('users.add_token'):
return HttpResponseForbidden()
token = Token(user=request.user)
form = TokenForm(instance=token)
@@ -274,7 +278,8 @@ class TokenEditView(LoginRequiredMixin, View):
})
class TokenDeleteView(LoginRequiredMixin, View):
class TokenDeleteView(PermissionRequiredMixin, View):
permission_required = 'users.delete_token'
def get(self, request, pk):

View File

@@ -108,10 +108,9 @@ class TimeZoneField(Field):
def to_internal_value(self, data):
if not data:
return ""
try:
return pytz.timezone(str(data))
except pytz.exceptions.UnknownTimeZoneError:
raise ValidationError('Invalid time zone "{}"'.format(data))
if data not in pytz.common_timezones:
raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
return pytz.timezone(data)
class SerializedPKRelatedField(PrimaryKeyRelatedField):
@@ -170,7 +169,9 @@ class WritableNestedSerializer(ModelSerializer):
if data is None:
return None
try:
return self.Meta.model.objects.get(pk=data)
return self.Meta.model.objects.get(pk=int(data))
except (TypeError, ValueError):
raise ValidationError("Primary key must be an integer")
except ObjectDoesNotExist:
raise ValidationError("Invalid ID")
@@ -191,6 +192,19 @@ class ModelViewSet(_ModelViewSet):
return super(ModelViewSet, self).get_serializer(*args, **kwargs)
def get_serializer_class(self):
# If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one
# exists
request = self.get_serializer_context()['request']
if request.query_params.get('brief', False):
serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested')
if serializer_class is not None:
return serializer_class
# Fall back to the hard-coded serializer class
return self.serializer_class
class FieldChoicesViewSet(ViewSet):
"""

Some files were not shown because too many files have changed in this diff Show More