Compare commits

...

457 Commits

Author SHA1 Message Date
Jeremy Stretch
b8dd379cef Merge pull request #3518 from netbox-community/develop
Release v2.6.4
2019-09-19 09:34:49 -04:00
Jeremy Stretch
be2417d808 Release v2.6.4 2019-09-19 09:30:16 -04:00
Jeremy Stretch
d4b263dd0c Fixes #3439: Add a note about the Swagger API interface 2019-09-18 16:41:08 -04:00
Jeremy Stretch
37aaf6f4ab Fixes #3429: Update installation docs for Redis 2019-09-18 16:35:45 -04:00
Jeremy Stretch
51fb0b59ec Closes #3485: Enable embedded graphs for devices 2019-09-18 15:59:52 -04:00
Jeremy Stretch
a0545568cd Fixes #3514: Label TextVar fields when rendering custom script forms 2019-09-18 15:39:26 -04:00
Jeremy Stretch
84208d5429 Fixes #3511: Correct API URL for nested device bays 2019-09-18 14:40:47 -04:00
Jeremy Stretch
7264a4ffb6 Fixes #3513: Fix assignment of tags when creating front/rear ports 2019-09-18 14:33:47 -04:00
Daniel Sheppard
e8ee6f1bc5 Clean up extra line that snuck in 2019-09-17 15:45:55 -05:00
Jeremy Stretch
a742d897d7 Closes #3510: Add minimum/maximum prefix length enforcement for IPNetworkVar 2019-09-17 16:36:36 -04:00
Daniel Sheppard
0601619f50 Merge branch 'develop' of https://github.com/netbox-community/netbox into develop 2019-09-13 12:09:02 -05:00
Daniel Sheppard
1a1f6aff7b Closes: #3495 2019-09-13 12:08:48 -05:00
Jeremy Stretch
5962e7c942 Fixes #3501: Fix rendering of checkboxes on custom script forms 2019-09-13 11:45:35 -04:00
Daniel Sheppard
57d35181f0 Fix performance issues when creating/editing interfaces due to unfiltered vlan queryset 2019-09-12 11:13:40 -05:00
Daniel Sheppard
73065fa6e7 Using static element to determine brief parameter, corrected to $(element) 2019-09-11 10:10:43 -05:00
Jeremy Stretch
a8ca536d44 Bump platform name/slug max length to 100 chars (#3318) 2019-09-10 15:50:41 -04:00
Jeremy Stretch
062c65fd67 Changelog cleanup 2019-09-10 15:45:54 -04:00
Jeremy Stretch
f533530693 Moved related projects list to the wiki 2019-09-10 15:30:19 -04:00
Jeremy Stretch
355910e182 Fixes #3489: Prevent exception triggered by webhook upon object deletion 2019-09-09 15:50:10 -04:00
Daniel Sheppard
e67d4fb2e5 Update Changelog with Future Changes 2019-09-06 13:32:21 -05:00
Daniel Sheppard
050f2478d3 Fixes: #3318 - Increases length of platform name and slug to 64 characters (#3353) 2019-09-06 13:01:27 -05:00
Daniel Sheppard
9c6dbd7337 Add in in-line vlan editing and Bulk vlan editing (#3350)
* Fixes #3341 - Added in-line vlan editing
* Fixes #2160 - Added bulk vlan editing

Inconsequential behaviour changes:

* APISelect can now take "full=True" to return a non-brief set
* Select2 will no group by "group & site, group, site, global" if full=True is set in APISelect
2019-09-06 12:45:37 -05:00
Daniel Sheppard
8f5e73a598 Add filter for has local context data (#3159)
* Add filter for has local context data
* Broke out filter and form for re-use
* Fix missing StaticSelect2 import
* Fix missing BOOLEAN_WITH_BLANK_CHOICES import
* Fix class resolution
* Fix field ordering
* Fix PEP8 errors
2019-09-06 11:42:56 -05:00
Jeremy Stretch
2ce0ff505a Post-release version bump 2019-09-04 16:28:58 -04:00
Jeremy Stretch
a4d8b92cb1 Merge pull request #3478 from netbox-community/develop
Release v2.6.3
2019-09-04 16:27:32 -04:00
Jeremy Stretch
143a158e4a Release v2.6.3 2019-09-04 16:23:06 -04:00
Jeremy Stretch
4213454234 Tweak docs for custom scripts 2019-09-04 16:21:21 -04:00
Jeremy Stretch
559beffd24 Add documentation for custom links 2019-08-28 12:39:11 -04:00
Jeremy Stretch
5f4bac6076 Closes #3454: Enable filtering circuits by region 2019-08-28 12:12:27 -04:00
Jeremy Stretch
8ff3d2cbf6 Closes #3456: Enable bulk editing of tag color 2019-08-28 11:56:00 -04:00
Jeremy Stretch
273a9793db Fix typo 2019-08-28 10:51:56 -04:00
Jeremy Stretch
5a911aa5a1 Fixes #3392: Add database index for ObjectChange time 2019-08-28 10:48:19 -04:00
Jeremy Stretch
3078e366e2 Simplify changelog cleanup logic 2019-08-28 10:44:05 -04:00
Jeremy Stretch
22b8a45a71 Add tests for changelog 2019-08-28 10:18:37 -04:00
Jeremy Stretch
4756353fbd Merge pull request #3453 from netbox-community/3452-change-logging
Fixes #3452: Queue deletion ObjectChanges until after response is sent
2019-08-28 09:51:54 -04:00
Jeremy Stretch
3e8799b5c7 Fix script form rendering 2019-08-28 09:20:19 -04:00
Jeremy Stretch
a3d9e633c1 Always include 'commit' option 2019-08-26 17:04:04 -04:00
Jeremy Stretch
6e66f8d68a Fixes #3452: Queue deletion ObjectChanges until after response is sent 2019-08-26 16:52:05 -04:00
Jeremy Stretch
03ac2721bc Merge pull request #3423 from netbox-community/3415-custom-scripts
Add custom scripting
2019-08-26 14:06:22 -04:00
Jeremy Stretch
456621695a Wrap script form inside a panel 2019-08-26 13:53:30 -04:00
Jeremy Stretch
9a9660a765 Fix errant changelog entries when executing a script without committing 2019-08-26 11:59:38 -04:00
Jeremy Stretch
6568653d13 Fix typo in link 2019-08-22 10:51:29 -04:00
Jeremy Stretch
5248fc7c7c Merge pull request #3434 from netbox-community/3428-cache-invalidation
Cache invalidation
2019-08-22 10:49:51 -04:00
Jeremy Stretch
6a8f256a56 Fix typo 2019-08-21 15:46:06 -04:00
John Anderson
46bedc6156 missed a merge conflict resolution 2019-08-21 14:30:07 -04:00
John Anderson
63c3f423c2 Merge branch 'develop' into 3428-cache-invalidation 2019-08-20 17:35:54 -04:00
John Anderson
3d2a738f44 #3428 changelog 2019-08-20 17:27:40 -04:00
John Anderson
f0f1ef2ef2 fix signals update call 2019-08-20 17:20:46 -04:00
John Anderson
c359ac5737 convert update() calls to save() calls 2019-08-20 17:16:00 -04:00
Jeremy Stretch
a4936ad0dd Introduce BaseScript for extending Script without creating a new executable script 2019-08-19 14:40:08 -04:00
Jeremy Stretch
a02ded6b01 Import Django User model automatically when running nbshell 2019-08-19 11:47:50 -04:00
Jeremy Stretch
2d2bb3ec0c Fixes #3421: Fix exception when ordering power connections list by PDU 2019-08-19 11:27:36 -04:00
Jeremy Stretch
eb6e95ae9b Add tests for Script Variables 2019-08-19 10:41:44 -04:00
Jeremy Stretch
f56a0aebdb Closes #3430: Linkify platform field on device view 2019-08-19 09:50:41 -04:00
John Anderson
c54f2e3e40 remove blank line after update call 2019-08-19 02:11:54 -04:00
John Anderson
ade844f7a7 fixes #3428 - caching invalidation issues
Mitgate invalidation issues by using prefetch_related instead of select_related.
Also use invalidated_update instead of just update.
2019-08-19 01:53:39 -04:00
Jeremy Stretch
2929621651 Updated docs for IPNetworkVar and FileVar 2019-08-16 15:31:29 -04:00
Jeremy Stretch
de770faf6a Add FileVar for file uploads 2019-08-16 15:27:58 -04:00
Jeremy Stretch
99394de14e Change fields to field_order 2019-08-15 16:19:25 -04:00
Jeremy Stretch
305d330391 Docs updates 2019-08-15 16:07:15 -04:00
Jeremy Stretch
9c41984f6d Changelog for #3426 2019-08-15 15:31:05 -04:00
Jeremy Stretch
aa858fea03 Merge pull request #3427 from candlerb/candlerb/3426
Improve API error handling when a list is given as a choice value
2019-08-15 15:29:47 -04:00
Brian Candler
6e5d527fec Improve API error handling when a list is given as a choice value
Fixes #3426
2019-08-15 17:16:24 +01:00
Jeremy Stretch
b11d3dde46 Closes #3391: Update Bootstrap CSS to v3.4.1 2019-08-15 11:47:57 -04:00
Jeremy Stretch
3df8ccb92f Fixes #3424: Fix tag coloring for non-linked tags 2019-08-15 11:12:52 -04:00
Jeremy Stretch
0b95cab47b Closes #3386: Add mac_address filter for virtual machines 2019-08-15 11:02:40 -04:00
Jeremy Stretch
cb0dbc0769 Add TextVar for large text entry 2019-08-14 16:20:52 -04:00
Jeremy Stretch
47d60dbb20 Fix table column widths 2019-08-14 15:46:08 -04:00
Jeremy Stretch
f8326ef6df Add markdown rendering for log mesages 2019-08-14 14:38:11 -04:00
Jeremy Stretch
434e656e27 Include stack trace when catching an exception 2019-08-14 14:26:13 -04:00
Jeremy Stretch
8bd1fad7d0 Use TreeNodeChoiceField for MPTT objects 2019-08-14 14:03:11 -04:00
Jeremy Stretch
7f65e009a8 Add convenience functions for loading YAML/JSON data from file 2019-08-14 13:08:21 -04:00
Jeremy Stretch
11e5e1c490 Show script log when an exception occurs 2019-08-14 12:19:36 -04:00
Jeremy Stretch
9c079ead4c Fix notice when form does not require user input 2019-08-14 10:18:25 -04:00
Jeremy Stretch
c562af3a13 Record script execution time 2019-08-14 10:12:30 -04:00
Jeremy Stretch
30e14db881 Tweak form display (cosmetic) 2019-08-14 09:40:23 -04:00
Jeremy Stretch
dab30f50d3 Add IPNetworkVar 2019-08-13 09:48:51 -04:00
Jeremy Stretch
3d6a583ce4 Allow user to override module name 2019-08-13 09:09:12 -04:00
Jeremy Stretch
44fd0ebb2d Meta.fields should be optional 2019-08-12 16:59:09 -04:00
Jeremy Stretch
0d289d660d Add option to commit database changes 2019-08-12 14:28:06 -04:00
Jeremy Stretch
3e75da4307 Implemented run_script() wrapper 2019-08-12 13:51:25 -04:00
Jeremy Stretch
19eb4c510c Move script attributes under a Meta class 2019-08-12 13:16:18 -04:00
Jeremy Stretch
dd4dafa7be Closes #3420: Serial number filter for racks, devices, and inventory items is now case-insensitive 2019-08-12 12:10:36 -04:00
Jeremy Stretch
116c395948 Fixes #3422: Prevent navigation menu from overlapping page content 2019-08-12 11:57:48 -04:00
Jeremy Stretch
ab504439fb Implemented permissions for scripts 2019-08-12 11:39:36 -04:00
Jeremy Stretch
463c636301 Extend example custom script to generate output 2019-08-12 11:13:16 -04:00
Jeremy Stretch
950a09895b BooleanVar cannot be required 2019-08-12 11:13:16 -04:00
Jeremy Stretch
c62a9f1b3f Example script tweak 2019-08-12 11:13:16 -04:00
Jeremy Stretch
3f7f3f88f3 Fix form field ordering 2019-08-12 11:13:16 -04:00
Jeremy Stretch
4fc19742ec Added documentation for custom scripts 2019-08-12 11:13:16 -04:00
Jeremy Stretch
9d054fb345 Add options for script vars; include script output 2019-08-12 11:13:16 -04:00
Jeremy Stretch
a25a27f31f Initial work on custom scripts (#3415) 2019-08-12 11:13:16 -04:00
Jeremy Stretch
f18c3be745 Merge pull request #3412 from netbox-community/3405-bugfix
Move device component creation logic out of Device model
2019-08-08 21:16:11 -04:00
Jeremy Stretch
0516aecb03 Changelog for #3405 2019-08-07 17:49:54 -04:00
Jeremy Stretch
605be30fb2 Add test for device component creation 2019-08-07 17:48:12 -04:00
Jeremy Stretch
86cd044a68 Fixes #3405: Move device component creation logic into template models 2019-08-07 17:47:44 -04:00
Jeremy Stretch
068a0e2257 Removed invalid contact email 2019-08-02 15:02:29 -04:00
Jeremy Stretch
3a2fc43542 Post-release version bump 2019-08-02 10:31:56 -04:00
Jeremy Stretch
7fc60cd667 Merge pull request #3387 from netbox-community/develop
Release v2.6.2
2019-08-02 10:31:34 -04:00
Jeremy Stretch
c90baaa807 Release v2.6.2 2019-08-02 10:29:10 -04:00
Jeremy Stretch
ea9492d4bd Fixes #3384: Maximum and allocated draw fields should be included on power port template creation form 2019-08-02 09:56:02 -04:00
Jeremy Stretch
025f77dcdc Fixes #3385: Fix power panels list when bulk editing power feeds 2019-08-02 09:43:46 -04:00
Jeremy Stretch
eb19b1a39e Changelog for #3367 2019-08-02 09:13:48 -04:00
Jeremy Stretch
25748efd54 Merge pull request #3383 from ragzilla/develop
Closes #3367: Add BNC Front/Rear port types and Coaxial cable type.
2019-08-02 09:10:40 -04:00
Matt Addison
2215a095c8 Closes #3367: Add BNC Front/Rear port types and Coaxial cable type. 2019-08-01 10:33:29 -04:00
Jeremy Stretch
a32d185ff0 Fixes #3018: Components connected via a cable must have an equal number of positions 2019-07-31 10:12:51 -04:00
Jeremy Stretch
ea32853ab3 Fixes #3289: Prevent position from being nullified when moving a device to a new rack 2019-07-30 17:07:58 -04:00
Jeremy Stretch
a6c41e0be5 Changelog for #3368 2019-07-30 16:26:52 -04:00
Jeremy Stretch
f223f9b9c1 Merge pull request #3369 from jlrgraham23/fix-changelog-wording
Indicate when changelog retention configured to be forever.
2019-07-30 16:20:39 -04:00
Daniel Sheppard
bcc7daeac7 Fixes #3370 - Add filter class to VirtualChassis API 2019-07-24 12:22:15 -05:00
Justin L R Graham
890ba3ea94 Indicate when changelog retention configured to be forever. 2019-07-23 13:46:55 -05:00
Jeremy Stretch
cab3c50ae6 Closes #3314: Paginate object changelog entries 2019-07-18 21:40:36 -04:00
Jeremy Stretch
86b6b9bf8b Fixes #3315: Enable filtering devices/interfaces by multiple MAC addresses 2019-07-18 21:21:56 -04:00
Jeremy Stretch
71551893b1 Fixes #3293: Enable filtering device components by multiple device IDs 2019-07-18 20:42:15 -04:00
Jeremy Stretch
0431b296c4 Merge pull request #3325 from lassebm/fix-3324
Fixes #3324: Doc incorrectly states child devices shown as non-racked
2019-07-17 16:27:55 -04:00
Jeremy Stretch
376eae748c Changelog for #3323 2019-07-17 16:25:19 -04:00
Jeremy Stretch
f2a45c5892 Merge pull request #3326 from lassebm/fix-3323
Fixes #3323: Interface Connections view inaccessible with "dcim.view_interface" permission
2019-07-17 16:24:14 -04:00
Jeremy Stretch
88b176ae15 Changelog for #3307 2019-07-17 16:22:30 -04:00
Jeremy Stretch
f1744ef4db Merge pull request #3308 from mmahacek/powerpanel-count
Add Powerpanel count to home page
2019-07-17 16:20:27 -04:00
Jeremy Stretch
892ff0c1ca Changelog for #3342 2019-07-16 11:31:36 -04:00
Jeremy Stretch
647163e2b2 Merge pull request #3337 from robellegate/fix/docs_digitalocean_repo_links
Replacing references to digitalocean org
2019-07-16 10:18:37 -04:00
Jeremy Stretch
8e043b00b8 Closes #3330: Remove .pyc file cleanup step from upgrade script 2019-07-16 10:11:39 -04:00
Jeremy Stretch
154b9e1faf Fixes #3342: Fix cluster delete button permissions reference 2019-07-16 10:07:38 -04:00
Robert Ellegate
30ef4b208c Replacing references to digitalocean org
s/(?<=:\/\/github.com\/)digitalocean(?=\/netbox)/netbox-community/g
2019-07-10 09:23:43 -04:00
Lasse Bang Mikkelsen
6276f5f7b9 Fixes #3323: Interface Connections view inaccessible with "dcim.view_interface" permission 2019-07-04 17:37:28 +02:00
Lasse Bang Mikkelsen
118ec358c0 Fixes #3324: Doc incorrectly states child devices shown as non-racked 2019-07-04 17:28:25 +02:00
Jeremy Stretch
3da9af5a9f Fixes #3317: Fix permissions for ConfigContextBulkDeleteView 2019-07-02 09:39:26 -04:00
mmahacek
ddced4fc2b Add stats.powerpanel_count 2019-06-28 17:04:42 -07:00
mmahacek
7a41b02fdd Add line for PowerPanel count 2019-06-28 17:03:06 -07:00
Jeremy Stretch
6c3c6fba62 Closes #984: Allow ordering circuits by A/Z side 2019-06-27 12:30:17 -04:00
Jeremy Stretch
2bb9464905 Merge pull request #3296 from michaelxniu/patch-1
Create NOTICE file
2019-06-25 14:10:14 -04:00
Michael Niu
821924f57f Create NOTICE file 2019-06-25 13:59:00 -04:00
Jeremy Stretch
74f14c5535 Post-release version bump 2019-06-25 09:44:00 -04:00
Jeremy Stretch
80c8c4c4b2 Merge pull request #3295 from digitalocean/develop
Release v2.6.1
2019-06-25 09:41:29 -04:00
Jeremy Stretch
d219c3ea88 Release v2.6.1 2019-06-25 09:39:30 -04:00
Jeremy Stretch
954ba91c86 Closes #3154: Add virtual_chassis_member device filter 2019-06-24 16:31:21 -04:00
Jeremy Stretch
7effb7e8d4 Fix for #3229 2019-06-24 15:48:49 -04:00
Jeremy Stretch
3fdb655a92 Fixes #3269: Raise validation error when specifying non-existent cable terminationss 2019-06-24 15:42:15 -04:00
Jeremy Stretch
cf770bf40c Closes #3277: Add cable trace buttons for console and power ports 2019-06-24 14:27:34 -04:00
Jeremy Stretch
9f50ced6fc Changelog for #3229 2019-06-24 14:22:03 -04:00
Jeremy Stretch
4dd97eab0c Merge pull request #3232 from hellerve/fix-3229
Filter group by site in rack filter
2019-06-24 14:19:50 -04:00
Jeremy Stretch
5de242fe53 Closes #3281: Hide custom links which render as empty text 2019-06-24 12:20:09 -04:00
Jeremy Stretch
251ba08e09 Fixes #3283: Fix rack group assignment on PowerFeed CSV import 2019-06-24 11:10:35 -04:00
Jeremy Stretch
653770ede9 Fixes #3292: Ignore empty URL query parameters 2019-06-24 11:00:18 -04:00
Jeremy Stretch
70ef6a69ee Fixes #3290: Fix server error when viewing cascaded PDUs 2019-06-24 10:05:21 -04:00
Jeremy Stretch
5a6c928a7c Fixes #3279: Reset the PostgreSQL sequence for Tag and TaggedItem IDs 2019-06-21 17:34:06 -04:00
John Anderson
bd9ef9951b Merge pull request #3284 from cimnine/fix_pwd_protected_redis_cache
Fixes Cacheops with a password protected redis
2019-06-21 16:35:50 -04:00
Jeremy Stretch
c067549f21 Fixes #3275: Fix error when adding power outlets to a device type 2019-06-21 16:24:12 -04:00
Christian Mäder
94ca3abefc Fixes Cacheops with a password protected redis
As per the [`README.rst`][1] of `django-cacheops`, if a password is
added to the connection string, it must be in the form
`redis://:password@host:port/db`. Notice the colon, which was missing
from the implementation in [`settings.py`][2].

[1]: 8ad970d55a/README.rst
[2]: 86d5b48007/netbox/netbox/settings.py (L349)
2019-06-21 22:23:10 +02:00
Jeremy Stretch
86d5b48007 Post-release version bump 2019-06-20 17:01:21 -04:00
Jeremy Stretch
2b7e8c4b9f Merge pull request #3271 from digitalocean/develop
Release v2.6.0
2019-06-20 16:59:31 -04:00
Jeremy Stretch
f888d50a15 Release v2.6.0 2019-06-20 16:55:43 -04:00
Jeremy Stretch
dfcd2c247d Closes #3264: Annotate changelog retention time on UI 2019-06-20 14:05:53 -04:00
Jeremy Stretch
1c54d7ed55 Hide color block if cable color is not defined 2019-06-20 14:00:45 -04:00
Jeremy Stretch
7cb618df5e Fixes #3176: Add cable trace button for console server ports and power outlets 2019-06-20 13:58:32 -04:00
Jeremy Stretch
6778d1398f Added changelog noting #3239 has been fixed 2019-06-20 13:26:15 -04:00
Jeremy Stretch
1dd3e9dab1 Merge pull request #3270 from digitalocean/develop-2.6
Merge develop-2.6
2019-06-20 13:20:42 -04:00
Jeremy Stretch
99f6b3a3bd Merged develop 2019-06-20 13:10:45 -04:00
Jeremy Stretch
b626387b38 Bumped dependencies to latest releases 2019-06-20 10:06:39 -04:00
Jeremy Stretch
dd554ee7b5 Updated django-cors-headers to v3.0.2 2019-06-20 09:48:34 -04:00
Jeremy Stretch
fe28e5befe Cleaned up changelog 2019-06-20 09:31:01 -04:00
Jeremy Stretch
21856c6f0c Merge pull request #3268 from digitalocean/power-utilization-tweaks
Cleaned up logic for calculating power draw
2019-06-20 09:09:06 -04:00
John Anderson
b745401817 prometheus docs updates 2019-06-20 00:04:44 -04:00
John Anderson
625a09785a minor tweaks to error handling to allow for defaulted values from pre v2.6 data 2019-06-19 23:47:48 -04:00
Jeremy Stretch
1a5aaf54dd Cleaned up logic for calculating power draw 2019-06-19 10:51:53 -04:00
John Anderson
ce9f7a8ddc closes #3161 - prometheus env var docs and set prometheus to not be enabled by default 2019-06-17 16:38:05 -04:00
Jeremy Stretch
9747cae773 Fix power outlet connection link 2019-06-17 16:32:21 -04:00
Jeremy Stretch
948286aa32 Fixes #3258: Exception raised when creating/viewing a circuit with a non-connected termination 2019-06-17 16:27:26 -04:00
Jeremy Stretch
8da660f5da Fixes #3231: Fixed cosmetic error indicating a missing schema migration 2019-06-17 15:30:23 -04:00
Jeremy Stretch
b884341d73 Update migration for tweak to available_power 2019-06-17 15:23:37 -04:00
Jeremy Stretch
c9afe96324 Fix power utilization calculation for three-phase feeds 2019-06-17 15:16:52 -04:00
Jeremy Stretch
d529ebc172 Rename power_factor to max_utilization 2019-06-17 14:52:11 -04:00
Jeremy Stretch
d25dd52ec9 Compressed migrations 2019-06-17 14:41:40 -04:00
Jeremy Stretch
97a5f3ea0e Merge pull request #3262 from digitalocean/feature/524-power-utilization-graphs
#524 - Added power utilization graphs to power feeds, devices, and racks
2019-06-17 14:38:12 -04:00
John Anderson
2eeffce924 #524 - Added power utilization graphs to power feeds, devices, and racks 2019-06-16 06:11:32 -04:00
Jeremy Stretch
438b01815a Fix test for tags view 2019-06-05 15:05:35 -04:00
Jeremy Stretch
8b3ec625f6 Add missing PermissionRequiredMixin to TagListView 2019-06-05 14:47:43 -04:00
Jeremy Stretch
50cbfb9360 Fixed merge conflict in requirements.txt 2019-06-05 14:47:20 -04:00
Jeremy Stretch
90f7122fde Closes #3241: Correct terminology in inventory items table header 2019-06-05 12:10:33 -04:00
hellerve
e89343e100 dcim: filter group by site in rack filter (fixes #3229) 2019-06-02 14:26:28 +02:00
Jeremy Stretch
3bb3b85fa2 Merged v2.5.13 2019-05-31 21:37:41 -04:00
Jeremy Stretch
f9dba8a75c Post-release version bump 2019-05-31 09:56:50 -04:00
Jeremy Stretch
1a97a1c9d2 Merge pull request #3230 from digitalocean/develop
Release v2.5.13
2019-05-31 09:54:46 -04:00
Jeremy Stretch
893e327ac6 Release v2.5.13 2019-05-31 09:49:53 -04:00
dansheps
814c50f461 Fix #3228 - UrlEncode full path for next if not on logon page
Include the full path for the ?next= variable in login links if we are not on the logon page.
Additionally include next for post requests that have the next variable set (will only come from the login page itself generally)
2019-05-30 12:01:41 -05:00
dansheps
1958c0b118 Merge remote-tracking branch 'origin/develop' into develop
# Conflicts:
#	netbox/utilities/middleware.py
2019-05-30 10:59:25 -05:00
dansheps
a11b33d214 Fix #3228 - Send full path info instead of just path info and urlencode said path info 2019-05-30 10:58:39 -05:00
dansheps
7d053f8ba4 Fix #3228 - Send full path info instead of just path info and urlencode said path info 2019-05-30 10:54:29 -05:00
Jeremy Stretch
1e1aba73ef Remove request.user assertion from ObjectChangeMiddleware 2019-05-30 10:32:09 -04:00
Jeremy Stretch
b9b009c0b5 Fixes #3227: Fix exception when deleting a circuit with a termination(s) 2019-05-29 17:17:06 -04:00
Jeremy Stretch
a6ff6505c6 Closes #3151: Add inventory item count to manufacturers list 2019-05-29 15:20:36 -04:00
Jeremy Stretch
823257ca72 Closes #3185: Improve performance for custom field access within templates 2019-05-29 15:04:57 -04:00
Jeremy Stretch
0804c1acbd Fixed test from #3211 follow-up work 2019-05-29 10:51:49 -04:00
Jeremy Stretch
28facca291 Changelog & grammar tweak for #3211 2019-05-29 10:33:29 -04:00
Jeremy Stretch
a7ca49c44d Merge pull request #3222 from hellerve/tmp
Fix error message on trying to delete protected models
2019-05-29 10:24:28 -04:00
Jeremy Stretch
5639fc9791 Merge pull request #3195 from TakeMeNL/feature/3156
Closes #3156: Add site link to rack reservations overview
2019-05-29 10:19:10 -04:00
Jeremy Stretch
8b00513175 Changelog for #3031 2019-05-29 10:10:07 -04:00
Jeremy Stretch
00ffa358b2 Merge pull request #3197 from KhaledTo/bug/3031
Fixes #3031: Select2 creates multiple tags for tags with spaces
2019-05-29 10:08:59 -04:00
TakeMeNL
1ff7e1149c Closes #3156: Add site link to rack reservations overview 2019-05-29 16:08:24 +02:00
hellerve
2c7bad9fff utilities: move protectederror handling to modelviewset 2019-05-28 21:11:23 +02:00
Jeremy Stretch
c4f481d705 Bump DRF to 3.9.1 to address WS-2019-0037 2019-05-28 14:21:36 -04:00
Jeremy Stretch
99a3a216c3 Merge pull request #3216 from hellerve/fix-3168
Fix 3168: Update to new path syntax
2019-05-28 13:16:08 -04:00
Jeremy Stretch
87f5dd05f5 Fixes #3223: Fix filtering devices by "has power outlets" 2019-05-28 13:10:54 -04:00
hellerve
cc87d99017 all: fix error message on trying to delete protected models (references #3211) 2019-05-28 17:31:02 +02:00
hellerve
1366730a3f netbox urls: move to re_path as suggested by @jeremystretch 2019-05-27 22:41:10 +02:00
Jeremy Stretch
ab07f721cf Changelog for #3204 & #3207 2019-05-27 15:40:17 -04:00
Jeremy Stretch
3c83a18291 Merge pull request #3205 from ananace/fix-interface-connection
Fixes #3204: Fix connecting cables to interfaces
2019-05-27 15:38:36 -04:00
Jeremy Stretch
8bbb3031e5 Merge pull request #3207 from ananace/fix-connecting-rear
Fixes #3206: Unable to connect to rear ports
2019-05-27 15:31:49 -04:00
Jeremy Stretch
473dafc2c8 Changelog for #3184 2019-05-27 15:03:00 -04:00
Jeremy Stretch
38d5a8fdc0 Merge pull request #3199 from candlerb/candlerb/3184
Add grey border around color-block
2019-05-27 15:00:48 -04:00
hellerve
b114b9d396 utilities: add converters module and use for json/yaml url 2019-05-26 14:56:01 +02:00
hellerve
f9cd89a4a4 urls: fix 3168 by changing url to path 2019-05-26 14:56:00 +02:00
Alexander Olofsson
7ba9675f02 Fixes #3206: Unable to connect to rear ports 2019-05-22 19:58:26 +02:00
Alexander Olofsson
d0e7b052a8 Fixes #3204: Fix connecting cables to interfaces 2019-05-22 19:00:13 +02:00
Brian Candler
4313a717c4 Add grey border around color-block
Fixes #3184
2019-05-20 21:06:53 +01:00
Khaled BEN ABDALLAH
cbace6f831 Fixes #3031: Select2 creates multiple tags for tags with spaces 2019-05-18 22:43:47 +02:00
Jeremy Stretch
edabc8eee9 Closes #3138: Add 2.5GE and 5GE interface form factors 2019-05-16 20:49:00 -04:00
Jeremy Stretch
9b47e57e8e Closes #3183: Enable bulk deletion of sites 2019-05-16 20:24:55 -04:00
Jeremy Stretch
2f32488c25 Fixes #3190: Fix custom field rendering for Jinja2 export templates 2019-05-16 19:45:36 -04:00
Jeremy Stretch
62d497dd0b Closes #3186: Add interface name filter for IP addresses 2019-05-14 19:03:03 -04:00
Jeremy Stretch
e19feb92ea Move TenancyFilterForm to tenancy.forms 2019-05-09 14:36:18 -04:00
Jeremy Stretch
fbde6282b2 Cleanup from #2931 2019-05-09 14:32:49 -04:00
Jeremy Stretch
7895ccfae1 Merge pull request #2931 from DanSheps/2813-addtenantgroupfilter
Closes #2813: Add Filter and View on Lists for TenantGroup
2019-05-09 13:48:46 -04:00
Jeremy Stretch
c24fb8df84 Merge pull request #3165 from digitalocean/3038-filtering-improvements
Closes #3038: Filtering improvements
2019-05-08 21:13:42 -04:00
Jeremy Stretch
53b5fed8ae Tweak docs to indicate deprecation of id__in filter 2019-05-08 21:10:49 -04:00
Jeremy Stretch
dfffd1ea94 Restore id__in filters to retain backward compatability until v2.7 2019-05-08 21:08:35 -04:00
Jeremy Stretch
ffa34c6133 Updated documentation 2019-05-07 14:23:02 -04:00
Jeremy Stretch
8e8c9822ea Correct Device.position filter 2019-05-07 14:12:56 -04:00
Jeremy Stretch
205adeb2e9 Remove NullableCharFieldFilter; add missing filter fields 2019-05-07 13:59:21 -04:00
Jeremy Stretch
3d616baf75 Remove NumericInFilter and id__in filters 2019-05-07 13:07:18 -04:00
Jeremy Stretch
6cb5173e27 Update query filters to OR multiple values 2019-05-07 12:25:33 -04:00
Daniel Sheppard
dfd4a712c9 Merge pull request #3158 from tb-killa/3150
Fixes: #3150- Formatting of cable length in cable trace
2019-05-06 11:54:28 -05:00
Daniel Sheppard
b97339017b Update CHANGELOG.md 2019-05-06 11:54:16 -05:00
Oli
1b862045e3 Formatting of cable length in cable trace 2019-05-06 15:36:44 +02:00
Jeremy Stretch
244c07e5f7 Closes #3085: Catch all exceptions during export template rendering 2019-05-02 15:36:51 -04:00
Jeremy Stretch
eb41bc66a4 Merge pull request #3142 from austin987/upgrade-cwd
upgrade.sh: make sure we are in the right directory
2019-05-02 14:51:37 -04:00
Austin English
01c5d9e909 upgrade.sh: make sure we are in the right directory 2019-05-02 13:02:53 -05:00
Jeremy Stretch
a8c57313d3 Fixes #3140: Add bulk edit capability for power outlets and console server ports 2019-05-02 13:56:30 -04:00
Jeremy Stretch
5f5e4ce1a1 Changelog for #3132 2019-05-02 11:41:37 -04:00
Jeremy Stretch
d50acb39dd Merge pull request #3133 from shanemadden/cable_circuit_endpoint_choice
Fixes: #3132: Add circuittermination to the choices API for cable endpoints
2019-05-02 11:39:18 -04:00
Jeremy Stretch
25c8007b66 Fixes #3137: Add power_port and feed_leg fields to power outlet creation form 2019-05-02 10:12:27 -04:00
Jeremy Stretch
d4db04c649 Fixes #3136: Add power draw fields to power port creation form 2019-05-02 10:02:02 -04:00
John Anderson
250f310a98 fixes #3134 - remove component buttons from power utilization table 2019-05-01 21:48:35 -04:00
Shane Madden
ee4a3bcb02 Add circuittermination as a choice for cable endpoint types, which is not in the choices API for cable termination types but is accepted by the application as a valid endpoint for cables 2019-05-01 13:47:52 -06:00
Jeremy Stretch
d4d355dce6 Fixes #3130: Fix exception when creating a new power outlet 2019-05-01 12:02:18 -04:00
Jeremy Stretch
346b00e215 Merge branch 'develop' into develop-2.6 2019-05-01 11:53:44 -04:00
Jeremy Stretch
ceeac9bae3 Merge pull request #3131 from digitalocean/develop
Release v2.5.12
2019-05-01 11:10:43 -04:00
Jeremy Stretch
49446ffb74 Post-release version bump 2019-05-01 11:09:11 -04:00
Jeremy Stretch
5487ab40af Release v2.5.12 2019-05-01 11:08:32 -04:00
Jeremy Stretch
5a8ba159f2 Fixes #3127: Fix natural ordering of device components 2019-04-30 13:25:37 -04:00
Jeremy Stretch
cb93303f56 Fixes #3126: Incorrect calculation of PowerFeed available power 2019-04-30 12:38:06 -04:00
Jeremy Stretch
088903218d Fixes #3125: Fix exception when viewing PDUs 2019-04-30 12:24:53 -04:00
Jeremy Stretch
73927d2d56 Changelog for #3123 2019-04-30 12:10:00 -04:00
John Anderson
f9a74b68c1 Merge pull request #3124 from larsweiler/develop-2.6-metrics
Exclude /metrics from LOGIN_REQUIRED
2019-04-30 11:08:55 -04:00
dansheps
22e5834d8b Remove tenant group from ipam table 2019-04-30 10:06:27 -05:00
Lars Weiler
2a2026a2cc Forgot the additional brackets for a tuple 2019-04-30 17:04:21 +02:00
dansheps
63b71d43da Merge branch 'develop' of https://github.com/digitalocean/netbox into 2813-addtenantgroupfilter 2019-04-30 10:01:29 -05:00
Lars Weiler
560c8d6f01 More elegant path checking 2019-04-30 16:54:23 +02:00
Lars Weiler
4c5603e6ff Fix PEP 8 errors 2019-04-30 16:47:38 +02:00
Lars Weiler
99f0e7b939 Exclude /metrics from LOGIN_REQUIRED 2019-04-30 16:09:10 +02:00
Jeremy Stretch
7b5c1964b9 Fix broken link 2019-04-29 16:55:17 -04:00
Jeremy Stretch
b7a5afa797 Revert previous change 2019-04-29 16:44:13 -04:00
Jeremy Stretch
66f90f46de Fix mkdocs 2019-04-29 16:37:32 -04:00
Jeremy Stretch
4d2ac1ca53 Release v2.6-beta1 2019-04-29 15:36:21 -04:00
Jeremy Stretch
05a21369ae Bump django-cors-headers version 2019-04-29 15:27:14 -04:00
Jeremy Stretch
9b404facab Cleaned up changelog 2019-04-29 15:24:52 -04:00
Jeremy Stretch
f31d6c55be Fix erroneous merge conflict resolution from v2.5.11 2019-04-29 14:57:28 -04:00
Jeremy Stretch
37c2c4b4a2 Merge v2.5.11 2019-04-29 14:40:18 -04:00
Jeremy Stretch
d5dcb77d99 Post-release version bump 2019-04-29 14:27:22 -04:00
Jeremy Stretch
2b93510c45 Merge pull request #3121 from digitalocean/develop
Release v2.5.11
2019-04-29 14:25:37 -04:00
Jeremy Stretch
9717c6f825 Add a note about Django 2.2 2019-04-29 14:22:47 -04:00
Jeremy Stretch
92c227d103 Release v2.5.11 2019-04-29 14:21:10 -04:00
Jeremy Stretch
1491222642 Fixes #3072: Preserve multiselect filter values when updating per-page count for list views 2019-04-29 12:54:03 -04:00
Jeremy Stretch
39fceeb455 Add device field on cable search form (#3023) 2019-04-29 11:29:07 -04:00
Jeremy Stretch
3562b5552b Fixes #3118: Disable last_login update on login when maintenance mode is enabled 2019-04-29 11:04:32 -04:00
Jeremy Stretch
6d778f686d Closes #3023: Add support for filtering cables by connected device 2019-04-29 10:07:08 -04:00
Jeremy Stretch
245a97176a Closes #2986: Replace DeviceComponentManager with NaturalOrderingManager 2019-04-26 22:23:28 -04:00
Jeremy Stretch
ca56871aaa Changelog & CSS fix for #3070 2019-04-26 17:06:54 -04:00
Jeremy Stretch
bd4086cb50 Merge pull request #3103 from clercrobin/add_decommissioning
Fixes : #3070 Add the decommissioning status for devices
2019-04-26 17:04:36 -04:00
Jeremy Stretch
d8c9b1af27 Fixes #3116: Fix tagged_items count in tags API endpoint 2019-04-26 16:54:13 -04:00
Jeremy Stretch
19d2850b29 Fixes #3112: Fix ordering of interface connections list by termination B name/device 2019-04-26 16:41:01 -04:00
Jeremy Stretch
2c730b08e4 Fix PowerPort connected_endpoint filtering, ordering 2019-04-25 15:06:48 -04:00
Jeremy Stretch
e1bca52d57 Fix regex for IPAddress.dns_name (but see #3106) 2019-04-25 14:49:52 -04:00
Jeremy Stretch
06245f6422 Promote django-rq to a required dependency 2019-04-25 14:25:16 -04:00
John Anderson
f057a2c016 closes #3104 - add support for exposing prometheus metrics 2019-04-25 01:09:19 -04:00
rclerc
f4636537ad Add the decommissioning status for devices 2019-04-24 09:14:29 +02:00
Jeremy Stretch
a026ec45b8 Closes #166: Add dns_name to IPAddress 2019-04-22 18:10:28 -04:00
Jeremy Stretch
695a07daf4 Clean up settings.py and restrict import of LDAP parameters 2019-04-22 16:33:28 -04:00
Jeremy Stretch
c2d0e8fd95 Cleanup from earlier work on caching 2019-04-22 14:49:31 -04:00
Jeremy Stretch
1be5cf8184 Fix pagination logic for detecting QuerySets 2019-04-22 11:09:12 -04:00
Jeremy Stretch
f4aec1e7d0 Change VLAN view columns to a 4/8 split 2019-04-22 07:47:16 -04:00
Jeremy Stretch
e4c06700bb Closes #3094: Remove NullsFirstQuerySet 2019-04-19 20:59:07 -04:00
Jeremy Stretch
46b3512c45 Remove extraneous imports 2019-04-19 16:58:39 -04:00
Jeremy Stretch
017a5011ec Added '*_count' fields for child objects 2019-04-19 16:50:42 -04:00
Jeremy Stretch
f4bbdf30e8 Implement get_subquery() for annotation of child object counts; Rename dcim.Site 'count_*' fields 2019-04-19 16:09:22 -04:00
Jeremy Stretch
7d41a9ccdb Changelog cleanup 2019-04-19 15:28:08 -04:00
Jeremy Stretch
074d0349a1 Increase length of CustomLink text and url fields 2019-04-19 14:58:55 -04:00
Jeremy Stretch
6ab56c3978 Misc cleanup 2019-04-19 14:56:40 -04:00
Jeremy Stretch
5303d2c16d Add device types and power feeds to home page 2019-04-19 14:27:19 -04:00
Jeremy Stretch
30da4594cd Improved enforcement of view permissions for home page 2019-04-19 14:16:55 -04:00
Jeremy Stretch
92567137a5 PowerFeed list improvements 2019-04-19 13:43:09 -04:00
Jeremy Stretch
7b6c104768 Add power_panel_id field to PowerFeedFilterForm 2019-04-19 13:17:43 -04:00
John Anderson
d27d347d21 changelog and docs updates for 2.6 2019-04-19 02:32:50 -04:00
John Anderson
16b4ffa3fa Merge pull request #3080 from digitalocean/2647-cacheops
change cacheing to use cacheops
2019-04-19 01:41:59 -04:00
John Anderson
8b75969d1d fix typo in requirements 2019-04-18 14:39:12 -04:00
John Anderson
a5e1088f1f fixes #2621 - deletion issue in the changelog middleware 2019-04-18 14:37:58 -04:00
Jeremy Stretch
92a450e59c Improve the logic for gathering models from all apps 2019-04-17 14:33:26 -04:00
Jeremy Stretch
2f3c39295c Clean up, update Webhook models 2019-04-17 14:19:57 -04:00
Jeremy Stretch
000fde25c6 Add PowerFeeds to global search 2019-04-17 14:06:45 -04:00
Jeremy Stretch
cd3924520d Clean up limit_to for ForeignKeys referencing ContentType 2019-04-17 13:36:05 -04:00
John Anderson
c7778db7f2 fix timeout expression 2019-04-17 12:38:54 -04:00
John Anderson
2580b026fe change cacheing to use cacheops 2019-04-17 12:29:21 -04:00
Jeremy Stretch
eb86053a53 Merge pull request #3078 from digitalocean/3077-nested-api-writes
Enable dictionary specification of related objects in API
2019-04-17 11:25:40 -04:00
Jeremy Stretch
fd4c5031c7 Add test for dict_to_filter_params 2019-04-17 11:19:59 -04:00
Jeremy Stretch
655e48823a Merge branch 'develop-2.6' into 3077-nested-api-writes 2019-04-17 10:57:01 -04:00
Jeremy Stretch
a108807534 Add tests for WritableNestedSerializer 2019-04-17 10:54:50 -04:00
Jeremy Stretch
dbe0e9506d Cleaned up CHANGELOG 2019-04-16 21:12:16 -04:00
Jeremy Stretch
75b4ba2c3a Removed tags from the admin UI 2019-04-16 21:00:29 -04:00
Jeremy Stretch
de7207de55 Enable dictionary specification of related objects in API 2019-04-16 18:02:52 -04:00
Jeremy Stretch
bf2f314cd4 Remove run_validators() override (fixed upstream) 2019-04-16 15:35:15 -04:00
Jeremy Stretch
6034265dfd Add limit_choices_to to CustomLink.content_type field 2019-04-15 21:53:22 -04:00
Jeremy Stretch
48fe5470d2 Changelog for #969 2019-04-15 21:49:16 -04:00
Jeremy Stretch
2b2de8f8a5 Merge pull request #3074 from digitalocean/969-custom-links
969 custom links
2019-04-15 21:43:17 -04:00
Jeremy Stretch
dd58e78fde Add custom links to templates 2019-04-15 21:38:04 -04:00
Jeremy Stretch
2ec7ac1ea3 Custom link cleanup 2019-04-15 21:29:02 -04:00
Jeremy Stretch
f342a37362 Fix RelatedObjectDoesNotExist when adding a new PowerFeed 2019-04-15 17:55:50 -04:00
Jeremy Stretch
a411e32a9f Changelog for #2647 2019-04-15 17:49:50 -04:00
Jeremy Stretch
1877afc760 Merge pull request #3069 from digitalocean/2647-caching
intial work on #2647 - caching
2019-04-15 17:16:10 -04:00
Jeremy Stretch
6f055792df Corrected typo 2019-04-15 17:14:24 -04:00
Jeremy Stretch
4536754b20 Initial work on #969: Custom links 2019-04-15 17:12:41 -04:00
John Anderson
351736cc6d Merge branch 'develop-2.6' into 2647-caching 2019-04-15 14:46:04 -04:00
John Anderson
4723ddb5ce move caching to views 2019-04-15 14:41:04 -04:00
Jeremy Stretch
20670c546d Updated dependencies for v2.6 2019-04-15 13:14:48 -04:00
John Anderson
cdff29c7d5 add redis to travis builds 2019-04-15 04:16:22 -04:00
John Anderson
c11abbe8ca pep8 and postgres backend 2019-04-15 04:07:52 -04:00
John Anderson
f0505477b8 intial work on #2647 - caching 2019-04-15 03:55:33 -04:00
Jeremy Stretch
c484b27a35 Merge pull request #3068 from digitalocean/1863-api-child-counts
Add child objects counts to API serializers
2019-04-12 17:25:27 -04:00
Jeremy Stretch
04c1945abc Changelog for #1863 2019-04-12 17:21:04 -04:00
Jeremy Stretch
ad4d23fa20 Replace distinct annotations with subqueries for much better performance 2019-04-12 17:18:04 -04:00
Jeremy Stretch
a46b43bff6 Added child counts to API serializers (WIP) 2019-04-12 17:07:56 -04:00
Jeremy Stretch
b1c160f9d4 Merge pull request #3067 from digitalocean/2920-interface-type
Rename Interface.form_factor to Interface.type
2019-04-12 15:36:51 -04:00
Jeremy Stretch
533520ec1f Rename Interface type (classification) filter to kind 2019-04-12 14:09:03 -04:00
Jeremy Stretch
4acd842237 Maintain backward-compatibile support for Interface.form_factor until v2.7 2019-04-12 13:57:33 -04:00
Jeremy Stretch
778d56ac12 Update 'form_factor' in docs 2019-04-12 13:46:37 -04:00
Jeremy Stretch
1a2c9e3bba Rename form_factor to type on dcim.Interface 2019-04-12 13:42:56 -04:00
Jeremy Stretch
067c788df7 Add changelog tabs for power panel, power feed & clean up nav links 2019-04-12 10:16:34 -04:00
Jeremy Stretch
78c90ba24b Changelog for #54 2019-04-12 10:00:43 -04:00
Jeremy Stretch
573af6a236 Merge pull request #3066 from digitalocean/323-view-permissions
Closes #323
2019-04-12 09:33:48 -04:00
Jeremy Stretch
b29944d5d7 Check view permissions for object navigation tabs 2019-04-12 09:29:36 -04:00
Jeremy Stretch
dfa26cc5e2 Hide non-viewable object types on home page 2019-04-12 09:18:05 -04:00
Jeremy Stretch
8c5d544734 Changelog for #323 2019-04-12 09:07:22 -04:00
Jeremy Stretch
df0686a1bd Implement custom auth backend and EXEMPT_VIEW_PERMISSIONS setting 2019-04-11 22:01:26 -04:00
Jeremy Stretch
3e818cde69 Disable navigation links for objects user does not have permission to view 2019-04-11 21:08:40 -04:00
Jeremy Stretch
43a569d18a Enforce view permissions for API views 2019-04-11 17:40:46 -04:00
Jeremy Stretch
e710ccb0e6 Enforce view permissions for UI views 2019-04-11 17:27:38 -04:00
Jeremy Stretch
ea6815b9bb Merge pull request #3061 from digitalocean/54-power-modeling
#54: Power modeling
2019-04-11 15:02:07 -04:00
Jeremy Stretch
3b06251720 Added tests for power panels, power feeds 2019-04-11 12:29:43 -04:00
Jeremy Stretch
bfa07aec39 Fix tests 2019-04-11 11:37:44 -04:00
Jeremy Stretch
05c19af2a3 Misc cleanup 2019-04-11 10:49:43 -04:00
Jeremy Stretch
b22fd2bc44 Rename max_utilization to power_factor 2019-04-10 16:36:38 -04:00
Jeremy Stretch
ac3e899f5e First stab at power utilization tracking 2019-04-10 16:32:13 -04:00
Jeremy Stretch
3d5f85c0ca Add associatiton from power outlet to power port/phase 2019-04-10 14:16:16 -04:00
Jeremy Stretch
0ddd71fc36 Include draw numbers of connected power ports 2019-04-10 13:05:51 -04:00
dansheps
6fa54bed73 Fix PEP8 Errors 2019-04-10 08:42:27 -05:00
dansheps
6e8e6809f3 Move Filter and Form to new file, update all files 2019-04-10 08:37:12 -05:00
Jeremy Stretch
8230ea1c83 Add max/allocated current draw fields to PowerPort 2019-04-09 17:32:04 -04:00
dansheps
a91a79681f Merge branch 'develop' of https://github.com/digitalocean/netbox into 2813-addtenantgroupfilter 2019-04-09 15:57:22 -05:00
Jeremy Stretch
c1127148e2 Provide individual views for each type of cable connection 2019-04-09 16:49:04 -04:00
Jeremy Stretch
ef867e789d Finish power outlet/feed connection forms 2019-04-09 15:37:31 -04:00
Jeremy Stretch
cd655d289b Fix CSV import forms 2019-04-09 14:55:17 -04:00
Jeremy Stretch
f1d1e8b537 Update migrations 2019-04-09 14:22:45 -04:00
Jeremy Stretch
71b674d11a Merge branch 'develop-2.6' into 54-power-modeling 2019-04-09 14:12:45 -04:00
Jeremy Stretch
4f9b666eee Merge branch 'develop' into develop-2.6 2019-04-09 14:11:26 -04:00
Jeremy Stretch
8d79353d9b Post-release version bump 2019-04-08 14:30:06 -04:00
Jeremy Stretch
f3fffc6161 Merge pull request #3053 from digitalocean/develop
Release v2.5.10
2019-04-08 14:26:27 -04:00
Jeremy Stretch
a8d20e13a8 Release v2.5.10 2019-04-08 14:19:37 -04:00
Jeremy Stretch
9a91bdbdb2 Fixes #2937: Redirect to list view after editing an object from list view 2019-04-08 14:10:55 -04:00
Jeremy Stretch
6f8591f769 Closes #3052: Add Jinja2 support for export templates 2019-04-08 12:20:24 -04:00
dansheps
7f6d79362e Fix virtualization test and add to changelog 2019-04-05 09:47:38 -05:00
Daniel Sheppard
c032413201 Remove unneeded import from testing. 2019-04-05 09:39:04 -05:00
dansheps
e556c78599 Fixes #3047: Fix exception string for invalid MAC Address format 2019-04-05 09:36:56 -05:00
Jeremy Stretch
1b389d662b Fixes #3046: Fix exception at reports API endpoint 2019-04-04 17:34:36 -04:00
Jeremy Stretch
090efde21a Fixes #3044: Ignore site/rack fields when connecting a new cable via device search 2019-04-04 16:19:20 -04:00
Jeremy Stretch
74c03e3295 Fixes #3036: DCIM interfaces API endpoint should not include VM interfaces 2019-04-04 15:07:41 -04:00
Jeremy Stretch
858be6d216 Fixes #3039: Fix exception when retrieving change object for a component template via API 2019-04-04 15:00:59 -04:00
Jeremy Stretch
1e160fd9e9 Fixes #3041: Fix form widget for bulk cable label update 2019-04-04 12:43:14 -04:00
Jeremy Stretch
4884f3ac77 Fix column links 2019-04-02 14:49:40 -04:00
Jeremy Stretch
0e5dff212e Fix rack elevation display 2019-04-02 13:50:57 -04:00
Jeremy Stretch
e0085ec819 Fixed table columns 2019-04-02 13:37:53 -04:00
Jeremy Stretch
07bfd7c8e5 Merge branch 'develop-2.6' into 54-power-modeling 2019-04-02 13:15:40 -04:00
Jeremy Stretch
c8cccc30d1 Merge branch 'develop' into develop-2.6 2019-04-02 13:12:34 -04:00
Jeremy Stretch
7c6d2a6281 Post-release version bump 2019-04-02 12:37:39 -04:00
Jeremy Stretch
fdf168934e Merge pull request #3034 from digitalocean/develop
Release v2.5.9
2019-04-02 12:34:34 -04:00
Jeremy Stretch
738a20ad34 Release v2.5.9 2019-04-02 11:54:00 -04:00
Jeremy Stretch
3602d5a84c Fixes #3032: Save assigned tags when creating a new secret 2019-04-02 11:42:49 -04:00
Jeremy Stretch
d23ca041cf Ensure fallback to default serializer when attempting to load nested serializer 2019-04-02 11:17:14 -04:00
Jeremy Stretch
110387e81b Fixes #3019: Fix tag population when running NetBox within a path 2019-04-02 11:05:16 -04:00
Jeremy Stretch
498f132cad Fixes #3027: Ignore empty local context data when rendering config contexts 2019-03-28 10:16:28 -04:00
Jeremy Stretch
dff3165402 Fixes #3026: Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher 2019-03-28 10:06:25 -04:00
Jeremy Stretch
3f5f75c71f Fixes #3001: Fix API representation of ObjectChange action and add changed_object_type 2019-03-28 09:57:26 -04:00
John Anderson
2e1887eb0e implements #3025 - Add request ID to outbound webhook requests 2019-03-24 15:35:42 -04:00
John Anderson
2170eedf08 implements #2933 - username in webhooks 2019-03-24 15:31:12 -04:00
Jeremy Stretch
a208cd156d Template and table polish 2019-03-22 21:58:45 -04:00
Jeremy Stretch
3acc8ca3ab Fixes #3022: Add missing cable termination types to DCIM _choices endpoint 2019-03-22 16:26:56 -04:00
Jeremy Stretch
fc76c8eb0f FieldChoicesViewSet should infer field choices from serializer, not model 2019-03-22 16:24:53 -04:00
Jeremy Stretch
681e20133a Further work on power feed modeling 2019-03-21 17:47:43 -04:00
Jeremy Stretch
7d1ee2e94e Changelog for #3011 2019-03-19 10:34:07 -04:00
Jeremy Stretch
3cea92384c Merge pull request #3012 from ajknv/develop
Add support for configuring use of an SSL connection to Redis.
2019-03-19 10:28:01 -04:00
Jeremy Stretch
6ba3d611af Merge pull request #3015 from DanSheps/3014-fix-vm-roles
Changes vm_role from "true" to "True" in virtualization form
2019-03-19 10:24:59 -04:00
Jeremy Stretch
f6345b9a5d Fixes #2998: Limit device query to non-racked devices if no rack selected when creating a cable 2019-03-19 10:22:52 -04:00
Jeremy Stretch
044f7395bb Closes #2995: Added powerbox to community SDK list 2019-03-19 09:48:16 -04:00
Alexander Kinneer
8bda6be65a Merge branch 'develop' of https://github.com/ajknv/netbox into develop 2019-03-18 11:28:48 -05:00
Alexander Kinneer
e544705256 Add support for configuring use of an SSL connection to Redis.
Requires a build or release of django-rq containing
44f3fdd7cb
2019-03-18 11:26:37 -05:00
Alexander Kinneer
f88099eb3b Add documentation for new Redis SSL configuration parameter. 2019-03-18 11:15:40 -05:00
dansheps
5f40be4bd5 Changes vm_role from "true" to "True" in virtualization form 2019-03-18 09:22:36 -05:00
John Anderson
1bf04f2e30 fixes #2936 - device role selection showing duplicate first entry 2019-03-17 01:24:54 -04:00
Alexander Kinneer
7edad4eba9 Add support for configuring use of an SSL connection to Redis.
Requires a build or release of django-rq containing
44f3fdd7cb
2019-03-15 10:35:03 -05:00
Jeremy Stretch
dd7249d2ef Merge pull request #2996 from DanSheps/2207-fix_deterministic_ordering_of_interfaces
Fixes #2207
2019-03-13 16:15:34 -04:00
dansheps
aab84ba6f3 Change ID to PK 2019-03-13 14:02:23 -05:00
Jeremy Stretch
520af82f05 Closes #2924: Add interface type for QSFP28 50GE 2019-03-13 10:00:40 -04:00
dansheps
9292534324 Changelog Updates 2019-03-12 15:54:38 -05:00
dansheps
61efe6102e Fixes #2207
* Added 'id' field sort to InterfaceManager
2019-03-12 15:52:44 -05:00
Jeremy Stretch
705f82e416 Added filters for power panels & feeds 2019-03-12 12:05:58 -04:00
Jeremy Stretch
e06dece00c More power work 2019-03-12 11:36:29 -04:00
Jeremy Stretch
3b9c0e4c67 Fixed up models & forms 2019-03-12 10:17:00 -04:00
Jeremy Stretch
5b753923b6 Initial work on power modeling (WIP) 2019-03-11 22:40:52 -04:00
Jeremy Stretch
0b95016e00 Merge branch 'develop' into develop-2.6 2019-03-11 21:01:18 -04:00
Daniel Sheppard
0022a5a6e2 Merge pull request #2992 from DanSheps/api-documentation-filtering
* Fixes #2991: Fixed typo
* Fixes #2577: Clarified documentation regarding filtering multiple times (some filters can, some cannot)
2019-03-11 15:26:59 -05:00
dansheps
332487efcd API Filtering Documentation Changes
* Fixed typo
* Clarified documentation regarding filtering multiple times (some filters can, some cannot)
2019-03-11 15:11:16 -05:00
Jeremy Stretch
28331dfd53 Post-release version bump 2019-03-11 13:39:30 -04:00
Jeremy Stretch
d9c8c0cbc1 Fixes #2968: Correct API documentation for SerializerMethodFields 2019-03-08 10:25:09 -05:00
Jeremy Stretch
8a1d7fdb37 Updated migrations to account for extras/0017_exporttemplate_mime_type_length 2019-03-05 13:46:37 -05:00
Jeremy Stretch
c52d077f92 Merge branch 'develop' into develop-2.6 2019-03-05 13:39:00 -05:00
dansheps
37811d3f7e * Resolve conflict with virtualization filters. 2019-03-05 08:19:21 -06:00
dansheps
3bb1cbcdb0 * Resolve conflict with virtualization filters. 2019-03-05 08:18:04 -06:00
dansheps
5fcd673f9f Merge remote-tracking branch 'dansheps/2813-addtenantgroupfilter' into 2813-addtenantgroupfilter
# Conflicts:
#	netbox/ipam/tables.py
2019-03-05 08:11:44 -06:00
dansheps
b4d7f9ea43 Fixes #2781: Fixes filter by regions on site and device list
* Add Device filter
2019-03-05 08:10:10 -06:00
Jeremy Stretch
66d9d9d9cb Rearranged changelog items 2019-03-04 16:01:43 -05:00
Grokzen
b9f4a9e57b Closes #1792 - Add CustomFieldChoices API endpoint (#2941)
* Add new api endpoint for CustomFieldChoices

* Add changelog item for #1792

* Add tests for CustomFieldchoiceAPI endpoint
2019-03-04 15:58:40 -05:00
Jeremy Stretch
80b3a5ffc4 Fixed erroneous addition of 'description' field to unique_together 2019-03-04 15:57:21 -05:00
John Anderson
11976f8968 Merge branch 'develop' into develop-2.6 2019-03-03 19:06:15 -05:00
John Anderson
5991bd368c Merge branch 'develop-2.6' of github.com:digitalocean/netbox into develop-2.6 2019-03-03 19:06:05 -05:00
John Anderson
231a5aa9fd v2.6.0-dev version update 2019-03-01 23:16:08 -05:00
Daniel Sheppard
679aa0f764 Update tables.py
Fix whitespace
2019-02-26 07:53:59 -06:00
dansheps
8683efe54a Fixes #2813: Add Filter and List View for TenantGroup
Added Filter for TenantGroup to the following Forms and Filter classes

* circuit.Circuit
* dcim.Site
* dcim.Rack
* dcim.RackElevation
* dcim.RackReservation
* dcim.Device
* ipam.IPAddress
* ipam.Prefix
* ipam.VRF
* ipam.VLAN
* virtualization.VirtualMachine

Added List View to the following classes:

* circuit.Circuit
* dcim.Site
* dcim.Rack
* dcim.RackReservation
* dcim.Device
* ipam.IPAddress
* ipam.Prefix
* ipam.VRF
* ipam.VLAN
* virtualization.VirtualMachine
2019-02-23 11:09:02 -06:00
dansheps
f78c228c75 Fixes #2813: Add Filter for TenantGroup to the following Forms and Filter classes:
* circuit.Circuit
* dcim.Site
* dcim.Rack
* dcim.RackElevation
* dcim.RackReservation
* dcim.Device
* ipam.IPAddress
* ipam.Prefix
* ipam.VRF
* ipam.VLAN
* virtualization.VirtualMachine
2019-02-23 10:37:30 -06:00
Jeremy Stretch
6a8c935380 Merge branch 'develop' into develop-2.6 2019-02-22 12:10:44 -05:00
Jeremy Stretch
00c4d3dd92 Merge pull request #2918 from digitalocean/2643-description-fields
Closes #2643: Add description field to console/power components and device bays
2019-02-22 12:08:24 -05:00
John Anderson
8e548605c8 added changelog views for Tag 2019-02-22 02:29:00 -05:00
John Anderson
de52f21905 fix circular import for ObjectChange, for now... 2019-02-22 01:42:17 -05:00
John Anderson
b9d11aa4ca refactor tag migrations and add changelog fields to tag 2019-02-22 01:32:31 -05:00
John Anderson
fba6d28603 removed migration to delete taggit models 2019-02-21 00:15:15 -05:00
Jeremy Stretch
bb6fb81fc0 Closes #2643: Add description field to console/power components and device bays 2019-02-20 14:34:05 -05:00
John Anderson
0a06d92c2e added default values for comments and color fields on tag 2019-02-20 03:56:32 -05:00
John Anderson
fc2bb724fa initial pass on migrating to custom tag model with color and comments fields 2019-02-20 03:52:47 -05:00
John Anderson
e521508de9 #2350 - added virtual machines and test cases 2019-02-18 22:10:05 -05:00
John Anderson
bd573fd5cf implemented #2350 - config context included by default in API 2019-02-18 21:37:00 -05:00
261 changed files with 12422 additions and 4903 deletions

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@
/netbox/netbox/ldap_config.py
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/scripts/*
!/netbox/scripts/__init__.py
/netbox/static
.idea
/*.sh

View File

@@ -1,6 +1,7 @@
sudo: required
services:
- postgresql
- redis-server
addons:
postgresql: "9.4"
language: python

File diff suppressed because it is too large Load Diff

View File

@@ -16,11 +16,11 @@ For real-time discussion, you can join the #netbox Slack channel on [NetworkToCo
## Reporting Bugs
* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases)
* First, ensure that you've installed the [latest stable version](https://github.com/netbox-community/netbox/releases)
of NetBox. If you're running an older version, it's possible that the bug has
already been fixed.
* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
* Next, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
to see if the bug you've found has already been reported. If you think you may
be experiencing a reported issue that hasn't already been resolved, please
click "add a reaction" in the top right corner of the issue and add a thumbs
@@ -51,7 +51,7 @@ your issue.
## Feature Requests
* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues)
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
to see if the feature you're requesting is already listed. (Be sure to search
closed issues as well, since some feature requests have been rejected.) If the
feature you'd like to see has already been requested and is open, click "add a

1
NOTICE Normal file
View File

@@ -0,0 +1 @@
Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC.

View File

@@ -7,7 +7,7 @@ to address the needs of network and infrastructure engineers.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox).
complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/).
@@ -32,7 +32,7 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
# Installation
Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases)
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
and run `upgrade.sh`.
## Alternative Installations
@@ -43,15 +43,4 @@ and run `upgrade.sh`.
# 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.
Please see [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for a list of relevant community projects.

View File

@@ -2,6 +2,10 @@
# https://github.com/django/django
Django
# Django caching using Redis
# https://github.com/Suor/django-cacheops
django-cacheops
# Django middleware which permits cross-domain API requests
# https://github.com/OttoYiu/django-cors-headers
django-cors-headers
@@ -18,6 +22,14 @@ django-filter
# https://github.com/django-mptt/django-mptt
django-mptt
# Django integration for RQ (Reqis queuing)
# https://github.com/rq/django-rq
django-rq
# Prometheus metrics library for Django
# https://github.com/korfuri/django-prometheus
django-prometheus
# Abstraction models for rendering and paginating HTML tables
# https://github.com/jieter/django-tables2
django-tables2

View File

@@ -0,0 +1,21 @@
# Caching
To improve performance, NetBox supports caching for most object and list views. Caching is implemented using Redis,
and [django-cacheops](https://github.com/Suor/django-cacheops)
Several management commands are avaliable for administrators to manaully invalidate cache entries in extenuating circumstances.
To invalidate a specifc model instance (for example a Device with ID 34):
```
python netbox/manage.py invalidate dcim.Device.34
```
To invalidate all instance of a model:
```
python netbox/manage.py invalidate dcim.Device
```
To flush the entire cache database:
```
python netbox/manage.py invalidate all
```

View File

@@ -0,0 +1,43 @@
# Custom Links
Custom links allow users to place arbitrary hyperlinks within NetBox views. These are helpful for cross-referencing related records in external systems. For example, you might create a custom link on the device view which links to the current device in a network monitoring system.
Custom links are created under the admin UI. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link is assigned text and a URL, both of which support Jinja2 templating. The text and URL are rendered with the context variable `obj` representing the current object.
For example, you might define a link like this:
* Text: `View NMS`
* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
When viewing a device named Router4, this link would render as:
```
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
```
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
## Conditional Rendering
Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered.
For example, if you only want to display a link for active devices, you could set the link text to
```
{% if obj.status == 1 %}View NMS{% endif %}
```
The link will not appear when viewing a device with any status other than "active."
Another example, if you want to only show an object of a certain manufacturer, you could set the link text to:
```
{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS {% endif %}
```
The link will only appear when viewing a device with a manufacturer name of "Cisco."
## Link Groups
You can specify a group name to organize links into related sets. Grouped links will render as a dropdown menu beneath a
single button bearing the name of the group.

View File

@@ -0,0 +1,213 @@
# Custom Scripts
Custom scripting was introduced to provide a way for users to execute custom logic from within the NetBox UI. Custom scripts enable the user to directly and conveniently manipulate NetBox data in a prescribed fashion. They can be used to accomplish myriad tasks, such as:
* Automatically populate new devices and cables in preparation for a new site deployment
* Create a range of new reserved prefixes or IP addresses
* Fetch data from an external source and import it to NetBox
Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're written from scratch, a custom script can be used to accomplish just about anything.
## Writing Custom Scripts
All custom scripts must inherit from the `extras.scripts.Script` base class. This class provides the functionality necessary to generate forms and log activity.
```
from extras.scripts import Script
class MyScript(Script):
..
```
Scripts comprise two core components: variables and a `run()` method. Variables allow your script to accept user input via the NetBox UI. The `run()` method is where your script's execution logic lives. (Note that your script can have as many methods as needed: this is merely the point of invocation for NetBox.)
```
class MyScript(Script):
var1 = StringVar(...)
var2 = IntegerVar(...)
var3 = ObjectVar(...)
def run(self, data):
...
```
The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
Returning output from your script is optional. Any raw output generated by the script will be displayed under the "output" tab in the UI.
## Module Attributes
### `name`
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the filename will be used.
## Script Attributes
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
### `name`
This is the human-friendly names of your script. If omitted, the class name will be used.
### `description`
A human-friendly description of what your script does.
### `field_order`
A list of field names indicating the order in which the form fields should appear. This is optional, however on Python 3.5 and earlier the fields will appear in random order. (Declarative ordering is preserved on Python 3.6 and above.) For example:
```
field_order = ['var1', 'var2', 'var3']
```
## Reading Data from Files
The Script class provides two convenience methods for reading data from files:
* `load_yaml`
* `load_json`
These two methods will load data in YAML or JSON format, respectively, from files within the local path (i.e. `SCRIPTS_ROOT`).
## Logging
The Script object provides a set of convenient functions for recording messages at different severity levels:
* `log_debug`
* `log_success`
* `log_info`
* `log_warning`
* `log_failure`
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
## Variable Reference
### StringVar
Stores a string of characters (i.e. a line of text). Options include:
* `min_length` - Minimum number of characters
* `max_length` - Maximum number of characters
* `regex` - A regular expression against which the provided value must match
Note: `min_length` and `max_length` can be set to the same number to effect a fixed-length field.
### TextVar
Arbitrary text of any length. Renders as multi-line text input field.
### IntegerVar
Stored a numeric integer. Options include:
* `min_value:` - Minimum value
* `max_value` - Maximum value
### BooleanVar
A true/false flag. This field has no options beyond the defaults.
### ObjectVar
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
* `queryset` - A [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/)
### FileVar
An uploaded file. Note that uploaded files are present in memory only for the duration of the script's execution: They will not be save for future use.
### IPNetworkVar
An IPv4 or IPv6 network with a mask.
### Default Options
All variables support the following default options:
* `label` - The name of the form field
* `description` - A brief description of the field
* `default` - The field's default value
* `required` - Indicates whether the field is mandatory (default: true)
## Example
Below is an example script that creates new objects for a planned site. The user is prompted for three variables:
* The name of the new site
* The device model (a filtered list of defined device types)
* The number of access switches to create
These variables are presented as a web form to be completed by the user. Once submitted, the script's `run()` method is called to create the appropriate objects.
```
from django.utils.text import slugify
from dcim.constants import *
from dcim.models import Device, DeviceRole, DeviceType, Site
from extras.scripts import *
class NewBranchScript(Script):
class Meta:
name = "New Branch"
description = "Provision a new branch site"
fields = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar(
description="Name of the new site"
)
switch_count = IntegerVar(
description="Number of access switches to create"
)
switch_model = ObjectVar(
description="Access switch model",
queryset = DeviceType.objects.filter(
manufacturer__name='Cisco',
model__in=['Catalyst 3560X-48T', 'Catalyst 3750X-48T']
)
)
def run(self, data):
# Create the new site
site = Site(
name=data['site_name'],
slug=slugify(data['site_name']),
status=SITE_STATUS_PLANNED
)
site.save()
self.log_success("Created new site: {}".format(site))
# Create access switches
switch_role = DeviceRole.objects.get(name='Access Switch')
for i in range(1, data['switch_count'] + 1):
switch = Device(
device_type=data['switch_model'],
name='{}-switch{}'.format(site.slug, i),
site=site,
status=DEVICE_STATUS_PLANNED,
device_role=switch_role
)
switch.save()
self.log_success("Created new switch: {}".format(switch))
# Generate a CSV table of new devices
output = [
'name,make,model'
]
for switch in Device.objects.filter(site=site):
attrs = [
switch.name,
switch.device_type.manufacturer.name,
switch.device_type.model
]
output.append(','.join(attrs))
return '\n'.join(output)
```

View File

@@ -0,0 +1,34 @@
# Prometheus Metrics
NetBox supports optionally exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a popular time series metric platform used for monitoring.
NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g. `https://netbox.local/metrics`. Metric exposition can be toggled with the `METRICS_ENABLED` configuration setting. Metrics are not exposed by default.
## Metric Types
NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of different types of metrics, including:
- Per model insert, update, and delete counters
- Per view request counters
- Per view request latency histograms
- Request body size histograms
- Response body size histograms
- Response code counters
- Database connection, execution, and error counters
- Cache hit, miss, and invalidation counters
- Django middleware latency histograms
- Other Django related metadata metrics
For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance.
## Multi Processing Notes
When deploying NetBox in a multiprocess mannor--such as using Gunicorn as recomented in the installation docs--the Prometheus client library requires the use of a shared directory
to collect metrics from all the worker processes. This can be any arbitrary directory to which the processes have read/write access. This directory is then made available by use of the
`prometheus_multiproc_dir` environment variable.
This can be setup by first creating a shared directory and then adding this line (with the appropriate directory) to the `[program:netbox]` section of the supervisor config file.
```
environment=prometheus_multiproc_dir=/tmp/prometheus_metrics
```

View File

@@ -43,7 +43,7 @@ class DeviceConnectionsReport(Report):
def test_console_connection(self):
# Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
if console_port.connected_endpoint is None:
self.log_failure(
console_port.device,

View File

@@ -4,14 +4,6 @@ A webhook defines an HTTP request that is sent to an external application when c
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
## Installation
If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below.
* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages)
* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration)
* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation)
## Requests
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):

View File

@@ -30,7 +30,7 @@ psql -c 'create database netbox'
psql netbox < netbox.sql
```
Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway.
Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway.
## Export the Database Schema

View File

@@ -1,5 +1,3 @@
NetBox v2.0 and later includes a full-featured REST API that allows its data model to be read and manipulated externally.
# What is a REST API?
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP to create, retrieve, update, and delete objects from a database. (This set of operations is commonly referred to as CRUD.) Each type of operation is associated with a particular HTTP verb:
@@ -34,6 +32,10 @@ $ curl -s http://localhost:8000/api/ipam/ip-addresses/2954/ | jq '.'
Each attribute of the NetBox object is expressed as a field in the dictionary. Fields may include their own nested objects, as in the case of the `status` field above. Every object includes a primary key named `id` which uniquely identifies it in the database.
# Interactive Documentation
Comprehensive, interactive documentation of all API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with NetBox's various API endpoints and different request types.
# URL Hierarchy
NetBox's entire API is housed under the API root at `https://<hostname>/api/`. The URL structure is divided at the root level by application: circuits, DCIM, extras, IPAM, secrets, and tenancy. Within each application, each model has its own path. For example, the provider and circuit objects are located under the "circuits" application:
@@ -104,24 +106,37 @@ The base serializer is used to represent the default view of a model. This inclu
}
```
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
## Related Objects
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to display the object to a user. When performing write API actions (`POST`, `PUT`, and `PATCH`), related objects may be specified by either numeric ID (primary key), or by a set of attributes sufficiently unique to return the desired object.
For example, when creating a new device, its rack can be specified by NetBox ID (PK):
```
{
"id": 1201,
"site": 7,
"group": 4,
"vid": 102,
"name": "Users-Floor2",
"tenant": null,
"status": 1,
"role": 9,
"description": ""
"name": "MyNewDevice",
"rack": 123,
...
}
```
Or by a set of nested attributes used to identify the rack:
```
{
"name": "MyNewDevice",
"rack": {
"site": {
"name": "Equinix DC6"
},
"name": "R204"
},
...
}
```
Note that if the provided parameters do not return exactly one object, a validation error is raised.
## Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
@@ -261,12 +276,31 @@ A list of objects retrieved via the API can be filtered by passing one or more q
GET /api/ipam/prefixes/?status=1
```
The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`:
```
GET /api/ipam/prefixes/?status=1&status=2
"prefix:status": [
{
"label": "Container",
"value": 0
},
{
"label": "Active",
"value": 1
},
{
"label": "Reserved",
"value": 2
},
{
"label": "Deprecated",
"value": 3
}
],
```
For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar".
## Custom Fields
To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123:

View File

@@ -44,6 +44,14 @@ BASE_PATH = 'netbox/'
---
## CACHE_TIMEOUT
Default: 900
The number of seconds to retain cache entries before automatically invalidating them.
---
## CHANGELOG_RETENTION
Default: 90
@@ -64,7 +72,13 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all
## CORS_ORIGIN_REGEX_WHITELIST
These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.)
These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example:
```
CORS_ORIGIN_WHITELIST = [
'https://example.com',
]
```
---
@@ -89,6 +103,30 @@ In order to send email, NetBox needs an email server configured. The following i
---
## EXEMPT_VIEW_PERMISSIONS
Default: Empty list
A list of models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users and by anonymous users.
List models in the form `<app>.<model>`. For example:
```
EXEMPT_VIEW_PERMISSIONS = [
'dcim.site',
'dcim.region',
'ipam.prefix',
]
```
To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.)
```
EXEMPT_VIEW_PERMISSIONS = ['*']
```
---
# ENFORCE_GLOBAL_UNIQUE
Default: False
@@ -165,6 +203,14 @@ The file path to the location where media files (such as image attachments) are
---
## METRICS_ENABLED
Default: False
Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics/) documentation for more details.
---
## NAPALM_USERNAME
## NAPALM_PASSWORD
@@ -231,6 +277,14 @@ The file path to the location where custom reports will be kept. By default, thi
---
## SCRIPTS_ROOT
Default: $BASE_DIR/netbox/scripts/
The file path to the location where custom scripts will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path.
---
## SESSION_FILE_PATH
Default: None
@@ -269,49 +323,3 @@ SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00
DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m.
SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23
```
---
## Redis Connection Settings
[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../additional-features/webhooks/). A Redis connection is configured using a dictionary similar to the following:
```
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
}
```
### DATABASE
Default: 0
The Redis database ID.
### DEFAULT_TIMEOUT
Default: 300
The timeout value to use when connecting to the Redis server (in seconds).
### HOST
Default: localhost
The hostname or IP address of the Redis server.
### PORT
Default: 6379
The TCP port to use when connecting to the Redis server.
### PASSWORD
Default: None
The password to use when authenticating to the Redis server (optional).

View File

@@ -16,11 +16,11 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
NetBox requires access to a PostgreSQL database service to store data. This service can run locally or on a remote system. The following parameters must be defined within the `DATABASE` dictionary:
* NAME - Database name
* USER - PostgreSQL username
* PASSWORD - PostgreSQL password
* HOST - Name or IP address of the database server (use `localhost` if running locally)
* PORT - TCP port of the PostgreSQL service; leave blank for default port (5432)
* `NAME` - Database name
* `USER` - PostgreSQL username
* `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
Example:
@@ -36,6 +36,47 @@ DATABASE = {
---
## REDIS
[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of
NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching
functionality (as well as other planned features).
Redis is configured using a configuration setting similar to `DATABASE`:
* `HOST` - Name or IP address of the Redis server (use `localhost` if running locally)
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID for webhooks
* `CACHE_DATABASE` - Numeric database ID for caching
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis
Example:
```
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
```
!!! note:
If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
!!! warning:
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook
processing data being lost in cache flushing events.
---
## SECRET_KEY
This is a secret cryptographic key is used to improve the security of cookies and password resets. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions.

View File

@@ -81,7 +81,7 @@ Power ports connect only to power outlets. Power connections can be marked as ei
Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*.
Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned.
Each interface is a assigned a type denoting its physical properties. Two special types exist: the "virtual" type can be used to designate logical interfaces (such as SVIs), and the "LAG" type can be used to desinate link aggregation groups to which physical interfaces can be assigned.
Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address.
@@ -95,7 +95,7 @@ Pass-through ports can also be used to model "bump in the wire" devices, such as
### Device Bays
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view.
Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or the "Non-Racked Devices" list within the rack view.
Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices.

View File

@@ -38,7 +38,7 @@ Add the name of the new field to `csv_headers` and included a CSV-friendly repre
### 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.
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 `prefetch_related()` as appropriate. This will optimize the view and avoid excessive database lookups.
### 5. Update API serializer

View File

@@ -1,12 +1,12 @@
# NetBox Development
NetBox is maintained as a [GitHub project](https://github.com/digitalocean/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
## Communication
Communication among developers should always occur via public channels:
* [GitHub issues](https://github.com/digitalocean/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
* [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.

View File

@@ -29,6 +29,7 @@ Update the following static libraries to their most recent stable release:
* Bootstrap 3
* Font Awesome 4
* Select2
* jQuery
* jQuery UI
@@ -60,7 +61,7 @@ Once CI has completed on the PR, merge it.
## Create a New Release
Draft a [new release](https://github.com/digitalocean/netbox/releases/new) with the following parameters.
Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters.
* **Tag:** Current version (e.g. `v2.3.4`)
* **Target:** `master`

View File

@@ -1,18 +1,18 @@
# Installation
This section of the documentation discusses installing and configuring the NetBox application.
This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies:
**Ubuntu**
```no-highlight
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev
# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev
```
**CentOS**
```no-highlight
# yum install -y epel-release
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
# easy_install-3.6 pip
# ln -s /usr/bin/python36 /usr/bin/python3
```
@@ -21,10 +21,10 @@ You may opt to install NetBox either from a numbered release or by cloning the m
## Option A: Download a Release
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
```no-highlight
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -s netbox-X.Y.Z/ netbox
@@ -56,7 +56,7 @@ If `git` is not already installed, install it:
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
```no-highlight
# git clone -b master https://github.com/digitalocean/netbox.git .
# git clone -b master https://github.com/netbox-community/netbox.git .
Cloning into '.'...
remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done.
@@ -90,28 +90,6 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati
# pip3 install napalm
```
## Webhooks (Optional)
[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one.
**Ubuntu**
```no-highlight
# apt-get install -y redis-server
```
**CentOS**
```no-highlight
# yum install -y redis
```
Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks.
```no-highlight
# pip3 install django-rq
```
# Configuration
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
@@ -123,9 +101,10 @@ Move into the NetBox configuration directory and make a copy of `configuration.e
Open `configuration.py` with your preferred editor and set the following variables:
* ALLOWED_HOSTS
* DATABASE
* SECRET_KEY
* `ALLOWED_HOSTS`
* `DATABASE`
* `REDIS`
* `SECRET_KEY`
## ALLOWED_HOSTS
@@ -139,7 +118,7 @@ ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
## DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address.
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../configuration/required-settings/#database) for more detail on individual parameters.
Example:
@@ -153,6 +132,22 @@ DATABASE = {
}
```
## REDIS
Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings/#redis) for more detail on individual parameters.
```python
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'CACHE_DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
```
## SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
@@ -162,21 +157,6 @@ You may use the script located at `netbox/generate_secret_key.py` to generate a
!!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
## Webhooks Configuration
If you have opted to enable the webhooks, set `WEBHOOKS_ENABLED = True` and define the relevant `REDIS` database parameters. Below is an example:
```python
WEBHOOKS_ENABLED = True
REDIS = {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
}
```
# Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):

View File

@@ -1,7 +1,7 @@
# Migration
!!! warning
Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible.
As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later.
## Ubuntu
@@ -36,9 +36,3 @@ If using LDAP authentication, install the `django-auth-ldap` package:
```no-highlight
# pip3 install django-auth-ldap
```
If using Webhooks, install the `django-rq` package:
```no-highlight
# pip3 install django-rq
```

View File

@@ -4,12 +4,12 @@ As with the initial installation, you can upgrade NetBox by either downloading t
## Option A: Download a Release
Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`.
Download and extract the latest version:
```no-highlight
# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -sfn netbox-X.Y.Z/ netbox

View File

@@ -1,6 +1,6 @@
site_name: NetBox
theme: readthedocs
repo_url: https://github.com/digitalocean/netbox
repo_url: https://github.com/netbox-community/netbox
pages:
- Introduction: 'index.md'
@@ -36,6 +36,8 @@ pages:
- Reports: 'additional-features/reports.md'
- Webhooks: 'additional-features/webhooks.md'
- Change Logging: 'additional-features/change-logging.md'
- Caching: 'additional-features/caching.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Administration:
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'

View File

@@ -17,10 +17,11 @@ __all__ = [
class NestedProviderSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = Provider
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
#
@@ -29,10 +30,11 @@ class NestedProviderSerializer(WritableNestedSerializer):
class NestedCircuitTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = CircuitType
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'circuit_count']
class NestedCircuitSerializer(WritableNestedSerializer):

View File

@@ -1,3 +1,4 @@
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.constants import CIRCUIT_STATUS_CHOICES
@@ -16,12 +17,13 @@ from .nested_serializers import *
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = Provider
fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
'custom_fields', 'created', 'last_updated', 'circuit_count',
]
@@ -30,10 +32,11 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class CircuitTypeSerializer(ValidatedModelSerializer):
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'circuit_count']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):

View File

@@ -1,3 +1,4 @@
from django.db.models import Count
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -27,12 +28,14 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet):
#
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags')
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Count('circuits')
)
serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilter
@action(detail=True)
def graphs(self, request, pk=None):
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular provider.
"""
@@ -47,7 +50,9 @@ class ProviderViewSet(CustomFieldModelViewSet):
#
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.all()
queryset = CircuitType.objects.annotate(
circuit_count=Count('circuits')
)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilter
@@ -57,7 +62,7 @@ class CircuitTypeViewSet(ModelViewSet):
#
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.select_related('type', 'tenant', 'provider').prefetch_related('tags')
queryset = Circuit.objects.prefetch_related('type', 'tenant', 'provider').prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilter
@@ -67,7 +72,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
#
class CircuitTerminationViewSet(ModelViewSet):
queryset = CircuitTermination.objects.select_related(
queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', 'connected_endpoint__device', 'cable'
)
serializer_class = serializers.CircuitTerminationSerializer

View File

@@ -1,15 +1,15 @@
import django_filters
from django.db.models import Q
from dcim.models import Site
from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from .constants import CIRCUIT_STATUS_CHOICES
from .models import Provider, Circuit, CircuitTermination, CircuitType
class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet):
class ProviderFilter(CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -51,10 +51,10 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
class Meta:
model = CircuitType
fields = ['name', 'slug']
fields = ['id', 'name', 'slug']
class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -87,16 +87,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=CIRCUIT_STATUS_CHOICES,
null_value=None
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='terminations__site',
queryset=Site.objects.all(),
@@ -108,6 +98,17 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Site (slug)',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='terminations__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
tag = TagFilter()
class Meta:

View File

@@ -4,6 +4,7 @@ from taggit.forms import TagField
from dcim.models import Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyForm
from tenancy.forms import TenancyFilterForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
@@ -265,8 +266,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit
]
class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Circuit
field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate']
q = forms.CharField(
required=False,
label='Search'
@@ -292,16 +294,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
widget=StaticSelect2Multiple()
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
null_label='-- None --',
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field="slug",
null_option=True,
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',

View File

@@ -0,0 +1,25 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('circuits', '0014_circuittermination_description'),
('extras', '0019_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='circuit',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='provider',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@@ -6,7 +6,7 @@ from taggit.managers import TaggableManager
from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES
from dcim.fields import ASNField
from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES
@@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']
@@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
object_id_field='obj_id'
)
tags = TaggableManager()
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments',
@@ -270,18 +270,21 @@ class CircuitTermination(CableTermination):
def __str__(self):
return 'Side {}'.format(self.get_term_side_display())
def log_change(self, user, request_id, action):
"""
Reference the parent circuit when recording the change.
"""
ObjectChange(
user=user,
request_id=request_id,
def to_objectchange(self, action):
# Annotate the parent Circuit
try:
related_object = self.circuit
except Circuit.DoesNotExist:
# Parent circuit has been deleted
related_object = None
return ObjectChange(
changed_object=self,
related_object=self.circuit,
object_repr=str(self),
action=action,
related_object=related_object,
object_data=serialize_object(self)
).save()
)
@property
def parent(self):
@@ -290,6 +293,6 @@ class CircuitTermination(CableTermination):
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side)
return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
except CircuitTermination.DoesNotExist:
return None

View File

@@ -10,4 +10,8 @@ def update_circuit(instance, **kwargs):
"""
When a CircuitTermination has been modified, update the last_updated time of its parent Circuit.
"""
Circuit.objects.filter(pk=instance.circuit_id).update(last_updated=timezone.now())
circuits = Circuit.objects.filter(pk=instance.circuit_id)
time = timezone.now()
for circuit in circuits:
circuit.last_updated = time
circuit.save()

View File

@@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -20,15 +20,6 @@ STATUS_LABEL = """
"""
class CircuitTerminationColumn(tables.Column):
def render(self, value):
return mark_safe('<a href="{}">{}</a>'.format(
value.site.get_absolute_url(),
value.site
))
#
# Providers
#
@@ -77,9 +68,13 @@ class CircuitTable(BaseTable):
provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')])
status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status')
tenant = tables.TemplateColumn(template_code=COL_TENANT)
termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side')
termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side')
a_side = tables.Column(
verbose_name='A Side'
)
z_side = tables.Column(
verbose_name='Z Side'
)
class Meta(BaseTable.Meta):
model = Circuit
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description')
fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description')

View File

@@ -61,7 +61,7 @@ class ProviderTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['circuit_count', 'id', 'name', 'slug', 'url']
)
def test_create_provider(self):
@@ -162,7 +162,7 @@ class CircuitTypeTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['circuit_count', 'id', 'name', 'slug', 'url']
)
def test_create_circuittype(self):

View File

@@ -4,13 +4,15 @@ from django.test import Client, TestCase
from django.urls import reverse
from circuits.models import Circuit, CircuitType, Provider
from utilities.testing import create_test_user
class ProviderTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['circuits.view_provider'])
self.client = Client()
self.client.force_login(user)
Provider.objects.bulk_create([
Provider(name='Provider 1', slug='provider-1', asn=65001),
@@ -38,8 +40,9 @@ class ProviderTestCase(TestCase):
class CircuitTypeTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['circuits.view_circuittype'])
self.client = Client()
self.client.force_login(user)
CircuitType.objects.bulk_create([
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
@@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase):
class CircuitTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['circuits.view_circuit'])
self.client = Client()
self.client.force_login(user)
provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
provider.save()
@@ -84,8 +88,8 @@ class CircuitTestCase(TestCase):
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_provider(self):
def test_circuit(self):
provider = Provider.objects.first()
response = self.client.get(provider.get_absolute_url())
circuit = Circuit.objects.first()
response = self.client.get(circuit.get_absolute_url())
self.assertEqual(response.status_code, 200)

View File

@@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import path
from dcim.views import CableCreateView, CableTraceView
from extras.views import ObjectChangeLogView
@@ -9,41 +9,42 @@ app_name = 'circuits'
urlpatterns = [
# Providers
url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'),
url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'),
url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'),
url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
url(r'^providers/(?P<slug>[\w-]+)/$', views.ProviderView.as_view(), name='provider'),
url(r'^providers/(?P<slug>[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'),
url(r'^providers/(?P<slug>[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'),
url(r'^providers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
path(r'providers/', views.ProviderListView.as_view(), name='provider_list'),
path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
path(r'providers/<slug:slug>/', views.ProviderView.as_view(), name='provider'),
path(r'providers/<slug:slug>/edit/', views.ProviderEditView.as_view(), name='provider_edit'),
path(r'providers/<slug:slug>/delete/', views.ProviderDeleteView.as_view(), name='provider_delete'),
path(r'providers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}),
# Circuit types
url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'),
url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
url(r'^circuit-types/(?P<slug>[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
url(r'^circuit-types/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path(r'circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path(r'circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'),
url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'),
url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'),
url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
url(r'^circuits/(?P<pk>\d+)/$', views.CircuitView.as_view(), name='circuit'),
url(r'^circuits/(?P<pk>\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'),
url(r'^circuits/(?P<pk>\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'),
url(r'^circuits/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
url(r'^circuits/(?P<pk>\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'),
path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
path(r'circuits/<int:pk>/', views.CircuitView.as_view(), name='circuit'),
path(r'circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path(r'circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path(r'circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path(r'circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
# Circuit terminations
url(r'^circuits/(?P<circuit>\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
url(r'^circuit-terminations/(?P<pk>\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
url(r'^circuit-terminations/(?P<pk>\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
url(r'^circuit-terminations/(?P<termination_a_id>\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
url(r'^circuit-terminations/(?P<pk>\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
path(r'circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path(r'circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path(r'circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path(r'circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),
path(r'circuit-terminations/<int:pk>/trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}),
]

View File

@@ -2,7 +2,7 @@ from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count
from django.db.models import Count, OuterRef, Subquery
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
@@ -20,7 +20,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
class ProviderListView(ObjectListView):
class ProviderListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
filter = filters.ProviderFilter
filter_form = forms.ProviderFilterForm
@@ -28,16 +29,13 @@ class ProviderListView(ObjectListView):
template_name = 'circuits/provider_list.html'
class ProviderView(View):
class ProviderView(PermissionRequiredMixin, View):
permission_required = 'circuits.view_provider'
def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(provider=provider).select_related(
'type', 'tenant'
).prefetch_related(
'terminations__site'
)
circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
return render(request, 'circuits/provider.html', {
@@ -93,7 +91,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuit Types
#
class CircuitTypeListView(ObjectListView):
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
table = tables.CircuitTypeTable
template_name = 'circuits/circuittype_list.html'
@@ -128,11 +127,14 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Circuits
#
class CircuitListView(ObjectListView):
queryset = Circuit.objects.select_related(
'provider', 'type', 'tenant'
).prefetch_related(
'terminations__site'
class CircuitListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuit'
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site'
).annotate(
a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]),
z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]),
)
filter = filters.CircuitFilter
filter_form = forms.CircuitFilterForm
@@ -140,17 +142,18 @@ class CircuitListView(ObjectListView):
template_name = 'circuits/circuit_list.html'
class CircuitView(View):
class CircuitView(PermissionRequiredMixin, View):
permission_required = 'circuits.view_circuit'
def get(self, request, pk):
circuit = get_object_or_404(Circuit.objects.select_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.select_related(
circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_A
).first()
termination_z = CircuitTermination.objects.select_related(
termination_z = CircuitTermination.objects.prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=TERM_SIDE_Z
@@ -190,7 +193,7 @@ class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter
table = tables.CircuitTable
form = forms.CircuitBulkEditForm
@@ -199,7 +202,7 @@ class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
filter = filters.CircuitFilter
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'

View File

@@ -3,8 +3,8 @@ from rest_framework import serializers
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
Interface, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate,
Region, Site, VirtualChassis,
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole,
RearPort, RearPortTemplate, Region, Site, VirtualChassis,
)
from utilities.api import ChoiceField, WritableNestedSerializer
@@ -21,7 +21,9 @@ __all__ = [
'NestedInterfaceSerializer',
'NestedManufacturerSerializer',
'NestedPlatformSerializer',
'NestedPowerFeedSerializer',
'NestedPowerOutletSerializer',
'NestedPowerPanelSerializer',
'NestedPowerPortSerializer',
'NestedRackGroupSerializer',
'NestedRackRoleSerializer',
@@ -40,10 +42,11 @@ __all__ = [
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = Region
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'site_count']
class NestedSiteSerializer(WritableNestedSerializer):
@@ -60,26 +63,29 @@ class NestedSiteSerializer(WritableNestedSerializer):
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackGroup
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'rack_count']
class NestedRackRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'rack_count']
class NestedRackSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
fields = ['id', 'url', 'name', 'display_name']
fields = ['id', 'url', 'name', 'display_name', 'device_count']
#
@@ -88,19 +94,21 @@ class NestedRackSerializer(WritableNestedSerializer):
class NestedManufacturerSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
class Meta:
model = Manufacturer
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'devicetype_count']
class NestedDeviceTypeSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer(read_only=True)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name']
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
@@ -125,18 +133,22 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
class NestedDeviceRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedPlatformSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = Platform
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count']
class NestedDeviceSerializer(WritableNestedSerializer):
@@ -216,7 +228,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
class NestedDeviceBaySerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer(read_only=True)
class Meta:
@@ -243,7 +255,29 @@ class NestedCableSerializer(serializers.ModelSerializer):
class NestedVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer()
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'master']
fields = ['id', 'url', 'master', 'member_count']
#
# Power panels/feeds
#
class NestedPowerPanelSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = PowerPanel
fields = ['id', 'url', 'name', 'powerfeed_count']
class NestedPowerFeedSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
class Meta:
model = PowerFeed
fields = ['id', 'url', 'name']

View File

@@ -1,3 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
@@ -6,8 +8,9 @@ from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from extras.api.customfields import CustomFieldModelSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
@@ -35,6 +38,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
)
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_connected_endpoint(self, obj):
"""
Return the appropriate serializer for the type of connected object.
@@ -55,10 +59,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
class RegionSerializer(serializers.ModelSerializer):
parent = NestedRegionSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'parent']
fields = ['id', 'name', 'slug', 'parent', 'site_count']
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -67,19 +72,20 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
tags = TagListSerializerField(required=False)
count_prefixes = serializers.IntegerField(read_only=True)
count_vlans = serializers.IntegerField(read_only=True)
count_racks = serializers.IntegerField(read_only=True)
count_devices = serializers.IntegerField(read_only=True)
count_circuits = serializers.IntegerField(read_only=True)
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
rack_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes',
'count_vlans', 'count_racks', 'count_devices', 'count_circuits',
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
]
@@ -89,17 +95,19 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
class RackGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackGroup
fields = ['id', 'name', 'slug', 'site']
fields = ['id', 'name', 'slug', 'site', 'rack_count']
class RackRoleSerializer(ValidatedModelSerializer):
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
fields = ['id', 'name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color', 'rack_count']
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -112,13 +120,15 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
# Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This
# prevents facility_id from being interpreted as a required field.
@@ -165,23 +175,26 @@ class RackReservationSerializer(ValidatedModelSerializer):
#
class ManufacturerSerializer(ValidatedModelSerializer):
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True)
platform_count = serializers.IntegerField(read_only=True)
class Meta:
model = Manufacturer
fields = ['id', 'name', 'slug']
fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count']
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = [
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count',
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
]
@@ -206,24 +219,34 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = ['id', 'device_type', 'name']
fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
power_port = PowerPortTemplateSerializer(
required=False
)
feed_leg = ChoiceField(
choices=POWERFEED_LEG_CHOICES,
required=False,
allow_null=True
)
class Meta:
model = PowerOutletTemplate
fields = ['id', 'device_type', 'name']
fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
device_type = NestedDeviceTypeSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only']
fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only']
class RearPortTemplateSerializer(ValidatedModelSerializer):
@@ -258,18 +281,25 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
#
class DeviceRoleSerializer(ValidatedModelSerializer):
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role']
fields = ['id', 'name', 'slug', 'color', 'vm_role', 'device_count', 'virtualmachine_count']
class PlatformSerializer(ValidatedModelSerializer):
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = Platform
fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args']
fields = [
'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count',
'virtualmachine_count',
]
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -312,6 +342,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
return data
@swagger_serializer_method(serializer_or_field=NestedDeviceSerializer)
def get_parent_device(self, obj):
try:
device_bay = obj.parent_bay
@@ -334,6 +365,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_config_context(self, obj):
return obj.get_config_context()
@@ -346,8 +378,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
@@ -359,21 +391,33 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = ConsolePort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status',
'cable', 'tags',
]
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
power_port = NestedPowerPortSerializer(
required=False
)
feed_leg = ChoiceField(
choices=POWERFEED_LEG_CHOICES,
required=False,
allow_null=True
)
cable = NestedCableSerializer(
read_only=True
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerOutlet
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
@@ -385,14 +429,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = PowerPort
fields = [
'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
'tags',
'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False)
type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
# TODO: Remove in v2.7 (backward-compatibility for form_factor)
form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
@@ -408,9 +454,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses',
'id', 'device', 'name', 'type', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()
@@ -434,7 +480,7 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data)
class RearPortSerializer(ValidatedModelSerializer):
class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
cable = NestedCableSerializer(read_only=True)
@@ -456,7 +502,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'name']
class FrontPortSerializer(ValidatedModelSerializer):
class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer()
type = ChoiceField(choices=PORT_TYPE_CHOICES)
rear_port = FrontPortRearPortSerializer()
@@ -475,7 +521,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'installed_device', 'tags']
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
#
@@ -502,8 +548,12 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
#
class CableSerializer(ValidatedModelSerializer):
termination_a_type = ContentTypeField()
termination_b_type = ContentTypeField()
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
@@ -531,9 +581,11 @@ class CableSerializer(ValidatedModelSerializer):
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_a(self, obj):
return self._get_termination(obj, 'a')
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_termination_b(self, obj):
return self._get_termination(obj, 'b')
@@ -564,6 +616,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
model = Interface
fields = ['interface_a', 'interface_b', 'connection_status']
@swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer)
def get_interface_a(self, obj):
context = {'request': self.context['request']}
return NestedInterfaceSerializer(instance=obj, context=context).data
@@ -576,7 +629,61 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain', 'tags']
fields = ['id', 'master', 'domain', 'tags', 'member_count']
#
# Power panels
#
class PowerPanelSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
required=False,
allow_null=True,
default=None
)
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = PowerPanel
fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
required=False,
allow_null=True,
default=None
)
type = ChoiceField(
choices=POWERFEED_TYPE_CHOICES,
default=POWERFEED_TYPE_PRIMARY
)
status = ChoiceField(
choices=POWERFEED_STATUS_CHOICES,
default=POWERFEED_STATUS_ACTIVE
)
supply = ChoiceField(
choices=POWERFEED_SUPPLY_CHOICES,
default=POWERFEED_SUPPLY_AC
)
phase = ChoiceField(
choices=POWERFEED_PHASE_CHOICES,
default=POWERFEED_PHASE_SINGLE
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerFeed
fields = [
'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet)
# Virtual chassis
router.register(r'virtual-chassis', views.VirtualChassisViewSet)
# Power
router.register(r'power-panels', views.PowerPanelViewSet)
router.register(r'power-feeds', views.PowerFeedViewSet)
# Miscellaneous
router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device')

View File

@@ -1,7 +1,7 @@
from collections import OrderedDict
from django.conf import settings
from django.db.models import F, Q
from django.db.models import Count, F
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
@@ -12,19 +12,25 @@ from rest_framework.mixins import ListModelMixin
from rest_framework.response import Response
from rest_framework.viewsets import GenericViewSet, ViewSet
from circuits.models import Circuit
from dcim import filters
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
from extras.api.serializers import RenderedGraphSerializer
from extras.api.views import CustomFieldModelViewSet
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph
from ipam.models import Prefix, VLAN
from utilities.api import (
get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable,
)
from utilities.utils import get_subquery
from virtualization.models import VirtualMachine
from . import serializers
from .exceptions import MissingFilterException
@@ -35,14 +41,16 @@ from .exceptions import MissingFilterException
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(Cable, ['length_unit', 'status', 'type']),
(Cable, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']),
(ConsolePort, ['connection_status']),
(Device, ['face', 'status']),
(DeviceType, ['subdevice_role']),
(FrontPort, ['type']),
(FrontPortTemplate, ['type']),
(Interface, ['form_factor', 'mode']),
(InterfaceTemplate, ['form_factor']),
(Interface, ['type', 'mode']),
(InterfaceTemplate, ['type']),
(PowerOutlet, ['feed_leg']),
(PowerOutletTemplate, ['feed_leg']),
(PowerPort, ['connection_status']),
(Rack, ['outer_unit', 'status', 'type', 'width']),
(RearPort, ['type']),
@@ -90,7 +98,9 @@ class CableTraceMixin(object):
#
class RegionViewSet(ModelViewSet):
queryset = Region.objects.all()
queryset = Region.objects.annotate(
site_count=Count('sites')
)
serializer_class = serializers.RegionSerializer
filterset_class = filters.RegionFilter
@@ -100,12 +110,21 @@ class RegionViewSet(ModelViewSet):
#
class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
queryset = Site.objects.prefetch_related(
'region', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'site'),
rack_count=get_subquery(Rack, 'site'),
prefix_count=get_subquery(Prefix, 'site'),
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
)
serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilter
@action(detail=True)
def graphs(self, request, pk=None):
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular site.
"""
@@ -120,7 +139,9 @@ class SiteViewSet(CustomFieldModelViewSet):
#
class RackGroupViewSet(ModelViewSet):
queryset = RackGroup.objects.select_related('site')
queryset = RackGroup.objects.prefetch_related('site').annotate(
rack_count=Count('racks')
)
serializer_class = serializers.RackGroupSerializer
filterset_class = filters.RackGroupFilter
@@ -130,7 +151,9 @@ class RackGroupViewSet(ModelViewSet):
#
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.all()
queryset = RackRole.objects.annotate(
rack_count=Count('racks')
)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilter
@@ -140,7 +163,12 @@ class RackRoleViewSet(ModelViewSet):
#
class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
queryset = Rack.objects.prefetch_related(
'site', 'group__site', 'role', 'tenant', 'tags'
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilter
@@ -175,7 +203,7 @@ class RackViewSet(CustomFieldModelViewSet):
#
class RackReservationViewSet(ModelViewSet):
queryset = RackReservation.objects.select_related('rack', 'user', 'tenant')
queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant')
serializer_class = serializers.RackReservationSerializer
filterset_class = filters.RackReservationFilter
@@ -189,7 +217,11 @@ class RackReservationViewSet(ModelViewSet):
#
class ManufacturerViewSet(ModelViewSet):
queryset = Manufacturer.objects.all()
queryset = Manufacturer.objects.annotate(
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilter
@@ -199,7 +231,9 @@ class ManufacturerViewSet(ModelViewSet):
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
device_count=Count('instances')
)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilter
@@ -209,49 +243,49 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
#
class ConsolePortTemplateViewSet(ModelViewSet):
queryset = ConsolePortTemplate.objects.select_related('device_type__manufacturer')
queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsolePortTemplateSerializer
filterset_class = filters.ConsolePortTemplateFilter
class ConsoleServerPortTemplateViewSet(ModelViewSet):
queryset = ConsoleServerPortTemplate.objects.select_related('device_type__manufacturer')
queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.ConsoleServerPortTemplateSerializer
filterset_class = filters.ConsoleServerPortTemplateFilter
class PowerPortTemplateViewSet(ModelViewSet):
queryset = PowerPortTemplate.objects.select_related('device_type__manufacturer')
queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerPortTemplateSerializer
filterset_class = filters.PowerPortTemplateFilter
class PowerOutletTemplateViewSet(ModelViewSet):
queryset = PowerOutletTemplate.objects.select_related('device_type__manufacturer')
queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.PowerOutletTemplateSerializer
filterset_class = filters.PowerOutletTemplateFilter
class InterfaceTemplateViewSet(ModelViewSet):
queryset = InterfaceTemplate.objects.select_related('device_type__manufacturer')
queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.InterfaceTemplateSerializer
filterset_class = filters.InterfaceTemplateFilter
class FrontPortTemplateViewSet(ModelViewSet):
queryset = FrontPortTemplate.objects.select_related('device_type__manufacturer')
queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.FrontPortTemplateSerializer
filterset_class = filters.FrontPortTemplateFilter
class RearPortTemplateViewSet(ModelViewSet):
queryset = RearPortTemplate.objects.select_related('device_type__manufacturer')
queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.RearPortTemplateSerializer
filterset_class = filters.RearPortTemplateFilter
class DeviceBayTemplateViewSet(ModelViewSet):
queryset = DeviceBayTemplate.objects.select_related('device_type__manufacturer')
queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
serializer_class = serializers.DeviceBayTemplateSerializer
filterset_class = filters.DeviceBayTemplateFilter
@@ -261,7 +295,10 @@ class DeviceBayTemplateViewSet(ModelViewSet):
#
class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.all()
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilter
@@ -271,7 +308,10 @@ class DeviceRoleViewSet(ModelViewSet):
#
class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.all()
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilter
@@ -281,26 +321,42 @@ class PlatformViewSet(ModelViewSet):
#
class DeviceViewSet(CustomFieldModelViewSet):
queryset = Device.objects.select_related(
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master',
).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
)
filterset_class = filters.DeviceFilter
def get_serializer_class(self):
"""
Include rendered config context when retrieving a single Device.
Select the specific serializer based on the request context.
If the `brief` query param equates to True, return the NestedDeviceSerializer
If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer
Else, return the DeviceWithConfigContextSerializer
"""
if self.action == 'retrieve':
return serializers.DeviceWithConfigContextSerializer
request = self.get_serializer_context()['request']
if request.query_params.get('brief', False):
return serializers.NestedDeviceSerializer
return serializers.DeviceSerializer
elif 'config_context' in request.query_params.get('exclude', []):
return serializers.DeviceSerializer
return serializers.DeviceWithConfigContextSerializer
@action(detail=True)
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(Device, pk=pk)
queryset = Graph.objects.filter(type=GRAPH_TYPE_DEVICE)
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@action(detail=True, url_path='napalm')
def napalm(self, request, pk):
@@ -379,56 +435,42 @@ class DeviceViewSet(CustomFieldModelViewSet):
#
class ConsolePortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsolePort.objects.select_related(
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
queryset = ConsolePort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.select_related(
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
queryset = ConsoleServerPort.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerPort.objects.select_related(
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable', 'tags'
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerPortFilter
class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
queryset = PowerOutlet.objects.select_related(
'device', 'connected_endpoint__device', 'cable'
).prefetch_related(
'tags'
)
queryset = PowerOutlet.objects.prefetch_related('device', 'connected_endpoint__device', 'cable', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filters.PowerOutletFilter
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
queryset = Interface.objects.select_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable'
).prefetch_related(
'ip_addresses', 'tags'
queryset = Interface.objects.prefetch_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable', 'ip_addresses', 'tags'
).filter(
device__isnull=False
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filters.InterfaceFilter
@action(detail=True)
def graphs(self, request, pk=None):
def graphs(self, request, pk):
"""
A convenience method for rendering graphs for a particular interface.
"""
@@ -439,33 +481,25 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
class FrontPortViewSet(ModelViewSet):
queryset = FrontPort.objects.select_related(
'device__device_type__manufacturer', 'rear_port', 'cable'
).prefetch_related(
'tags'
)
queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags')
serializer_class = serializers.FrontPortSerializer
filterset_class = filters.FrontPortFilter
class RearPortViewSet(ModelViewSet):
queryset = RearPort.objects.select_related(
'device__device_type__manufacturer', 'cable'
).prefetch_related(
'tags'
)
queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags')
serializer_class = serializers.RearPortSerializer
filterset_class = filters.RearPortFilter
class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer
filterset_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags')
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer
filterset_class = filters.InventoryItemFilter
@@ -475,7 +509,7 @@ class InventoryItemViewSet(ModelViewSet):
#
class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = ConsolePort.objects.select_related(
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
@@ -485,17 +519,17 @@ class ConsoleConnectionViewSet(ListModelMixin, GenericViewSet):
class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = PowerPort.objects.select_related(
queryset = PowerPort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
_connected_poweroutlet__isnull=False
)
serializer_class = serializers.PowerPortSerializer
filterset_class = filters.PowerConnectionFilter
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.select_related(
queryset = Interface.objects.prefetch_related(
'device', '_connected_interface__device'
).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -523,8 +557,35 @@ class CableViewSet(ModelViewSet):
#
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags')
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=Count('members')
)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilter
#
# Power panels
#
class PowerPanelViewSet(ModelViewSet):
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilter
#
# Power feeds
#
class PowerFeedViewSet(CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack', 'tags')
serializer_class = serializers.PowerFeedSerializer
filterset_class = filters.PowerFeedFilter
#

View File

@@ -66,198 +66,204 @@ IFACE_ORDERING_CHOICES = [
[IFACE_ORDERING_NAME, 'Name (alphabetically)']
]
# Interface form factors
# Interface types
# Virtual
IFACE_FF_VIRTUAL = 0
IFACE_FF_LAG = 200
IFACE_TYPE_VIRTUAL = 0
IFACE_TYPE_LAG = 200
# Ethernet
IFACE_FF_100ME_FIXED = 800
IFACE_FF_1GE_FIXED = 1000
IFACE_FF_1GE_GBIC = 1050
IFACE_FF_1GE_SFP = 1100
IFACE_FF_10GE_FIXED = 1150
IFACE_FF_10GE_CX4 = 1170
IFACE_FF_10GE_SFP_PLUS = 1200
IFACE_FF_10GE_XFP = 1300
IFACE_FF_10GE_XENPAK = 1310
IFACE_FF_10GE_X2 = 1320
IFACE_FF_25GE_SFP28 = 1350
IFACE_FF_40GE_QSFP_PLUS = 1400
IFACE_FF_100GE_CFP = 1500
IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520
IFACE_FF_100GE_CPAK = 1550
IFACE_FF_100GE_QSFP28 = 1600
IFACE_FF_200GE_CFP2 = 1650
IFACE_FF_200GE_QSFP56 = 1700
IFACE_FF_400GE_QSFP_DD = 1750
IFACE_TYPE_100ME_FIXED = 800
IFACE_TYPE_1GE_FIXED = 1000
IFACE_TYPE_1GE_GBIC = 1050
IFACE_TYPE_1GE_SFP = 1100
IFACE_TYPE_2GE_FIXED = 1120
IFACE_TYPE_5GE_FIXED = 1130
IFACE_TYPE_10GE_FIXED = 1150
IFACE_TYPE_10GE_CX4 = 1170
IFACE_TYPE_10GE_SFP_PLUS = 1200
IFACE_TYPE_10GE_XFP = 1300
IFACE_TYPE_10GE_XENPAK = 1310
IFACE_TYPE_10GE_X2 = 1320
IFACE_TYPE_25GE_SFP28 = 1350
IFACE_TYPE_40GE_QSFP_PLUS = 1400
IFACE_TYPE_50GE_QSFP28 = 1420
IFACE_TYPE_100GE_CFP = 1500
IFACE_TYPE_100GE_CFP2 = 1510
IFACE_TYPE_100GE_CFP4 = 1520
IFACE_TYPE_100GE_CPAK = 1550
IFACE_TYPE_100GE_QSFP28 = 1600
IFACE_TYPE_200GE_CFP2 = 1650
IFACE_TYPE_200GE_QSFP56 = 1700
IFACE_TYPE_400GE_QSFP_DD = 1750
# Wireless
IFACE_FF_80211A = 2600
IFACE_FF_80211G = 2610
IFACE_FF_80211N = 2620
IFACE_FF_80211AC = 2630
IFACE_FF_80211AD = 2640
IFACE_TYPE_80211A = 2600
IFACE_TYPE_80211G = 2610
IFACE_TYPE_80211N = 2620
IFACE_TYPE_80211AC = 2630
IFACE_TYPE_80211AD = 2640
# Cellular
IFACE_FF_GSM = 2810
IFACE_FF_CDMA = 2820
IFACE_FF_LTE = 2830
IFACE_TYPE_GSM = 2810
IFACE_TYPE_CDMA = 2820
IFACE_TYPE_LTE = 2830
# SONET
IFACE_FF_SONET_OC3 = 6100
IFACE_FF_SONET_OC12 = 6200
IFACE_FF_SONET_OC48 = 6300
IFACE_FF_SONET_OC192 = 6400
IFACE_FF_SONET_OC768 = 6500
IFACE_FF_SONET_OC1920 = 6600
IFACE_FF_SONET_OC3840 = 6700
IFACE_TYPE_SONET_OC3 = 6100
IFACE_TYPE_SONET_OC12 = 6200
IFACE_TYPE_SONET_OC48 = 6300
IFACE_TYPE_SONET_OC192 = 6400
IFACE_TYPE_SONET_OC768 = 6500
IFACE_TYPE_SONET_OC1920 = 6600
IFACE_TYPE_SONET_OC3840 = 6700
# Fibrechannel
IFACE_FF_1GFC_SFP = 3010
IFACE_FF_2GFC_SFP = 3020
IFACE_FF_4GFC_SFP = 3040
IFACE_FF_8GFC_SFP_PLUS = 3080
IFACE_FF_16GFC_SFP_PLUS = 3160
IFACE_FF_32GFC_SFP28 = 3320
IFACE_FF_128GFC_QSFP28 = 3400
IFACE_TYPE_1GFC_SFP = 3010
IFACE_TYPE_2GFC_SFP = 3020
IFACE_TYPE_4GFC_SFP = 3040
IFACE_TYPE_8GFC_SFP_PLUS = 3080
IFACE_TYPE_16GFC_SFP_PLUS = 3160
IFACE_TYPE_32GFC_SFP28 = 3320
IFACE_TYPE_128GFC_QSFP28 = 3400
# Serial
IFACE_FF_T1 = 4000
IFACE_FF_E1 = 4010
IFACE_FF_T3 = 4040
IFACE_FF_E3 = 4050
IFACE_TYPE_T1 = 4000
IFACE_TYPE_E1 = 4010
IFACE_TYPE_T3 = 4040
IFACE_TYPE_E3 = 4050
# Stacking
IFACE_FF_STACKWISE = 5000
IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150
IFACE_FF_JUNIPER_VCP = 5200
IFACE_FF_SUMMITSTACK = 5300
IFACE_FF_SUMMITSTACK128 = 5310
IFACE_FF_SUMMITSTACK256 = 5320
IFACE_FF_SUMMITSTACK512 = 5330
IFACE_TYPE_STACKWISE = 5000
IFACE_TYPE_STACKWISE_PLUS = 5050
IFACE_TYPE_FLEXSTACK = 5100
IFACE_TYPE_FLEXSTACK_PLUS = 5150
IFACE_TYPE_JUNIPER_VCP = 5200
IFACE_TYPE_SUMMITSTACK = 5300
IFACE_TYPE_SUMMITSTACK128 = 5310
IFACE_TYPE_SUMMITSTACK256 = 5320
IFACE_TYPE_SUMMITSTACK512 = 5330
# Other
IFACE_FF_OTHER = 32767
IFACE_TYPE_OTHER = 32767
IFACE_FF_CHOICES = [
IFACE_TYPE_CHOICES = [
[
'Virtual interfaces',
[
[IFACE_FF_VIRTUAL, 'Virtual'],
[IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
[IFACE_TYPE_VIRTUAL, 'Virtual'],
[IFACE_TYPE_LAG, 'Link Aggregation Group (LAG)'],
],
],
[
'Ethernet (fixed)',
[
[IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'],
[IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'],
[IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'],
[IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'],
[IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'],
[IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'],
[IFACE_TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'],
[IFACE_TYPE_5GE_FIXED, '5GBASE-T (5GE)'],
[IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'],
[IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'],
]
],
[
'Ethernet (modular)',
[
[IFACE_FF_1GE_GBIC, 'GBIC (1GE)'],
[IFACE_FF_1GE_SFP, 'SFP (1GE)'],
[IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'],
[IFACE_FF_10GE_XFP, 'XFP (10GE)'],
[IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'],
[IFACE_FF_10GE_X2, 'X2 (10GE)'],
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
[IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
[IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
[IFACE_TYPE_1GE_GBIC, 'GBIC (1GE)'],
[IFACE_TYPE_1GE_SFP, 'SFP (1GE)'],
[IFACE_TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'],
[IFACE_TYPE_10GE_XFP, 'XFP (10GE)'],
[IFACE_TYPE_10GE_XENPAK, 'XENPAK (10GE)'],
[IFACE_TYPE_10GE_X2, 'X2 (10GE)'],
[IFACE_TYPE_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_TYPE_50GE_QSFP28, 'QSFP28 (50GE)'],
[IFACE_TYPE_100GE_CFP, 'CFP (100GE)'],
[IFACE_TYPE_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_TYPE_200GE_CFP2, 'CFP2 (200GE)'],
[IFACE_TYPE_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
[IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
[IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
]
],
[
'Wireless',
[
[IFACE_FF_80211A, 'IEEE 802.11a'],
[IFACE_FF_80211G, 'IEEE 802.11b/g'],
[IFACE_FF_80211N, 'IEEE 802.11n'],
[IFACE_FF_80211AC, 'IEEE 802.11ac'],
[IFACE_FF_80211AD, 'IEEE 802.11ad'],
[IFACE_TYPE_80211A, 'IEEE 802.11a'],
[IFACE_TYPE_80211G, 'IEEE 802.11b/g'],
[IFACE_TYPE_80211N, 'IEEE 802.11n'],
[IFACE_TYPE_80211AC, 'IEEE 802.11ac'],
[IFACE_TYPE_80211AD, 'IEEE 802.11ad'],
]
],
[
'Cellular',
[
[IFACE_FF_GSM, 'GSM'],
[IFACE_FF_CDMA, 'CDMA'],
[IFACE_FF_LTE, 'LTE'],
[IFACE_TYPE_GSM, 'GSM'],
[IFACE_TYPE_CDMA, 'CDMA'],
[IFACE_TYPE_LTE, 'LTE'],
]
],
[
'SONET',
[
[IFACE_FF_SONET_OC3, 'OC-3/STM-1'],
[IFACE_FF_SONET_OC12, 'OC-12/STM-4'],
[IFACE_FF_SONET_OC48, 'OC-48/STM-16'],
[IFACE_FF_SONET_OC192, 'OC-192/STM-64'],
[IFACE_FF_SONET_OC768, 'OC-768/STM-256'],
[IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'],
[IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'],
[IFACE_TYPE_SONET_OC3, 'OC-3/STM-1'],
[IFACE_TYPE_SONET_OC12, 'OC-12/STM-4'],
[IFACE_TYPE_SONET_OC48, 'OC-48/STM-16'],
[IFACE_TYPE_SONET_OC192, 'OC-192/STM-64'],
[IFACE_TYPE_SONET_OC768, 'OC-768/STM-256'],
[IFACE_TYPE_SONET_OC1920, 'OC-1920/STM-640'],
[IFACE_TYPE_SONET_OC3840, 'OC-3840/STM-1234'],
]
],
[
'FibreChannel',
[
[IFACE_FF_1GFC_SFP, 'SFP (1GFC)'],
[IFACE_FF_2GFC_SFP, 'SFP (2GFC)'],
[IFACE_FF_4GFC_SFP, 'SFP (4GFC)'],
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
[IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
[IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
[IFACE_TYPE_1GFC_SFP, 'SFP (1GFC)'],
[IFACE_TYPE_2GFC_SFP, 'SFP (2GFC)'],
[IFACE_TYPE_4GFC_SFP, 'SFP (4GFC)'],
[IFACE_TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
[IFACE_TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
[IFACE_TYPE_32GFC_SFP28, 'SFP28 (32GFC)'],
[IFACE_TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'],
]
],
[
'Serial',
[
[IFACE_FF_T1, 'T1 (1.544 Mbps)'],
[IFACE_FF_E1, 'E1 (2.048 Mbps)'],
[IFACE_FF_T3, 'T3 (45 Mbps)'],
[IFACE_FF_E3, 'E3 (34 Mbps)'],
[IFACE_TYPE_T1, 'T1 (1.544 Mbps)'],
[IFACE_TYPE_E1, 'E1 (2.048 Mbps)'],
[IFACE_TYPE_T3, 'T3 (45 Mbps)'],
[IFACE_TYPE_E3, 'E3 (34 Mbps)'],
]
],
[
'Stacking',
[
[IFACE_FF_STACKWISE, 'Cisco StackWise'],
[IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'],
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
[IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
[IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
[IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
[IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
[IFACE_TYPE_STACKWISE, 'Cisco StackWise'],
[IFACE_TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'],
[IFACE_TYPE_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_TYPE_JUNIPER_VCP, 'Juniper VCP'],
[IFACE_TYPE_SUMMITSTACK, 'Extreme SummitStack'],
[IFACE_TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'],
[IFACE_TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'],
[IFACE_TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'],
]
],
[
'Other',
[
[IFACE_FF_OTHER, 'Other'],
[IFACE_TYPE_OTHER, 'Other'],
]
],
]
VIRTUAL_IFACE_TYPES = [
IFACE_FF_VIRTUAL,
IFACE_FF_LAG,
IFACE_TYPE_VIRTUAL,
IFACE_TYPE_LAG,
]
WIRELESS_IFACE_TYPES = [
IFACE_FF_80211A,
IFACE_FF_80211G,
IFACE_FF_80211N,
IFACE_FF_80211AC,
IFACE_FF_80211AD,
IFACE_TYPE_80211A,
IFACE_TYPE_80211G,
IFACE_TYPE_80211N,
IFACE_TYPE_80211AC,
IFACE_TYPE_80211AD,
]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
@@ -274,6 +280,7 @@ IFACE_MODE_CHOICES = [
# Pass-through port types
PORT_TYPE_8P8C = 1000
PORT_TYPE_110_PUNCH = 1100
PORT_TYPE_BNC = 1200
PORT_TYPE_ST = 2000
PORT_TYPE_SC = 2100
PORT_TYPE_SC_APC = 2110
@@ -290,6 +297,7 @@ PORT_TYPE_CHOICES = [
[
[PORT_TYPE_8P8C, '8P8C'],
[PORT_TYPE_110_PUNCH, '110 Punch'],
[PORT_TYPE_BNC, 'BNC'],
],
],
[
@@ -316,6 +324,7 @@ DEVICE_STATUS_PLANNED = 2
DEVICE_STATUS_STAGED = 3
DEVICE_STATUS_FAILED = 4
DEVICE_STATUS_INVENTORY = 5
DEVICE_STATUS_DECOMMISSIONING = 6
DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_ACTIVE, 'Active'],
[DEVICE_STATUS_OFFLINE, 'Offline'],
@@ -323,6 +332,7 @@ DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_STAGED, 'Staged'],
[DEVICE_STATUS_FAILED, 'Failed'],
[DEVICE_STATUS_INVENTORY, 'Inventory'],
[DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'],
]
# Site statuses
@@ -343,6 +353,7 @@ STATUS_CLASSES = {
3: 'primary',
4: 'danger',
5: 'default',
6: 'warning',
}
# Console/power/interface connection statuses
@@ -355,7 +366,7 @@ CONNECTION_STATUS_CHOICES = [
# Cable endpoint types
CABLE_TERMINATION_TYPES = [
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
]
# Cable types
@@ -367,6 +378,7 @@ CABLE_TYPE_CAT6A = 1610
CABLE_TYPE_CAT7 = 1700
CABLE_TYPE_DAC_ACTIVE = 1800
CABLE_TYPE_DAC_PASSIVE = 1810
CABLE_TYPE_COAXIAL = 1900
CABLE_TYPE_MMF = 3000
CABLE_TYPE_MMF_OM1 = 3010
CABLE_TYPE_MMF_OM2 = 3020
@@ -388,6 +400,7 @@ CABLE_TYPE_CHOICES = (
(CABLE_TYPE_CAT7, 'CAT7'),
(CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
(CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
(CABLE_TYPE_COAXIAL, 'Coaxial'),
),
),
(
@@ -420,7 +433,7 @@ CABLE_TERMINATION_TYPE_CHOICES = {
COMPATIBLE_TERMINATION_TYPES = {
'consoleport': ['consoleserverport', 'frontport', 'rearport'],
'consoleserverport': ['consoleport', 'frontport', 'rearport'],
'powerport': ['poweroutlet'],
'powerport': ['poweroutlet', 'powerfeed'],
'poweroutlet': ['powerport'],
'interface': ['interface', 'circuittermination', 'frontport', 'rearport'],
'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'],
@@ -443,3 +456,41 @@ RACK_DIMENSION_UNIT_CHOICES = (
(LENGTH_UNIT_MILLIMETER, 'Millimeters'),
(LENGTH_UNIT_INCH, 'Inches'),
)
# Power feeds
POWERFEED_TYPE_PRIMARY = 1
POWERFEED_TYPE_REDUNDANT = 2
POWERFEED_TYPE_CHOICES = (
(POWERFEED_TYPE_PRIMARY, 'Primary'),
(POWERFEED_TYPE_REDUNDANT, 'Redundant'),
)
POWERFEED_SUPPLY_AC = 1
POWERFEED_SUPPLY_DC = 2
POWERFEED_SUPPLY_CHOICES = (
(POWERFEED_SUPPLY_AC, 'AC'),
(POWERFEED_SUPPLY_DC, 'DC'),
)
POWERFEED_PHASE_SINGLE = 1
POWERFEED_PHASE_3PHASE = 3
POWERFEED_PHASE_CHOICES = (
(POWERFEED_PHASE_SINGLE, 'Single phase'),
(POWERFEED_PHASE_3PHASE, 'Three-phase'),
)
POWERFEED_STATUS_OFFLINE = 0
POWERFEED_STATUS_ACTIVE = 1
POWERFEED_STATUS_PLANNED = 2
POWERFEED_STATUS_FAILED = 4
POWERFEED_STATUS_CHOICES = (
(POWERFEED_STATUS_ACTIVE, 'Active'),
(POWERFEED_STATUS_OFFLINE, 'Offline'),
(POWERFEED_STATUS_PLANNED, 'Planned'),
(POWERFEED_STATUS_FAILED, 'Failed'),
)
POWERFEED_LEG_A = 1
POWERFEED_LEG_B = 2
POWERFEED_LEG_C = 3
POWERFEED_LEG_CHOICES = (
(POWERFEED_LEG_A, 'A'),
(POWERFEED_LEG_B, 'B'),
(POWERFEED_LEG_C, 'C'),
)

View File

@@ -31,7 +31,7 @@ class MACAddressField(models.Field):
try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError as e:
raise ValidationError(e)
raise ValidationError("Invalid MAC address format: {}".format(value))
def db_type(self, connection):
return 'macaddr'

View File

@@ -1,22 +1,24 @@
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 extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES
from utilities.filters import (
NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .constants import *
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
@@ -34,10 +36,10 @@ class RegionFilter(NameSlugSearchFilterSet):
class Meta:
model = Region
fields = ['name', 'slug']
fields = ['id', 'name', 'slug']
class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -61,21 +63,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Region (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
tag = TagFilter()
class Meta:
model = Site
fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email']
fields = [
'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email',
]
def search(self, queryset, name, value):
if not value.strip():
@@ -112,17 +107,17 @@ class RackGroupFilter(NameSlugSearchFilterSet):
class Meta:
model = RackGroup
fields = ['site_id', 'name', 'slug']
fields = ['id', 'name', 'slug']
class RackRoleFilter(NameSlugSearchFilterSet):
class Meta:
model = RackRole
fields = ['name', 'slug', 'color']
fields = ['id', 'name', 'slug', 'color']
class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -131,7 +126,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
facility_id = NullableCharFieldFilter()
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -152,16 +146,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
status = django_filters.MultipleChoiceFilter(
choices=RACK_STATUS_CHOICES,
null_value=None
@@ -176,14 +160,16 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
to_field_name='slug',
label='Role (slug)',
)
asset_tag = NullableCharFieldFilter()
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
tag = TagFilter()
class Meta:
model = Rack
fields = [
'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit',
'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit',
]
def search(self, queryset, name, value):
@@ -198,7 +184,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet):
)
class RackReservationFilter(django_filters.FilterSet):
class RackReservationFilter(TenancyFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -233,16 +219,6 @@ class RackReservationFilter(django_filters.FilterSet):
to_field_name='slug',
label='Group',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label='User (ID)',
@@ -273,7 +249,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
class Meta:
model = Manufacturer
fields = ['name', 'slug']
fields = ['id', 'name', 'slug']
class DeviceTypeFilter(CustomFieldFilterSet):
@@ -371,63 +347,63 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsolePortTemplate
fields = ['name']
fields = ['id', 'name']
class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = ConsoleServerPortTemplate
fields = ['name']
fields = ['id', 'name']
class PowerPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerPortTemplate
fields = ['name']
fields = ['id', 'name', 'maximum_draw', 'allocated_draw']
class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = PowerOutletTemplate
fields = ['name']
fields = ['id', 'name', 'feed_leg']
class InterfaceTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = InterfaceTemplate
fields = ['name', 'form_factor', 'mgmt_only']
fields = ['id', 'name', 'type', 'mgmt_only']
class FrontPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = FrontPortTemplate
fields = ['name', 'type']
fields = ['id', 'name', 'type']
class RearPortTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = RearPortTemplate
fields = ['name', 'type']
fields = ['id', 'name', 'type', 'positions']
class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet):
class Meta:
model = DeviceBayTemplate
fields = ['name']
fields = ['id', 'name']
class DeviceRoleFilter(NameSlugSearchFilterSet):
class Meta:
model = DeviceRole
fields = ['name', 'slug', 'color', 'vm_role']
fields = ['id', 'name', 'slug', 'color', 'vm_role']
class PlatformFilter(NameSlugSearchFilterSet):
@@ -445,10 +421,10 @@ class PlatformFilter(NameSlugSearchFilterSet):
class Meta:
model = Platform
fields = ['name', 'slug']
fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilter(CustomFieldFilterSet):
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -483,16 +459,6 @@ class DeviceFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Role (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
queryset=Tenant.objects.all(),
label='Tenant (ID)',
)
tenant = django_filters.ModelMultipleChoiceFilter(
field_name='tenant__slug',
queryset=Tenant.objects.all(),
to_field_name='slug',
label='Tenant (slug)',
)
platform_id = django_filters.ModelMultipleChoiceFilter(
queryset=Platform.objects.all(),
label='Platform (ID)',
@@ -503,8 +469,6 @@ class DeviceFilter(CustomFieldFilterSet):
to_field_name='slug',
label='Platform (slug)',
)
name = NullableCharFieldFilter()
asset_tag = NullableCharFieldFilter()
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
@@ -536,10 +500,6 @@ class DeviceFilter(CustomFieldFilterSet):
queryset=Rack.objects.all(),
label='Rack (ID)',
)
position = django_filters.ChoiceFilter(
choices=DEVICE_POSITION_CHOICES,
null_label='Non-racked'
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
@@ -558,10 +518,13 @@ class DeviceFilter(CustomFieldFilterSet):
field_name='device_type__is_full_depth',
label='Is full depth',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
mac_address = MultiValueMACAddressFilter(
field_name='interfaces__mac_address',
label='MAC address',
)
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
has_primary_ip = django_filters.BooleanFilter(
method='_has_primary_ip',
label='Has a primary IP',
@@ -571,6 +534,10 @@ class DeviceFilter(CustomFieldFilterSet):
queryset=VirtualChassis.objects.all(),
label='Virtual chassis (ID)',
)
virtual_chassis_member = django_filters.BooleanFilter(
method='_virtual_chassis_member',
label='Is a virtual chassis member'
)
console_ports = django_filters.BooleanFilter(
method='_console_ports',
label='Has console ports',
@@ -599,7 +566,7 @@ class DeviceFilter(CustomFieldFilterSet):
class Meta:
model = Device
fields = ['serial', 'face']
fields = ['id', 'name', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority']
def search(self, queryset, name, value):
if not value.strip():
@@ -612,16 +579,6 @@ class DeviceFilter(CustomFieldFilterSet):
Q(comments__icontains=value)
).distinct()
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
mac = EUI(value.strip())
return queryset.filter(interfaces__mac_address=mac).distinct()
except AddrFormatError:
return queryset.none()
def _has_primary_ip(self, queryset, name, value):
if value:
return queryset.filter(
@@ -634,6 +591,9 @@ class DeviceFilter(CustomFieldFilterSet):
Q(primary_ip6__isnull=False)
)
def _virtual_chassis_member(self, queryset, name, value):
return queryset.exclude(virtual_chassis__isnull=value)
def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleports__isnull=value)
@@ -644,7 +604,7 @@ class DeviceFilter(CustomFieldFilterSet):
return queryset.exclude(powerports__isnull=value)
def _power_outlets(self, queryset, name, value):
return queryset.exclude(poweroutlets_isnull=value)
return queryset.exclude(poweroutlets__isnull=value)
def _interfaces(self, queryset, name, value):
return queryset.exclude(interfaces__isnull=value)
@@ -661,7 +621,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
method='search',
label='Search',
)
device_id = django_filters.ModelChoiceFilter(
device_id = django_filters.ModelMultipleChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
)
@@ -676,7 +636,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
)
@@ -689,7 +650,7 @@ class ConsolePortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsolePort
fields = ['name', 'connection_status']
fields = ['id', 'name', 'description', 'connection_status']
class ConsoleServerPortFilter(DeviceComponentFilterSet):
@@ -701,7 +662,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet):
class Meta:
model = ConsoleServerPort
fields = ['name', 'connection_status']
fields = ['id', 'name', 'description', 'connection_status']
class PowerPortFilter(DeviceComponentFilterSet):
@@ -713,7 +674,7 @@ class PowerPortFilter(DeviceComponentFilterSet):
class Meta:
model = PowerPort
fields = ['name', 'connection_status']
fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status']
class PowerOutletFilter(DeviceComponentFilterSet):
@@ -725,7 +686,7 @@ class PowerOutletFilter(DeviceComponentFilterSet):
class Meta:
model = PowerOutlet
fields = ['name', 'connection_status']
fields = ['id', 'name', 'feed_leg', 'description', 'connection_status']
class InterfaceFilter(django_filters.FilterSet):
@@ -741,8 +702,8 @@ class InterfaceFilter(django_filters.FilterSet):
field_name='name',
label='Device',
)
device_id = django_filters.NumberFilter(
method='filter_device',
device_id = MultiValueNumberFilter(
method='filter_device_id',
field_name='pk',
label='Device (ID)',
)
@@ -751,19 +712,16 @@ class InterfaceFilter(django_filters.FilterSet):
lookup_expr='isnull',
exclude=True
)
type = django_filters.CharFilter(
method='filter_type',
label='Interface type',
kind = django_filters.CharFilter(
method='filter_kind',
label='Kind of interface',
)
lag_id = django_filters.ModelMultipleChoiceFilter(
field_name='lag',
queryset=Interface.objects.all(),
label='LAG interface (ID)',
)
mac_address = django_filters.CharFilter(
method='_mac_address',
label='MAC address',
)
mac_address = MultiValueMACAddressFilter()
tag = TagFilter()
vlan_id = django_filters.CharFilter(
method='filter_vlan_id',
@@ -773,20 +731,21 @@ class InterfaceFilter(django_filters.FilterSet):
method='filter_vlan',
label='Assigned VID'
)
form_factor = django_filters.MultipleChoiceFilter(
choices=IFACE_FF_CHOICES,
type = django_filters.MultipleChoiceFilter(
choices=IFACE_TYPE_CHOICES,
null_value=None
)
class Meta:
model = Interface
fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only']
fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value)
Q(name__icontains=value) |
Q(description__icontains=value)
).distinct()
def filter_device(self, queryset, name, value):
@@ -797,6 +756,17 @@ class InterfaceFilter(django_filters.FilterSet):
except Device.DoesNotExist:
return queryset.none()
def filter_device_id(self, queryset, name, id_list):
# Include interfaces belonging to peer virtual chassis members
vc_interface_ids = []
try:
devices = Device.objects.filter(pk__in=id_list)
for device in devices:
vc_interface_ids += device.vc_interfaces.values_list('id', flat=True)
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
def filter_vlan_id(self, queryset, name, value):
value = value.strip()
if not value:
@@ -815,25 +785,14 @@ class InterfaceFilter(django_filters.FilterSet):
Q(tagged_vlans__vid=value)
)
def filter_type(self, queryset, name, value):
def filter_kind(self, queryset, name, value):
value = value.strip().lower()
return {
'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES),
'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES),
'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES),
'lag': queryset.filter(form_factor=IFACE_FF_LAG),
'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES),
'virtual': queryset.filter(type__in=VIRTUAL_IFACE_TYPES),
'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES),
}.get(value, queryset.none())
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
return queryset
try:
mac = EUI(value.strip())
return queryset.filter(mac_address=mac)
except AddrFormatError:
return queryset.none()
class FrontPortFilter(DeviceComponentFilterSet):
cabled = django_filters.BooleanFilter(
@@ -844,7 +803,7 @@ class FrontPortFilter(DeviceComponentFilterSet):
class Meta:
model = FrontPort
fields = ['name', 'type']
fields = ['id', 'name', 'type', 'description']
class RearPortFilter(DeviceComponentFilterSet):
@@ -856,14 +815,14 @@ class RearPortFilter(DeviceComponentFilterSet):
class Meta:
model = RearPort
fields = ['name', 'type']
fields = ['id', 'name', 'type', 'positions', 'description']
class DeviceBayFilter(DeviceComponentFilterSet):
class Meta:
model = DeviceBay
fields = ['name']
fields = ['id', 'name', 'description']
class InventoryItemFilter(DeviceComponentFilterSet):
@@ -894,11 +853,13 @@ class InventoryItemFilter(DeviceComponentFilterSet):
to_field_name='slug',
label='Manufacturer (slug)',
)
asset_tag = NullableCharFieldFilter()
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
class Meta:
model = InventoryItem
fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered']
fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered']
def search(self, queryset, name, value):
if not value.strip():
@@ -944,7 +905,7 @@ class VirtualChassisFilter(django_filters.FilterSet):
class Meta:
model = VirtualChassis
fields = ['domain']
fields = ['id', 'domain']
def search(self, queryset, name, value):
if not value.strip():
@@ -964,19 +925,40 @@ class CableFilter(django_filters.FilterSet):
type = django_filters.MultipleChoiceFilter(
choices=CABLE_TYPE_CHOICES
)
status = django_filters.MultipleChoiceFilter(
choices=CONNECTION_STATUS_CHOICES
)
color = django_filters.MultipleChoiceFilter(
choices=COLOR_CHOICES
)
device = django_filters.CharFilter(
method='filter_connected_device',
field_name='name'
)
device_id = django_filters.CharFilter(
method='filter_connected_device',
field_name='pk'
)
class Meta:
model = Cable
fields = ['type', 'status', 'color', 'length', 'length_unit']
fields = ['id', 'label', 'length', 'length_unit']
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(label__icontains=value)
def filter_connected_device(self, queryset, name, value):
if not value.strip():
return queryset
try:
device = Device.objects.get(**{name: value})
except ObjectDoesNotExist:
return queryset.none()
cable_pks = device.get_cables(pk_list=True)
return queryset.filter(pk__in=cable_pks)
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter(
@@ -1023,14 +1005,14 @@ class PowerConnectionFilter(django_filters.FilterSet):
def filter_site(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(connected_endpoint__device__site__slug=value)
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(connected_endpoint__device__name__icontains=value)
Q(_connected_poweroutlet__device__name__icontains=value)
)
@@ -1063,3 +1045,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
Q(device__name__icontains=value) |
Q(_connected_interface__device__name__icontains=value)
)
class PowerPanelFilter(django_filters.FilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
)
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
rack_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack_group',
queryset=RackGroup.objects.all(),
label='Rack group (ID)',
)
class Meta:
model = PowerPanel
fields = ['name']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value)
)
return queryset.filter(qs_filter)
class PowerFeedFilter(CustomFieldFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
)
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
power_panel_id = django_filters.ModelMultipleChoiceFilter(
queryset=PowerPanel.objects.all(),
label='Power panel (ID)',
)
rack_id = django_filters.ModelMultipleChoiceFilter(
field_name='rack',
queryset=Rack.objects.all(),
label='Rack (ID)',
)
tag = TagFilter()
class Meta:
model = PowerFeed
fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(comments__icontains=value)
)
return queryset.filter(qs_filter)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -14,22 +14,6 @@ CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$')
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
class DeviceComponentManager(Manager):
def get_queryset(self):
queryset = super().get_queryset()
table_name = self.model._meta.db_table
sql = r"CONCAT(REGEXP_REPLACE({}.name, '\d+$', ''), LPAD(SUBSTRING({}.name FROM '\d+$'), 8, '0'))"
# Pad any trailing digits to effect natural sorting
return queryset.extra(
select={
'name_padded': sql.format(table_name, table_name),
}
).order_by('name_padded', 'pk')
class InterfaceQuerySet(QuerySet):
def connectable(self):
@@ -37,7 +21,7 @@ class InterfaceQuerySet(QuerySet):
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
wireless).
"""
return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES)
return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
class InterfaceManager(Manager):
@@ -64,11 +48,15 @@ class InterfaceManager(Manager):
The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not
match any of the prescribed fields.
The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device
components.
"""
sql_col = '{}.name'.format(self.model._meta.db_table)
ordering = [
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name',
'_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk'
]
fields = {

View File

@@ -174,8 +174,8 @@ class Migration(migrations.Migration):
('length', models.PositiveSmallIntegerField(blank=True, null=True)),
('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
],
),
migrations.AlterUniqueTogether(

View File

@@ -0,0 +1,85 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('dcim', '0069_deprecate_nullablecharfield'),
('extras', '0019_tag_taggeditem'),
]
operations = [
migrations.AlterField(
model_name='consoleport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='consoleserverport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='device',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='devicebay',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='devicetype',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='frontport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='interface',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='inventoryitem',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='poweroutlet',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='powerport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='rack',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='rearport',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='site',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AlterField(
model_name='virtualchassis',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@@ -0,0 +1,38 @@
# Generated by Django 2.1.7 on 2019-02-20 18:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0070_custom_tag_models'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='consoleserverport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='devicebay',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='poweroutlet',
name='description',
field=models.CharField(blank=True, max_length=100),
),
migrations.AddField(
model_name='powerport',
name='description',
field=models.CharField(blank=True, max_length=100),
),
]

View File

@@ -0,0 +1,134 @@
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0021_add_color_comments_changelog_to_tag'),
('dcim', '0071_device_components_add_description'),
]
operations = [
migrations.CreateModel(
name='PowerFeed',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=50)),
('status', models.PositiveSmallIntegerField(default=1)),
('type', models.PositiveSmallIntegerField(default=1)),
('supply', models.PositiveSmallIntegerField(default=1)),
('phase', models.PositiveSmallIntegerField(default=1)),
('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])),
('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])),
('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])),
('available_power', models.PositiveSmallIntegerField(default=0, editable=False)),
('comments', models.TextField(blank=True)),
('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')),
],
options={
'ordering': ['power_panel', 'name'],
},
),
migrations.CreateModel(
name='PowerPanel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=50)),
('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')),
('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')),
],
options={
'ordering': ['site', 'name'],
},
),
migrations.AddField(
model_name='powerfeed',
name='power_panel',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel'),
),
migrations.AddField(
model_name='powerfeed',
name='rack',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack'),
),
migrations.AddField(
model_name='powerfeed',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='powerfeed',
name='connected_endpoint',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'),
),
migrations.AddField(
model_name='powerfeed',
name='connection_status',
field=models.NullBooleanField(),
),
migrations.RenameField(
model_name='powerport',
old_name='connected_endpoint',
new_name='_connected_poweroutlet',
),
migrations.AddField(
model_name='powerport',
name='_connected_powerfeed',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'),
),
migrations.AddField(
model_name='powerport',
name='allocated_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerport',
name='maximum_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerporttemplate',
name='allocated_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AddField(
model_name='powerporttemplate',
name='maximum_draw',
field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]),
),
migrations.AlterUniqueTogether(
name='powerpanel',
unique_together={('site', 'name')},
),
migrations.AlterUniqueTogether(
name='powerfeed',
unique_together={('power_panel', 'name')},
),
migrations.AddField(
model_name='poweroutlet',
name='feed_leg',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='poweroutlet',
name='power_port',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='feed_leg',
field=models.PositiveSmallIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='power_port',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.1.7 on 2019-04-12 17:27
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0072_powerfeeds'),
]
operations = [
migrations.RenameField(
model_name='interface',
old_name='form_factor',
new_name='type',
),
migrations.RenameField(
model_name='interfacetemplate',
old_name='form_factor',
new_name='type',
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-07-17 20:40
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0073_interface_form_factor_to_type'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100, unique=True),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100, unique=True),
),
]

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,11 @@ def assign_virtualchassis_master(instance, created, **kwargs):
When a VirtualChassis is created, automatically assign its master device to the VC.
"""
if created:
Device.objects.filter(pk=instance.master.pk).update(virtual_chassis=instance, vc_position=None)
devices = Device.objects.filter(pk=instance.master.pk)
for device in devices:
device.virtual_chassis = instance
device.vc_position = None
device.save()
@receiver(pre_delete, sender=VirtualChassis)
@@ -18,7 +22,11 @@ def clear_virtualchassis_members(instance, **kwargs):
"""
When a VirtualChassis is deleted, nullify the vc_position and vc_priority fields of its prior members.
"""
Device.objects.filter(virtual_chassis=instance.pk).update(vc_position=None, vc_priority=None)
devices = Device.objects.filter(virtual_chassis=instance.pk)
for device in devices:
device.vc_position = None
device.vc_priority = None
device.save()
@receiver(post_save, sender=Cable)

View File

@@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
REGION_LINK = """
@@ -44,7 +45,7 @@ REGION_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:region_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -56,7 +57,7 @@ RACKGROUP_ACTIONS = """
<i class="fa fa-eye"></i>
</a>
{% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit">
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning" title="Edit">
<i class="glyphicon glyphicon-pencil"></i>
</a>
{% endif %}
@@ -67,7 +68,7 @@ RACKROLE_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -88,7 +89,7 @@ RACKRESERVATION_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -97,7 +98,7 @@ MANUFACTURER_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -106,7 +107,7 @@ DEVICEROLE_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -131,7 +132,7 @@ PLATFORM_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:platform_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -144,6 +145,10 @@ STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
TYPE_LABEL = """
<span class="label label-{{ record.get_type_class }}">{{ record.get_type_display }}</span>
"""
DEVICE_PRIMARY_IP = """
{{ record.primary_ip6.address.ip|default:"" }}
{% if record.primary_ip6 and record.primary_ip4 %}<br />{% endif %}
@@ -168,7 +173,7 @@ VIRTUALCHASSIS_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -184,6 +189,10 @@ CABLE_LENGTH = """
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
"""
POWERPANEL_POWERFEED_COUNT = """
<a href="{% url 'dcim:powerfeed_list' %}?power_panel_id={{ record.pk }}">{{ value }}</a>
"""
#
# Regions
@@ -290,12 +299,21 @@ class RackDetailTable(RackTable):
template_code=RACK_DEVICE_COUNT,
verbose_name='Devices'
)
get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization')
get_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
verbose_name='Space'
)
get_power_utilization = tables.TemplateColumn(
template_code=UTILIZATION_GRAPH,
orderable=False,
verbose_name='Power'
)
class Meta(RackTable.Meta):
fields = (
'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
'get_utilization',
'get_utilization', 'get_power_utilization',
)
@@ -305,7 +323,12 @@ class RackDetailTable(RackTable):
class RackReservationTable(BaseTable):
pk = ToggleColumn()
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')])
site = tables.LinkColumn(
viewname='dcim:site',
accessor=Accessor('rack.site'),
args=[Accessor('rack.site.slug')],
)
tenant = tables.TemplateColumn(template_code=COL_TENANT)
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
@@ -314,7 +337,7 @@ class RackReservationTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackReservation
fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
#
@@ -323,16 +346,26 @@ class RackReservationTable(BaseTable):
class ManufacturerTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
devicetype_count = tables.Column(verbose_name='Device Types')
platform_count = tables.Column(verbose_name='Platforms')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
verbose_name='')
name = tables.LinkColumn()
devicetype_count = tables.Column(
verbose_name='Device Types'
)
inventoryitem_count = tables.Column(
verbose_name='Inventory Items'
)
platform_count = tables.Column(
verbose_name='Platforms'
)
slug = tables.Column()
actions = tables.TemplateColumn(
template_code=MANUFACTURER_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
class Meta(BaseTable.Meta):
model = Manufacturer
fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions')
fields = ('pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'slug', 'actions')
#
@@ -391,7 +424,7 @@ class PowerPortTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPortTemplate
fields = ('pk', 'name')
fields = ('pk', 'name', 'maximum_draw', 'allocated_draw')
empty_text = "None"
@@ -400,7 +433,7 @@ class PowerOutletTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerOutletTemplate
fields = ('pk', 'name')
fields = ('pk', 'name', 'power_port', 'feed_leg')
empty_text = "None"
@@ -410,7 +443,7 @@ class InterfaceTemplateTable(BaseTable):
class Meta(BaseTable.Meta):
model = InterfaceTemplate
fields = ('pk', 'name', 'mgmt_only', 'form_factor')
fields = ('pk', 'name', 'mgmt_only', 'type')
empty_text = "None"
@@ -567,7 +600,7 @@ class ConsoleServerPortTable(BaseTable):
class Meta(BaseTable.Meta):
model = ConsoleServerPort
fields = ('name',)
fields = ('name', 'description')
class PowerPortTable(BaseTable):
@@ -581,14 +614,14 @@ class PowerOutletTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerOutlet
fields = ('name',)
fields = ('name', 'description')
class InterfaceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Interface
fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description')
fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description')
class FrontPortTable(BaseTable):
@@ -696,9 +729,11 @@ class PowerConnectionTable(BaseTable):
viewname='dcim:device',
accessor=Accessor('connected_endpoint.device'),
args=[Accessor('connected_endpoint.device.pk')],
order_by='_connected_poweroutlet__device',
verbose_name='PDU'
)
connected_endpoint = tables.Column(
outlet = tables.Column(
accessor=Accessor('_connected_poweroutlet'),
verbose_name='Outlet'
)
device = tables.LinkColumn(
@@ -711,7 +746,7 @@ class PowerConnectionTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPort
fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status')
fields = ('pdu', 'outlet', 'device', 'name', 'connection_status')
class InterfaceConnectionTable(BaseTable):
@@ -733,18 +768,18 @@ class InterfaceConnectionTable(BaseTable):
)
device_b = tables.LinkColumn(
viewname='dcim:device',
accessor=Accessor('connected_endpoint.device'),
args=[Accessor('connected_endpoint.device.pk')],
accessor=Accessor('_connected_interface.device'),
args=[Accessor('_connected_interface.device.pk')],
verbose_name='Device B'
)
interface_b = tables.LinkColumn(
viewname='dcim:interface',
accessor=Accessor('connected_endpoint.name'),
args=[Accessor('connected_endpoint.pk')],
accessor=Accessor('_connected_interface'),
args=[Accessor('_connected_interface.pk')],
verbose_name='Interface B'
)
description_b = tables.Column(
accessor=Accessor('connected_endpoint.description'),
accessor=Accessor('_connected_interface.description'),
verbose_name='Description'
)
@@ -786,3 +821,51 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'master', 'domain', 'member_count', 'actions')
#
# Power panels
#
class PowerPanelTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
site = tables.LinkColumn(
viewname='dcim:site',
args=[Accessor('site.slug')]
)
powerfeed_count = tables.TemplateColumn(
template_code=POWERPANEL_POWERFEED_COUNT,
verbose_name='Feeds'
)
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count')
#
# Power feeds
#
class PowerFeedTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn()
power_panel = tables.LinkColumn(
viewname='dcim:powerpanel',
args=[Accessor('power_panel.pk')],
)
rack = tables.LinkColumn(
viewname='dcim:rack',
args=[Accessor('rack.pk')]
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
type = tables.TemplateColumn(
template_code=TYPE_LABEL
)
class Meta(BaseTable.Meta):
model = PowerFeed
fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase')

View File

@@ -7,8 +7,8 @@ from dcim.constants import *
from dcim.models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer,
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel,
Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
from ipam.models import IPAddress, VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
@@ -47,7 +47,7 @@ class RegionTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['id', 'name', 'site_count', 'slug', 'url']
)
def test_create_region(self):
@@ -285,7 +285,7 @@ class RackGroupTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['id', 'name', 'rack_count', 'slug', 'url']
)
def test_create_rackgroup(self):
@@ -393,7 +393,7 @@ class RackRoleTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['id', 'name', 'rack_count', 'slug', 'url']
)
def test_create_rackrole(self):
@@ -520,7 +520,7 @@ class RackTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['display_name', 'id', 'name', 'url']
['device_count', 'display_name', 'id', 'name', 'url']
)
def test_create_rack(self):
@@ -746,7 +746,7 @@ class ManufacturerTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['devicetype_count', 'id', 'name', 'slug', 'url']
)
def test_create_manufacturer(self):
@@ -855,7 +855,7 @@ class DeviceTypeTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url']
)
def test_create_devicetype(self):
@@ -1569,7 +1569,7 @@ class DeviceRoleTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
)
def test_create_devicerole(self):
@@ -1677,7 +1677,7 @@ class PlatformTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'slug', 'url']
['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
)
def test_create_platform(self):
@@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase):
site=self.site1,
cluster=self.cluster1
)
self.device_with_context_data = Device.objects.create(
device_type=self.devicetype1,
device_role=self.devicerole1,
name='Device with context data',
site=self.site1,
local_context_data={
'A': 1,
'B': 2
}
)
def test_get_device(self):
@@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase):
url = reverse('dcim-api:device-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
self.assertEqual(response.data['count'], 4)
def test_list_devices_brief(self):
@@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Device.objects.count(), 4)
self.assertEqual(Device.objects.count(), 5)
device4 = Device.objects.get(pk=response.data['id'])
self.assertEqual(device4.device_type_id, data['device_type'])
self.assertEqual(device4.device_role_id, data['device_role'])
@@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Device.objects.count(), 6)
self.assertEqual(Device.objects.count(), 7)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
@@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase):
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(Device.objects.count(), 3)
self.assertEqual(Device.objects.count(), 4)
device1 = Device.objects.get(pk=response.data['id'])
self.assertEqual(device1.device_type_id, data['device_type'])
self.assertEqual(device1.device_role_id, data['device_role'])
@@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase):
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Device.objects.count(), 2)
self.assertEqual(Device.objects.count(), 3)
def test_config_context_included_by_default_in_list_view(self):
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
response = self.client.get(url, **self.header)
self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1)
def test_config_context_excluded(self):
url = reverse('dcim-api:device-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
self.assertFalse('config_context' in response.data['results'][0])
class ConsolePortTest(APITestCase):
@@ -2529,7 +2553,7 @@ class InterfaceTest(APITestCase):
def test_update_interface(self):
lag_interface = Interface.objects.create(
device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG
device=self.device, name='Test LAG Interface', type=IFACE_TYPE_LAG
)
data = {
@@ -2817,7 +2841,7 @@ class CableTest(APITestCase):
)
for device in [self.device1, self.device2]:
for i in range(0, 10):
Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save()
Interface(device=device, type=IFACE_TYPE_1GE_FIXED, name='eth{}'.format(i)).save()
self.cable1 = Cable(
termination_a=self.device1.interfaces.get(name='eth0'),
@@ -3386,23 +3410,23 @@ class VirtualChassisTest(APITestCase):
device_type=device_type, device_role=device_role, name='StackSwitch9', site=site
)
for i in range(0, 13):
Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
for i in range(0, 13):
Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED)
Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED)
# Create two VirtualChassis with three members each
self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1')
@@ -3433,7 +3457,7 @@ class VirtualChassisTest(APITestCase):
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'master', 'url']
['id', 'master', 'member_count', 'url']
)
def test_create_virtualchassis(self):
@@ -3508,3 +3532,260 @@ class VirtualChassisTest(APITestCase):
self.assertTrue(
Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None)
)
class PowerPanelTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2')
self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3')
self.powerpanel1 = PowerPanel.objects.create(
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
)
self.powerpanel2 = PowerPanel.objects.create(
site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2'
)
self.powerpanel3 = PowerPanel.objects.create(
site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3'
)
def test_get_powerpanel(self):
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.powerpanel1.name)
def test_list_powerpanels(self):
url = reverse('dcim-api:powerpanel-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 3)
def test_list_powerpanels_brief(self):
url = reverse('dcim-api:powerpanel-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'powerfeed_count', 'url']
)
def test_create_powerpanel(self):
data = {
'name': 'Test Power Panel 4',
'site': self.site1.pk,
'rack_group': self.rackgroup1.pk,
}
url = reverse('dcim-api:powerpanel-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(PowerPanel.objects.count(), 4)
powerpanel4 = PowerPanel.objects.get(pk=response.data['id'])
self.assertEqual(powerpanel4.name, data['name'])
self.assertEqual(powerpanel4.site_id, data['site'])
self.assertEqual(powerpanel4.rack_group_id, data['rack_group'])
def test_create_powerpanel_bulk(self):
data = [
{
'name': 'Test Power Panel 4',
'site': self.site1.pk,
'rack_group': self.rackgroup1.pk,
},
{
'name': 'Test Power Panel 5',
'site': self.site1.pk,
'rack_group': self.rackgroup2.pk,
},
{
'name': 'Test Power Panel 6',
'site': self.site1.pk,
'rack_group': self.rackgroup3.pk,
},
]
url = reverse('dcim-api:powerpanel-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(PowerPanel.objects.count(), 6)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
self.assertEqual(response.data[2]['name'], data[2]['name'])
def test_update_powerpanel(self):
data = {
'name': 'Test Power Panel X',
'rack_group': self.rackgroup2.pk,
}
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(PowerPanel.objects.count(), 3)
powerpanel1 = PowerPanel.objects.get(pk=response.data['id'])
self.assertEqual(powerpanel1.name, data['name'])
self.assertEqual(powerpanel1.rack_group_id, data['rack_group'])
def test_delete_powerpanel(self):
url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerPanel.objects.count(), 2)
class PowerFeedTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rack1 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
)
self.rack2 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
)
self.rack3 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42,
)
self.rack4 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42,
)
self.powerpanel1 = PowerPanel.objects.create(
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1'
)
self.powerpanel2 = PowerPanel.objects.create(
site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2'
)
self.powerfeed1 = PowerFeed.objects.create(
power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY
)
self.powerfeed2 = PowerFeed.objects.create(
power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT
)
self.powerfeed3 = PowerFeed.objects.create(
power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY
)
self.powerfeed4 = PowerFeed.objects.create(
power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT
)
self.powerfeed5 = PowerFeed.objects.create(
power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY
)
self.powerfeed6 = PowerFeed.objects.create(
power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT
)
def test_get_powerfeed(self):
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.powerfeed1.name)
def test_list_powerfeeds(self):
url = reverse('dcim-api:powerfeed-list')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 6)
def test_list_powerfeeds_brief(self):
url = reverse('dcim-api:powerfeed-list')
response = self.client.get('{}?brief=1'.format(url), **self.header)
self.assertEqual(
sorted(response.data['results'][0]),
['id', 'name', 'url']
)
def test_create_powerfeed(self):
data = {
'name': 'Test Power Feed 4A',
'power_panel': self.powerpanel1.pk,
'rack': self.rack4.pk,
'type': POWERFEED_TYPE_PRIMARY,
}
url = reverse('dcim-api:powerfeed-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(PowerFeed.objects.count(), 7)
powerfeed4 = PowerFeed.objects.get(pk=response.data['id'])
self.assertEqual(powerfeed4.name, data['name'])
self.assertEqual(powerfeed4.power_panel_id, data['power_panel'])
self.assertEqual(powerfeed4.rack_id, data['rack'])
def test_create_powerfeed_bulk(self):
data = [
{
'name': 'Test Power Feed 4A',
'power_panel': self.powerpanel1.pk,
'rack': self.rack4.pk,
'type': POWERFEED_TYPE_PRIMARY,
},
{
'name': 'Test Power Feed 4B',
'power_panel': self.powerpanel1.pk,
'rack': self.rack4.pk,
'type': POWERFEED_TYPE_REDUNDANT,
},
]
url = reverse('dcim-api:powerfeed-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(PowerFeed.objects.count(), 8)
self.assertEqual(response.data[0]['name'], data[0]['name'])
self.assertEqual(response.data[1]['name'], data[1]['name'])
def test_update_powerfeed(self):
data = {
'name': 'Test Power Feed X',
'rack': self.rack4.pk,
'type': POWERFEED_TYPE_REDUNDANT,
}
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(PowerFeed.objects.count(), 6)
powerfeed1 = PowerFeed.objects.get(pk=response.data['id'])
self.assertEqual(powerfeed1.name, data['name'])
self.assertEqual(powerfeed1.rack_id, data['rack'])
self.assertEqual(powerfeed1.type, data['type'])
def test_delete_powerfeed(self):
url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(PowerFeed.objects.count(), 5)

View File

@@ -1,6 +1,5 @@
from django.test import TestCase
from dcim.constants import *
from dcim.models import *
@@ -152,6 +151,137 @@ class RackTestCase(TestCase):
self.assertTrue(pdu)
class DeviceTestCase(TestCase):
def setUp(self):
self.site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
self.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
)
self.device_role = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
)
# Create DeviceType components
ConsolePortTemplate(
device_type=self.device_type,
name='Console Port 1'
).save()
ConsoleServerPortTemplate(
device_type=self.device_type,
name='Console Server Port 1'
).save()
ppt = PowerPortTemplate(
device_type=self.device_type,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
ppt.save()
PowerOutletTemplate(
device_type=self.device_type,
name='Power Outlet 1',
power_port=ppt,
feed_leg=POWERFEED_LEG_A
).save()
InterfaceTemplate(
device_type=self.device_type,
name='Interface 1',
type=IFACE_TYPE_1GE_FIXED,
mgmt_only=True
).save()
rpt = RearPortTemplate(
device_type=self.device_type,
name='Rear Port 1',
type=PORT_TYPE_8P8C,
positions=8
)
rpt.save()
FrontPortTemplate(
device_type=self.device_type,
name='Front Port 1',
type=PORT_TYPE_8P8C,
rear_port=rpt,
rear_port_position=2
).save()
DeviceBayTemplate(
device_type=self.device_type,
name='Device Bay 1'
).save()
def test_device_creation(self):
"""
Ensure that all Device components are copied automatically from the DeviceType.
"""
d = Device(
site=self.site,
device_type=self.device_type,
device_role=self.device_role,
name='Test Device 1'
)
d.save()
ConsolePort.objects.get(
device=d,
name='Console Port 1'
)
ConsoleServerPort.objects.get(
device=d,
name='Console Server Port 1'
)
pp = PowerPort.objects.get(
device=d,
name='Power Port 1',
maximum_draw=1000,
allocated_draw=500
)
PowerOutlet.objects.get(
device=d,
name='Power Outlet 1',
power_port=pp,
feed_leg=POWERFEED_LEG_A
)
Interface.objects.get(
device=d,
name='Interface 1',
type=IFACE_TYPE_1GE_FIXED,
mgmt_only=True
)
rp = RearPort.objects.get(
device=d,
name='Rear Port 1',
type=PORT_TYPE_8P8C,
positions=8
)
FrontPort.objects.get(
device=d,
name='Front Port 1',
type=PORT_TYPE_8P8C,
rear_port=rp,
rear_port_position=2
)
DeviceBay.objects.get(
device=d,
name='Device Bay 1'
)
class CableTestCase(TestCase):
def setUp(self):
@@ -249,7 +379,7 @@ class CableTestCase(TestCase):
"""
A cable connection cannot include a virtual interface
"""
virtual_interface = Interface(device=self.device1, name="V1", form_factor=0)
virtual_interface = Interface(device=self.device1, name="V1", type=0)
cable = Cable(termination_a=self.interface2, termination_b=virtual_interface)
with self.assertRaises(ValidationError):
cable.clean()

View File

@@ -1,21 +1,22 @@
import urllib.parse
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from dcim.constants import CABLE_TYPE_CAT6, IFACE_FF_1GE_FIXED
from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED
from dcim.models import (
Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
RackReservation, RackRole, Site, Region, VirtualChassis,
)
from utilities.testing import create_test_user
class RegionTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_region'])
self.client = Client()
self.client.force_login(user)
# Create three Regions
for i in range(1, 4):
@@ -32,8 +33,9 @@ class RegionTestCase(TestCase):
class SiteTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_site'])
self.client = Client()
self.client.force_login(user)
region = Region(name='Region 1', slug='region-1')
region.save()
@@ -64,8 +66,9 @@ class SiteTestCase(TestCase):
class RackGroupTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_rackgroup'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase):
self.assertEqual(response.status_code, 200)
class RackTypeTestCase(TestCase):
class RackRoleTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_rackrole'])
self.client = Client()
self.client.force_login(user)
RackRole.objects.bulk_create([
RackRole(name='Rack Role 1', slug='rack-role-1'),
@@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase):
class RackReservationTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_rackreservation'])
self.client = Client()
User = get_user_model()
user = User(username='testuser', email='testuser@example.com')
user.save()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase):
class RackTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_rack'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -169,8 +171,9 @@ class RackTestCase(TestCase):
class ManufacturerTypeTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_manufacturer'])
self.client = Client()
self.client.force_login(user)
Manufacturer.objects.bulk_create([
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
@@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase):
class DeviceTypeTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_devicetype'])
self.client = Client()
self.client.force_login(user)
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
@@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase):
class DeviceRoleTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_devicerole'])
self.client = Client()
self.client.force_login(user)
DeviceRole.objects.bulk_create([
DeviceRole(name='Device Role 1', slug='device-role-1'),
@@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase):
class PlatformTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_platform'])
self.client = Client()
self.client.force_login(user)
Platform.objects.bulk_create([
Platform(name='Platform 1', slug='platform-1'),
@@ -261,8 +267,9 @@ class PlatformTestCase(TestCase):
class DeviceTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_device'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -303,8 +310,9 @@ class DeviceTestCase(TestCase):
class InventoryItemTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_inventoryitem'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -337,18 +345,13 @@ class InventoryItemTestCase(TestCase):
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_inventoryitem(self):
inventoryitem = InventoryItem.objects.first()
response = self.client.get(inventoryitem.get_absolute_url())
self.assertEqual(response.status_code, 200)
class CableTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_cable'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -367,17 +370,17 @@ class CableTestCase(TestCase):
device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
device2.save()
iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
iface1 = Interface(device=device1, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
iface1.save()
iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
iface2 = Interface(device=device1, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
iface2.save()
iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
iface3 = Interface(device=device1, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
iface3.save()
iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
iface4 = Interface(device=device2, name='Interface 1', type=IFACE_TYPE_1GE_FIXED)
iface4.save()
iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
iface5 = Interface(device=device2, name='Interface 2', type=IFACE_TYPE_1GE_FIXED)
iface5.save()
iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
iface6 = Interface(device=device2, name='Interface 3', type=IFACE_TYPE_1GE_FIXED)
iface6.save()
Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
@@ -401,11 +404,12 @@ class CableTestCase(TestCase):
self.assertEqual(response.status_code, 200)
class VirtualMachineTestCase(TestCase):
class VirtualChassisTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['dcim.view_virtualchassis'])
self.client = Client()
self.client.force_login(user)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
@@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_virtualchassis(self):
virtualchassis = VirtualChassis.objects.first()
response = self.client.get(virtualchassis.get_absolute_url())
self.assertEqual(response.status_code, 200)

View File

@@ -1,4 +1,4 @@
from django.conf.urls import url
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView
@@ -6,277 +6,301 @@ from secrets.views import secret_add
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
VirtualChassis,
)
app_name = 'dcim'
urlpatterns = [
# Regions
url(r'^regions/$', views.RegionListView.as_view(), name='region_list'),
url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'),
url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'),
url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
url(r'^regions/(?P<pk>\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'),
url(r'^regions/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
path(r'regions/', views.RegionListView.as_view(), name='region_list'),
path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path(r'regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path(r'regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
url(r'^sites/$', views.SiteListView.as_view(), name='site_list'),
url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'),
url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'),
url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
url(r'^sites/(?P<slug>[\w-]+)/$', views.SiteView.as_view(), name='site'),
url(r'^sites/(?P<slug>[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'),
url(r'^sites/(?P<slug>[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'),
url(r'^sites/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
url(r'^sites/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
path(r'sites/', views.SiteListView.as_view(), name='site_list'),
path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
path(r'sites/<slug:slug>/', views.SiteView.as_view(), name='site'),
path(r'sites/<slug:slug>/edit/', views.SiteEditView.as_view(), name='site_edit'),
path(r'sites/<slug:slug>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
path(r'sites/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
path(r'sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
# Rack groups
url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
url(r'^rack-groups/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path(r'rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path(r'rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'),
url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'),
url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
url(r'^rack-roles/(?P<pk>\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'),
url(r'^rack-roles/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path(r'rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path(r'rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'),
url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
url(r'^rack-reservations/(?P<pk>\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
url(r'^rack-reservations/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
path(r'rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
path(r'rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
path(r'rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
# Racks
url(r'^racks/$', views.RackListView.as_view(), name='rack_list'),
url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'),
url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'),
url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'),
url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
url(r'^racks/(?P<pk>\d+)/$', views.RackView.as_view(), name='rack'),
url(r'^racks/(?P<pk>\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'),
url(r'^racks/(?P<pk>\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'),
url(r'^racks/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
url(r'^racks/(?P<rack>\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
url(r'^racks/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
path(r'racks/', views.RackListView.as_view(), name='rack_list'),
path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'),
path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path(r'racks/<int:pk>/', views.RackView.as_view(), name='rack'),
path(r'racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
path(r'racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
path(r'racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
path(r'racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
path(r'racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
# Manufacturers
url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'),
url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
url(r'^manufacturers/(?P<slug>[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
url(r'^manufacturers/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path(r'manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path(r'manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'),
url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
url(r'^device-types/(?P<pk>\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'),
url(r'^device-types/(?P<pk>\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
url(r'^device-types/(?P<pk>\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
url(r'^device-types/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'),
path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
path(r'device-types/<int:pk>/', views.DeviceTypeView.as_view(), name='devicetype'),
path(r'device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path(r'device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path(r'device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
# Console port templates
url(r'^device-types/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
url(r'^device-types/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
path(r'device-types/<int:pk>/console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'),
path(r'device-types/<int:pk>/console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'),
# Console server port templates
url(r'^device-types/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
url(r'^device-types/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
path(r'device-types/<int:pk>/console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'),
path(r'device-types/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'),
# Power port templates
url(r'^device-types/(?P<pk>\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
url(r'^device-types/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
path(r'device-types/<int:pk>/power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'),
path(r'device-types/<int:pk>/power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'),
# Power outlet templates
url(r'^device-types/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
url(r'^device-types/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
path(r'device-types/<int:pk>/power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'),
path(r'device-types/<int:pk>/power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'),
# Interface templates
url(r'^device-types/(?P<pk>\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
url(r'^device-types/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
path(r'device-types/<int:pk>/interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'),
path(r'device-types/<int:pk>/interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'),
path(r'device-types/<int:pk>/interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'),
# Front port templates
url(r'^device-types/(?P<pk>\d+)/front-ports/add/$', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
url(r'^device-types/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
path(r'device-types/<int:pk>/front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'),
path(r'device-types/<int:pk>/front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'),
# Rear port templates
url(r'^device-types/(?P<pk>\d+)/rear-ports/add/$', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
url(r'^device-types/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
path(r'device-types/<int:pk>/rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'),
path(r'device-types/<int:pk>/rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'),
# Device bay templates
url(r'^device-types/(?P<pk>\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
url(r'^device-types/(?P<pk>\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
path(r'device-types/<int:pk>/device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'),
path(r'device-types/<int:pk>/device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'),
# Device roles
url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'),
url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
url(r'^device-roles/(?P<slug>[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
url(r'^device-roles/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path(r'device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path(r'device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'),
url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'),
url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'),
url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
url(r'^platforms/(?P<slug>[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'),
url(r'^platforms/(?P<slug>[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'),
path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path(r'platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path(r'platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'),
url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'),
url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'),
url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
url(r'^devices/(?P<pk>\d+)/$', views.DeviceView.as_view(), name='device'),
url(r'^devices/(?P<pk>\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'),
url(r'^devices/(?P<pk>\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'),
url(r'^devices/(?P<pk>\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
url(r'^devices/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
url(r'^devices/(?P<pk>\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'),
url(r'^devices/(?P<pk>\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'),
url(r'^devices/(?P<pk>\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
url(r'^devices/(?P<pk>\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'),
url(r'^devices/(?P<pk>\d+)/add-secret/$', secret_add, name='device_addsecret'),
url(r'^devices/(?P<device>\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'),
url(r'^devices/(?P<object_id>\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
path(r'devices/', views.DeviceListView.as_view(), name='device_list'),
path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'),
path(r'devices/<int:pk>/', views.DeviceView.as_view(), name='device'),
path(r'devices/<int:pk>/edit/', views.DeviceEditView.as_view(), name='device_edit'),
path(r'devices/<int:pk>/delete/', views.DeviceDeleteView.as_view(), name='device_delete'),
path(r'devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
path(r'devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
path(r'devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
path(r'devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path(r'devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path(r'devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path(r'devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
path(r'devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path(r'devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
url(r'^devices/(?P<pk>\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
url(r'^devices/(?P<pk>\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
url(r'^console-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
url(r'^console-ports/(?P<pk>\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
url(r'^console-ports/(?P<pk>\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
url(r'^console-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
path(r'devices/<int:pk>/console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path(r'devices/<int:pk>/console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path(r'console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path(r'console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path(r'console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path(r'console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
# Console server ports
url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
url(r'^devices/(?P<pk>\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
url(r'^console-server-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
url(r'^console-server-ports/(?P<pk>\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
url(r'^console-server-ports/(?P<pk>\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
url(r'^console-server-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
path(r'devices/<int:pk>/console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'),
path(r'devices/<int:pk>/console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'),
path(r'devices/<int:pk>/console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path(r'console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path(r'console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path(r'console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path(r'console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
# Power ports
url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
url(r'^devices/(?P<pk>\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'),
url(r'^devices/(?P<pk>\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
url(r'^power-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
url(r'^power-ports/(?P<pk>\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'),
url(r'^power-ports/(?P<pk>\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
url(r'^power-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
path(r'devices/<int:pk>/power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path(r'devices/<int:pk>/power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path(r'power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path(r'power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path(r'power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path(r'power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
# Power outlets
url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
url(r'^devices/(?P<pk>\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
url(r'^devices/(?P<pk>\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
url(r'^power-outlets/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
url(r'^power-outlets/(?P<pk>\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
url(r'^power-outlets/(?P<pk>\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
url(r'^power-outlets/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
path(r'devices/<int:pk>/power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'),
path(r'devices/<int:pk>/power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'),
path(r'devices/<int:pk>/power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path(r'power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path(r'power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path(r'power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path(r'power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
# Interfaces
url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
url(r'^devices/(?P<pk>\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'),
url(r'^devices/(?P<pk>\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
url(r'^interfaces/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
url(r'^interfaces/(?P<pk>\d+)/$', views.InterfaceView.as_view(), name='interface'),
url(r'^interfaces/(?P<pk>\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'),
url(r'^interfaces/(?P<pk>\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'),
url(r'^interfaces/(?P<pk>\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'),
url(r'^interfaces/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
url(r'^interfaces/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
path(r'devices/<int:pk>/interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'),
path(r'devices/<int:pk>/interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'),
path(r'devices/<int:pk>/interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path(r'interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path(r'interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path(r'interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path(r'interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path(r'interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path(r'interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
# Front ports
# url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
url(r'^devices/(?P<pk>\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'),
url(r'^devices/(?P<pk>\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
url(r'^front-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
url(r'^front-ports/(?P<pk>\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'),
url(r'^front-ports/(?P<pk>\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
url(r'^front-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
# path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
path(r'devices/<int:pk>/front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'),
path(r'devices/<int:pk>/front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'),
path(r'devices/<int:pk>/front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path(r'front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path(r'front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path(r'front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path(r'front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
# Rear ports
# url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
url(r'^devices/(?P<pk>\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'),
url(r'^devices/(?P<pk>\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
url(r'^devices/(?P<pk>\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
url(r'^rear-ports/(?P<termination_a_id>\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
url(r'^rear-ports/(?P<pk>\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'),
url(r'^rear-ports/(?P<pk>\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'),
url(r'^rear-ports/(?P<pk>\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
# path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
path(r'devices/<int:pk>/rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'),
path(r'devices/<int:pk>/rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'),
path(r'devices/<int:pk>/rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path(r'rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path(r'rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path(r'rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path(r'rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
# Device bays
url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
url(r'^devices/(?P<pk>\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
url(r'^devices/(?P<pk>\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
url(r'^device-bays/(?P<pk>\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
url(r'^device-bays/(?P<pk>\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
url(r'^device-bays/(?P<pk>\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
url(r'^device-bays/(?P<pk>\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
path(r'devices/<int:pk>/bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
path(r'devices/<int:pk>/bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path(r'device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path(r'device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path(r'device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path(r'device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
# Inventory items
url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
url(r'^inventory-items/(?P<pk>\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
url(r'^inventory-items/(?P<pk>\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
url(r'^devices/(?P<device>\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'),
path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path(r'inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path(r'inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path(r'devices/<int:device>/inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'),
# Cables
url(r'^cables/$', views.CableListView.as_view(), name='cable_list'),
url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'),
url(r'^cables/edit/$', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
url(r'^cables/delete/$', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
url(r'^cables/(?P<pk>\d+)/$', views.CableView.as_view(), name='cable'),
url(r'^cables/(?P<pk>\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'),
url(r'^cables/(?P<pk>\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'),
url(r'^cables/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
path(r'cables/', views.CableListView.as_view(), name='cable_list'),
path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'),
path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'),
path(r'cables/<int:pk>/', views.CableView.as_view(), name='cable'),
path(r'cables/<int:pk>/edit/', views.CableEditView.as_view(), name='cable_edit'),
path(r'cables/<int:pk>/delete/', views.CableDeleteView.as_view(), name='cable_delete'),
path(r'cables/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}),
# Console/power/interface connections (read-only)
url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'),
path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'),
path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'),
# Virtual chassis
url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
url(r'^virtual-chassis/(?P<pk>\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
url(r'^virtual-chassis/(?P<pk>\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
url(r'^virtual-chassis/(?P<pk>\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
url(r'^virtual-chassis/(?P<pk>\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
url(r'^virtual-chassis-members/(?P<pk>\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path(r'virtual-chassis/<int:pk>/edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'),
path(r'virtual-chassis/<int:pk>/delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'),
path(r'virtual-chassis/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}),
path(r'virtual-chassis/<int:pk>/add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'),
path(r'virtual-chassis-members/<int:pk>/delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'),
# Power panels
path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
path(r'power-panels/<int:pk>/', views.PowerPanelView.as_view(), name='powerpanel'),
path(r'power-panels/<int:pk>/edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'),
path(r'power-panels/<int:pk>/delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'),
path(r'power-panels/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}),
# Power feeds
path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),
path(r'power-feeds/<int:pk>/', views.PowerFeedView.as_view(), name='powerfeed'),
path(r'power-feeds/<int:pk>/edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'),
path(r'power-feeds/<int:pk>/delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'),
path(r'power-feeds/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}),
]

View File

@@ -3,6 +3,7 @@ import re
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.core.paginator import EmptyPage, PageNotAnInteger
from django.db import transaction
from django.db.models import Count, F
@@ -10,11 +11,13 @@ from django.forms import modelformset_factory
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.html import escape
from django.utils.http import is_safe_url
from django.utils.safestring import mark_safe
from django.views.generic import View
from circuits.models import Circuit
from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.constants import GRAPH_TYPE_DEVICE, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from extras.models import Graph, TopologyMap
from extras.views import ObjectConfigContextView
from ipam.models import Prefix, VLAN
from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
@@ -30,8 +33,9 @@ from . import filters, forms, tables
from .models import (
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack,
RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort,
PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
VirtualChassis,
)
@@ -135,7 +139,8 @@ class BulkDisconnectView(GetReturnURLMixin, View):
# Regions
#
class RegionListView(ObjectListView):
class RegionListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_region'
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
@@ -179,19 +184,21 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Sites
#
class SiteListView(ObjectListView):
queryset = Site.objects.select_related('region', 'tenant')
class SiteListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_site'
queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter
filter_form = forms.SiteFilterForm
table = tables.SiteTable
template_name = 'dcim/site_list.html'
class SiteView(View):
class SiteView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_site'
def get(self, request, slug):
site = get_object_or_404(Site.objects.select_related('region', 'tenant__group'), slug=slug)
site = get_object_or_404(Site.objects.prefetch_related('region', 'tenant__group'), slug=slug)
stats = {
'rack_count': Rack.objects.filter(site=site).count(),
'device_count': Device.objects.filter(site=site).count(),
@@ -240,19 +247,28 @@ class SiteBulkImportView(PermissionRequiredMixin, BulkImportView):
class SiteBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_site'
queryset = Site.objects.select_related('region', 'tenant')
queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter
table = tables.SiteTable
form = forms.SiteBulkEditForm
default_return_url = 'dcim:site_list'
class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_site'
queryset = Site.objects.prefetch_related('region', 'tenant')
filter = filters.SiteFilter
table = tables.SiteTable
default_return_url = 'dcim:site_list'
#
# Rack groups
#
class RackGroupListView(ObjectListView):
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
class RackGroupListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackgroup'
queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
filter = filters.RackGroupFilter
filter_form = forms.RackGroupFilterForm
table = tables.RackGroupTable
@@ -279,7 +295,7 @@ class RackGroupBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackgroup'
queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks'))
queryset = RackGroup.objects.prefetch_related('site').annotate(rack_count=Count('racks'))
filter = filters.RackGroupFilter
table = tables.RackGroupTable
default_return_url = 'dcim:rackgroup_list'
@@ -289,7 +305,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack roles
#
class RackRoleListView(ObjectListView):
class RackRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackrole'
queryset = RackRole.objects.annotate(rack_count=Count('racks'))
table = tables.RackRoleTable
template_name = 'dcim/rackrole_list.html'
@@ -324,11 +341,10 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Racks
#
class RackListView(ObjectListView):
queryset = Rack.objects.select_related(
'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
class RackListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rack'
queryset = Rack.objects.prefetch_related(
'site', 'group', 'tenant', 'role', 'devices__device_type'
).annotate(
device_count=Count('devices')
)
@@ -338,18 +354,15 @@ class RackListView(ObjectListView):
template_name = 'dcim/rack_list.html'
class RackElevationListView(View):
class RackElevationListView(PermissionRequiredMixin, View):
"""
Display a set of rack elevations side-by-side.
"""
permission_required = 'dcim.view_rack'
def get(self, request):
racks = Rack.objects.select_related(
'site', 'group', 'tenant', 'role'
).prefetch_related(
'devices__device_type'
)
racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type')
racks = filters.RackFilter(request.GET, racks).qs
total_count = racks.count()
@@ -379,22 +392,28 @@ class RackElevationListView(View):
})
class RackView(View):
class RackView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_rack'
def get(self, request, pk):
rack = get_object_or_404(Rack.objects.select_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
rack = get_object_or_404(Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role'), pk=pk)
nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True, parent_bay__isnull=True) \
.select_related('device_type__manufacturer')
nonracked_devices = Device.objects.filter(
rack=rack,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
reservations = RackReservation.objects.filter(rack=rack)
power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
return render(request, 'dcim/rack.html', {
'rack': rack,
'reservations': reservations,
'power_feeds': power_feeds,
'nonracked_devices': nonracked_devices,
'next_rack': next_rack,
'prev_rack': prev_rack,
@@ -430,7 +449,7 @@ class RackBulkImportView(PermissionRequiredMixin, BulkImportView):
class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rack'
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter
table = tables.RackTable
form = forms.RackBulkEditForm
@@ -439,7 +458,7 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rack'
queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role')
queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role')
filter = filters.RackFilter
table = tables.RackTable
default_return_url = 'dcim:rack_list'
@@ -449,8 +468,9 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Rack reservations
#
class RackReservationListView(ObjectListView):
queryset = RackReservation.objects.all()
class RackReservationListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_rackreservation'
queryset = RackReservation.objects.prefetch_related('rack__site')
filter = filters.RackReservationFilter
filter_form = forms.RackReservationFilterForm
table = tables.RackReservationTable
@@ -486,7 +506,7 @@ class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_rackreservation'
queryset = RackReservation.objects.select_related('rack', 'user')
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filter = filters.RackReservationFilter
table = tables.RackReservationTable
form = forms.RackReservationBulkEditForm
@@ -495,7 +515,7 @@ class RackReservationBulkEditView(PermissionRequiredMixin, BulkEditView):
class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_rackreservation'
queryset = RackReservation.objects.select_related('rack', 'user')
queryset = RackReservation.objects.prefetch_related('rack', 'user')
filter = filters.RackReservationFilter
table = tables.RackReservationTable
default_return_url = 'dcim:rackreservation_list'
@@ -505,9 +525,11 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Manufacturers
#
class ManufacturerListView(ObjectListView):
class ManufacturerListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_manufacturer'
queryset = Manufacturer.objects.annotate(
devicetype_count=Count('device_types', distinct=True),
inventoryitem_count=Count('inventory_items', distinct=True),
platform_count=Count('platforms', distinct=True),
)
table = tables.ManufacturerTable
@@ -543,15 +565,17 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device types
#
class DeviceTypeListView(ObjectListView):
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
class DeviceTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_devicetype'
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter
filter_form = forms.DeviceTypeFilterForm
table = tables.DeviceTypeTable
template_name = 'dcim/devicetype_list.html'
class DeviceTypeView(View):
class DeviceTypeView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_devicetype'
def get(self, request, pk):
@@ -640,7 +664,7 @@ class DeviceTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_devicetype'
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable
form = forms.DeviceTypeBulkEditForm
@@ -649,7 +673,7 @@ class DeviceTypeBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_devicetype'
queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances'))
queryset = DeviceType.objects.prefetch_related('manufacturer').annotate(instance_count=Count('instances'))
filter = filters.DeviceTypeFilter
table = tables.DeviceTypeTable
default_return_url = 'dcim:devicetype_list'
@@ -807,7 +831,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Device roles
#
class DeviceRoleListView(ObjectListView):
class DeviceRoleListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_devicerole'
queryset = DeviceRole.objects.all()
table = tables.DeviceRoleTable
template_name = 'dcim/devicerole_list.html'
@@ -842,7 +867,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Platforms
#
class PlatformListView(ObjectListView):
class PlatformListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_platform'
queryset = Platform.objects.all()
table = tables.PlatformTable
template_name = 'dcim/platform_list.html'
@@ -877,8 +903,9 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Devices
#
class DeviceListView(ObjectListView):
queryset = Device.objects.select_related(
class DeviceListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_device'
queryset = Device.objects.prefetch_related(
'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6'
)
filter = filters.DeviceFilter
@@ -887,11 +914,12 @@ class DeviceListView(ObjectListView):
template_name = 'dcim/device_list.html'
class DeviceView(View):
class DeviceView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_device'
def get(self, request, pk):
device = get_object_or_404(Device.objects.select_related(
device = get_object_or_404(Device.objects.prefetch_related(
'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform'
), pk=pk)
@@ -904,32 +932,31 @@ class DeviceView(View):
vc_members = []
# Console ports
console_ports = device.consoleports.select_related('connected_endpoint__device', 'cable')
console_ports = device.consoleports.prefetch_related('connected_endpoint__device', 'cable')
# Console server ports
consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable')
consoleserverports = device.consoleserverports.prefetch_related('connected_endpoint__device', 'cable')
# Power ports
power_ports = device.powerports.select_related('connected_endpoint__device', 'cable')
power_ports = device.powerports.prefetch_related('_connected_poweroutlet__device', 'cable')
# Power outlets
poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable')
poweroutlets = device.poweroutlets.prefetch_related('connected_endpoint__device', 'cable', 'power_port')
# Interfaces
interfaces = device.vc_interfaces.select_related(
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable'
).prefetch_related(
interfaces = device.vc_interfaces.prefetch_related(
'lag', '_connected_interface__device', '_connected_circuittermination__circuit', 'cable',
'cable__termination_a', 'cable__termination_b', 'ip_addresses', 'tags'
)
# Front ports
front_ports = device.frontports.select_related('rear_port', 'cable')
front_ports = device.frontports.prefetch_related('rear_port', 'cable')
# Rear ports
rear_ports = device.rearports.select_related('cable')
rear_ports = device.rearports.prefetch_related('cable')
# Device bays
device_bays = device.device_bays.select_related('installed_device__device_type__manufacturer')
device_bays = device.device_bays.prefetch_related('installed_device__device_type__manufacturer')
# Services
services = device.services.all()
@@ -942,13 +969,10 @@ class DeviceView(View):
site=device.site, device_role=device.device_role
).exclude(
pk=device.pk
).select_related(
).prefetch_related(
'rack', 'device_type__manufacturer'
)[:10]
# Show graph button on interfaces only if at least one graph has been created.
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists()
return render(request, 'dcim/device.html', {
'device': device,
'console_ports': console_ports,
@@ -963,21 +987,21 @@ class DeviceView(View):
'secrets': secrets,
'vc_members': vc_members,
'related_devices': related_devices,
'show_graphs': show_graphs,
'show_graphs': Graph.objects.filter(type=GRAPH_TYPE_DEVICE).exists(),
'show_interface_graphs': Graph.objects.filter(type=GRAPH_TYPE_INTERFACE).exists(),
})
class DeviceInventoryView(View):
class DeviceInventoryView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_device'
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
inventory_items = InventoryItem.objects.filter(
device=device, parent=None
).select_related(
'manufacturer'
).prefetch_related(
'child_items'
'manufacturer', 'child_items'
)
return render(request, 'dcim/device_inventory.html', {
@@ -988,7 +1012,7 @@ class DeviceInventoryView(View):
class DeviceStatusView(PermissionRequiredMixin, View):
permission_required = 'dcim.napalm_read'
permission_required = ('dcim.view_device', 'dcim.napalm_read')
def get(self, request, pk):
@@ -1001,12 +1025,12 @@ class DeviceStatusView(PermissionRequiredMixin, View):
class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
permission_required = 'dcim.napalm_read'
permission_required = ('dcim.view_device', 'dcim.napalm_read')
def get(self, request, pk):
device = get_object_or_404(Device, pk=pk)
interfaces = device.vc_interfaces.connectable().select_related(
interfaces = device.vc_interfaces.connectable().prefetch_related(
'_connected_interface__device'
)
@@ -1018,7 +1042,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
class DeviceConfigView(PermissionRequiredMixin, View):
permission_required = 'dcim.napalm_read'
permission_required = ('dcim.view_device', 'dcim.napalm_read')
def get(self, request, pk):
@@ -1030,7 +1054,8 @@ class DeviceConfigView(PermissionRequiredMixin, View):
})
class DeviceConfigContextView(ObjectConfigContextView):
class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView):
permission_required = 'dcim.view_device'
object_class = Device
base_template = 'dcim/device.html'
@@ -1082,7 +1107,7 @@ class ChildDeviceBulkImportView(PermissionRequiredMixin, BulkImportView):
class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_device'
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter
table = tables.DeviceTable
form = forms.DeviceBulkEditForm
@@ -1091,7 +1116,7 @@ class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView):
class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_device'
queryset = Device.objects.select_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer')
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1154,6 +1179,14 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView):
model = ConsoleServerPort
class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_consoleserverport'
queryset = ConsoleServerPort.objects.all()
parent_model = Device
table = tables.ConsoleServerPortTable
form = forms.ConsoleServerPortBulkEditForm
class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_consoleserverport'
queryset = ConsoleServerPort.objects.all()
@@ -1230,6 +1263,14 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView):
model = PowerOutlet
class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_poweroutlet'
queryset = PowerOutlet.objects.all()
parent_model = Device
table = tables.PowerOutletTable
form = forms.PowerOutletBulkEditForm
class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView):
permission_required = 'dcim.change_poweroutlet'
queryset = PowerOutlet.objects.all()
@@ -1253,7 +1294,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Interfaces
#
class InterfaceView(View):
class InterfaceView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_interface'
def get(self, request, pk):
@@ -1261,7 +1303,7 @@ class InterfaceView(View):
# Get assigned IP addresses
ipaddress_table = InterfaceIPAddressTable(
data=interface.ip_addresses.select_related('vrf', 'tenant'),
data=interface.ip_addresses.prefetch_related('vrf', 'tenant'),
orderable=False
)
@@ -1270,7 +1312,7 @@ class InterfaceView(View):
if interface.untagged_vlan is not None:
vlans.append(interface.untagged_vlan)
vlans[0].tagged = False
for vlan in interface.tagged_vlans.select_related('site', 'group', 'tenant', 'role'):
for vlan in interface.tagged_vlans.prefetch_related('site', 'group', 'tenant', 'role'):
vlan.tagged = True
vlans.append(vlan)
vlan_table = InterfaceVLANTable(
@@ -1305,12 +1347,6 @@ class InterfaceEditView(PermissionRequiredMixin, ObjectEditView):
template_name = 'dcim/interface_edit.html'
class InterfaceAssignVLANsView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.change_interface'
model = Interface
model_form = forms.InterfaceAssignVLANsForm
class InterfaceDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_interface'
model = Interface
@@ -1634,7 +1670,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
# Cables
#
class CableListView(ObjectListView):
class CableListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_cable'
queryset = Cable.objects.prefetch_related(
'termination_a', 'termination_b'
)
@@ -1644,7 +1681,8 @@ class CableListView(ObjectListView):
template_name = 'dcim/cable_list.html'
class CableView(View):
class CableView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_cable'
def get(self, request, pk):
@@ -1655,10 +1693,11 @@ class CableView(View):
})
class CableTraceView(View):
class CableTraceView(PermissionRequiredMixin, View):
"""
Trace a cable path beginning from the given termination.
"""
permission_required = 'dcim.view_cable'
def get(self, request, model, pk):
@@ -1670,20 +1709,80 @@ class CableTraceView(View):
})
class CableCreateView(PermissionRequiredMixin, ObjectEditView):
class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View):
permission_required = 'dcim.add_cable'
model = Cable
model_form = forms.CableCreateForm
template_name = 'dcim/cable_connect.html'
def alter_obj(self, obj, request, url_args, url_kwargs):
def dispatch(self, request, *args, **kwargs):
# Retrieve endpoint A based on the given type and PK
termination_a_type = url_kwargs.get('termination_a_type')
termination_a_id = url_kwargs.get('termination_a_id')
obj.termination_a = termination_a_type.objects.get(pk=termination_a_id)
termination_a_type = kwargs.get('termination_a_type')
termination_a_id = kwargs.get('termination_a_id')
return obj
termination_b_type_name = kwargs.get('termination_b_type')
self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', ''))
self.obj = Cable(
termination_a=termination_a_type.objects.get(pk=termination_a_id),
termination_b_type=self.termination_b_type
)
self.form_class = {
'console-port': forms.ConnectCableToConsolePortForm,
'console-server-port': forms.ConnectCableToConsoleServerPortForm,
'power-port': forms.ConnectCableToPowerPortForm,
'power-outlet': forms.ConnectCableToPowerOutletForm,
'interface': forms.ConnectCableToInterfaceForm,
'front-port': forms.ConnectCableToFrontPortForm,
'rear-port': forms.ConnectCableToRearPortForm,
'power-feed': forms.ConnectCableToPowerFeedForm,
'circuit-termination': forms.ConnectCableToCircuitTerminationForm,
}[termination_b_type_name]
return super().dispatch(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
# Parse initial data manually to avoid setting field values as lists
initial_data = {k: request.GET[k] for k in request.GET}
form = self.form_class(instance=self.obj, initial=initial_data)
return render(request, self.template_name, {
'obj': self.obj,
'obj_type': Cable._meta.verbose_name,
'termination_b_type': self.termination_b_type.name,
'form': form,
'return_url': self.get_return_url(request, self.obj),
})
def post(self, request, *args, **kwargs):
form = self.form_class(request.POST, request.FILES, instance=self.obj)
if form.is_valid():
obj = form.save()
msg = 'Created cable <a href="{}">{}</a>'.format(
obj.get_absolute_url(),
escape(obj)
)
messages.success(request, mark_safe(msg))
if '_addanother' in request.POST:
return redirect(request.get_full_path())
return_url = form.cleaned_data.get('return_url')
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
return redirect(return_url)
else:
return redirect(self.get_return_url(request, obj))
return render(request, self.template_name, {
'obj': self.obj,
'obj_type': Cable._meta.verbose_name,
'termination_b_type': self.termination_b_type.name,
'form': form,
'return_url': self.get_return_url(request, self.obj),
})
class CableEditView(PermissionRequiredMixin, ObjectEditView):
@@ -1728,8 +1827,9 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Connections
#
class ConsoleConnectionsListView(ObjectListView):
queryset = ConsolePort.objects.select_related(
class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport')
queryset = ConsolePort.objects.prefetch_related(
'device', 'connected_endpoint__device'
).filter(
connected_endpoint__isnull=False
@@ -1758,13 +1858,14 @@ class ConsoleConnectionsListView(ObjectListView):
return csv_data
class PowerConnectionsListView(ObjectListView):
queryset = PowerPort.objects.select_related(
'device', 'connected_endpoint__device'
class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet')
queryset = PowerPort.objects.prefetch_related(
'device', '_connected_poweroutlet__device'
).filter(
connected_endpoint__isnull=False
_connected_poweroutlet__isnull=False
).order_by(
'cable', 'connected_endpoint__device__name', 'connected_endpoint__name'
'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name'
)
filter = filters.PowerConnectionFilter
filter_form = forms.PowerConnectionFilterForm
@@ -1788,8 +1889,9 @@ class PowerConnectionsListView(ObjectListView):
return csv_data
class InterfaceConnectionsListView(ObjectListView):
queryset = Interface.objects.select_related(
class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_interface'
queryset = Interface.objects.prefetch_related(
'device', 'cable', '_connected_interface__device'
).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
@@ -1830,8 +1932,9 @@ class InterfaceConnectionsListView(ObjectListView):
# Inventory items
#
class InventoryItemListView(ObjectListView):
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
class InventoryItemListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_inventoryitem'
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filter = filters.InventoryItemFilter
filter_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable
@@ -1866,7 +1969,7 @@ class InventoryItemBulkImportView(PermissionRequiredMixin, BulkImportView):
class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_inventoryitem'
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
filter = filters.InventoryItemFilter
table = tables.InventoryItemTable
form = forms.InventoryItemBulkEditForm
@@ -1875,7 +1978,7 @@ class InventoryItemBulkEditView(PermissionRequiredMixin, BulkEditView):
class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_inventoryitem'
queryset = InventoryItem.objects.select_related('device', 'manufacturer')
queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
table = tables.InventoryItemTable
template_name = 'dcim/inventoryitem_bulk_delete.html'
default_return_url = 'dcim:inventoryitem_list'
@@ -1885,8 +1988,9 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Virtual chassis
#
class VirtualChassisListView(ObjectListView):
queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members'))
class VirtualChassisListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_virtualchassis'
queryset = VirtualChassis.objects.prefetch_related('master').annotate(member_count=Count('members'))
table = tables.VirtualChassisTable
filter = filters.VirtualChassisFilter
filter_form = forms.VirtualChassisFilterForm
@@ -1906,7 +2010,7 @@ class VirtualChassisCreateView(PermissionRequiredMixin, View):
return redirect('dcim:device_list')
device_queryset = Device.objects.filter(
pk__in=pk_form.cleaned_data.get('pk')
).select_related('rack').order_by('vc_position')
).prefetch_related('rack').order_by('vc_position')
VCMemberFormSet = modelformset_factory(
model=Device,
@@ -1960,7 +2064,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset
@@ -1981,7 +2085,7 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
formset=forms.BaseVCMemberFormSet,
extra=0
)
members_queryset = virtual_chassis.members.select_related('rack').order_by('vc_position')
members_queryset = virtual_chassis.members.prefetch_related('rack').order_by('vc_position')
vc_form = forms.VirtualChassisForm(request.POST, instance=virtual_chassis)
vc_form.fields['master'].queryset = members_queryset
@@ -1997,7 +2101,10 @@ class VirtualChassisEditView(PermissionRequiredMixin, GetReturnURLMixin, View):
# Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on
# duplicate positions. Then save each member instance.
members = formset.save(commit=False)
Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None)
devices = Device.objects.filter(pk__in=[m.pk for m in members])
for device in devices:
device.vc_position = None
device.save()
for member in members:
member.save()
@@ -2098,11 +2205,12 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
if form.is_valid():
Device.objects.filter(pk=device.pk).update(
virtual_chassis=None,
vc_position=None,
vc_priority=None
)
devices = Device.objects.filter(pk=device.pk)
for device in devices:
device.virtual_chassis = None
device.vc_position = None
device.vc_priority = None
device.save()
msg = 'Removed {} from virtual chassis {}'.format(device, device.virtual_chassis)
messages.success(request, msg)
@@ -2114,3 +2222,143 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin,
'form': form,
'return_url': self.get_return_url(request, device),
})
#
# Power panels
#
class PowerPanelListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_powerpanel'
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
)
filter = filters.PowerPanelFilter
filter_form = forms.PowerPanelFilterForm
table = tables.PowerPanelTable
template_name = 'dcim/powerpanel_list.html'
class PowerPanelView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_powerpanel'
def get(self, request, pk):
powerpanel = get_object_or_404(PowerPanel.objects.prefetch_related('site', 'rack_group'), pk=pk)
powerfeed_table = tables.PowerFeedTable(
data=PowerFeed.objects.filter(power_panel=powerpanel).prefetch_related('rack'),
orderable=False
)
powerfeed_table.exclude = ['power_panel']
return render(request, 'dcim/powerpanel.html', {
'powerpanel': powerpanel,
'powerfeed_table': powerfeed_table,
})
class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_powerpanel'
model = PowerPanel
model_form = forms.PowerPanelForm
default_return_url = 'dcim:powerpanel_list'
class PowerPanelEditView(PowerPanelCreateView):
permission_required = 'dcim.change_powerpanel'
class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerpanel'
model = PowerPanel
default_return_url = 'dcim:powerpanel_list'
class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_powerpanel'
model_form = forms.PowerPanelCSVForm
table = tables.PowerPanelTable
default_return_url = 'dcim:powerpanel_list'
class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerpanel'
queryset = PowerPanel.objects.prefetch_related(
'site', 'rack_group'
).annotate(
rack_count=Count('powerfeeds')
)
filter = filters.PowerPanelFilter
table = tables.PowerPanelTable
default_return_url = 'dcim:powerpanel_list'
#
# Power feeds
#
class PowerFeedListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'dcim.view_powerfeed'
queryset = PowerFeed.objects.prefetch_related(
'power_panel', 'rack'
)
filter = filters.PowerFeedFilter
filter_form = forms.PowerFeedFilterForm
table = tables.PowerFeedTable
template_name = 'dcim/powerfeed_list.html'
class PowerFeedView(PermissionRequiredMixin, View):
permission_required = 'dcim.view_powerfeed'
def get(self, request, pk):
powerfeed = get_object_or_404(PowerFeed.objects.prefetch_related('power_panel', 'rack'), pk=pk)
return render(request, 'dcim/powerfeed.html', {
'powerfeed': powerfeed,
})
class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'dcim.add_powerfeed'
model = PowerFeed
model_form = forms.PowerFeedForm
template_name = 'dcim/powerfeed_edit.html'
default_return_url = 'dcim:powerfeed_list'
class PowerFeedEditView(PowerFeedCreateView):
permission_required = 'dcim.change_powerfeed'
class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'dcim.delete_powerfeed'
model = PowerFeed
default_return_url = 'dcim:powerfeed_list'
class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'dcim.add_powerfeed'
model_form = forms.PowerFeedCSVForm
table = tables.PowerFeedTable
default_return_url = 'dcim:powerfeed_list'
class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'dcim.change_powerfeed'
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filter = filters.PowerFeedFilter
table = tables.PowerFeedTable
form = forms.PowerFeedBulkEditForm
default_return_url = 'dcim:powerfeed_list'
class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'dcim.delete_powerfeed'
queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack')
filter = filters.PowerFeedFilter
table = tables.PowerFeedTable
default_return_url = 'dcim:powerfeed_list'

View File

@@ -3,7 +3,7 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
def order_content_types(field):
@@ -77,6 +77,35 @@ class CustomFieldAdmin(admin.ModelAdmin):
return ', '.join([ct.name for ct in obj.obj_type.all()])
#
# Custom links
#
class CustomLinkForm(forms.ModelForm):
class Meta:
model = CustomLink
exclude = []
help_texts = {
'text': 'Jinja2 template code for the link text. Reference the object as <code>{{ obj }}</code>. Links '
'which render as empty text will not be displayed.',
'url': 'Jinja2 template code for the link URL. Reference the object as <code>{{ obj }}</code>.',
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Format ContentType choices
order_content_types(self.fields['content_type'])
self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(CustomLink, site=admin_site)
class CustomLinkAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'group_name', 'weight']
form = CustomLinkForm
#
# Graphs
#

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from taggit.models import Tag
from dcim.api.nested_serializers import (
NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer,
@@ -10,12 +11,14 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag
)
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import (
ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer,
ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
ValidatedModelSerializer,
)
from .nested_serializers import *
@@ -53,10 +56,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
#
class ExportTemplateSerializer(ValidatedModelSerializer):
template_language = ChoiceField(
choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2
)
class Meta:
model = ExportTemplate
fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension']
fields = [
'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'file_extension',
]
#
@@ -80,7 +90,7 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'tagged_items']
fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items']
#
@@ -88,7 +98,9 @@ class TagSerializer(ValidatedModelSerializer):
#
class ImageAttachmentSerializer(ValidatedModelSerializer):
content_type = ContentTypeField()
content_type = ContentTypeField(
queryset=ContentType.objects.all()
)
parent = serializers.SerializerMethodField(read_only=True)
class Meta:
@@ -112,6 +124,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
return data
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_parent(self, obj):
# Static mapping of models to their nested serializers
@@ -205,25 +218,42 @@ class ReportDetailSerializer(ReportSerializer):
#
class ObjectChangeSerializer(serializers.ModelSerializer):
user = NestedUserSerializer(read_only=True)
content_type = ContentTypeField(read_only=True)
changed_object = serializers.SerializerMethodField(read_only=True)
user = NestedUserSerializer(
read_only=True
)
action = ChoiceField(
choices=OBJECTCHANGE_ACTION_CHOICES,
read_only=True
)
changed_object_type = ContentTypeField(
read_only=True
)
changed_object = serializers.SerializerMethodField(
read_only=True
)
class Meta:
model = ObjectChange
fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data',
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object',
'object_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_changed_object(self, obj):
"""
Serialize a nested representation of the changed object.
"""
if obj.changed_object is None:
return None
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
if serializer is None:
try:
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
except SerializerNotFound:
return obj.object_repr
context = {'request': self.context['request']}
context = {
'request': self.context['request']
}
data = serializer(obj.changed_object, context=context).data
return data

View File

@@ -17,6 +17,9 @@ router.APIRootView = ExtrasRootView
# Field choices
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
# Custom field choices
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice')
# Graphs
router.register(r'graphs', views.GraphViewSet)

View File

@@ -1,3 +1,5 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404, HttpResponse
@@ -6,11 +8,11 @@ from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from taggit.models import Tag
from extras import filters
from extras.models import (
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
Tag,
)
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -23,11 +25,42 @@ from . import serializers
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CustomField, ['type']),
(ExportTemplate, ['template_language']),
(Graph, ['type']),
(ObjectChange, ['action']),
)
#
# Custom field choices
#
class CustomFieldChoicesViewSet(ViewSet):
"""
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
def __init__(self, *args, **kwargs):
super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs)
self._fields = OrderedDict()
for cfc in CustomFieldChoice.objects.all():
self._fields.setdefault(cfc.field.name, {})
self._fields[cfc.field.name][cfc.value] = cfc.pk
def list(self, request):
return Response(self._fields)
def retrieve(self, request, pk):
if pk not in self._fields:
raise Http404
return Response(self._fields[pk])
def get_view_name(self):
return "Custom Field choices"
#
# Custom fields
#
@@ -87,7 +120,7 @@ class ExportTemplateViewSet(ModelViewSet):
#
class TopologyMapViewSet(ModelViewSet):
queryset = TopologyMap.objects.select_related('site')
queryset = TopologyMap.objects.prefetch_related('site')
serializer_class = serializers.TopologyMapSerializer
filterset_class = filters.TopologyMapFilter
@@ -115,7 +148,9 @@ class TopologyMapViewSet(ModelViewSet):
#
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
queryset = Tag.objects.annotate(
tagged_items=Count('extras_taggeditem_items', distinct=True)
)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilter
@@ -225,6 +260,6 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of recent changes.
"""
queryset = ObjectChange.objects.select_related('user')
queryset = ObjectChange.objects.prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filters.ObjectChangeFilter

View File

@@ -7,6 +7,9 @@ class ExtrasConfig(AppConfig):
name = "extras"
def ready(self):
import extras.signals
# Check that we can connect to the configured Redis database if webhooks are enabled.
if settings.WEBHOOKS_ENABLED:
try:
@@ -22,6 +25,7 @@ class ExtrasConfig(AppConfig):
port=settings.REDIS_PORT,
db=settings.REDIS_DATABASE,
password=settings.REDIS_PASSWORD or None,
ssl=settings.REDIS_SSL,
)
rs.ping()
except redis.exceptions.ConnectionError:

View File

@@ -1,13 +1,24 @@
# Models which support custom fields
CUSTOMFIELD_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
)
CUSTOMFIELD_MODELS = [
'circuits.circuit',
'circuits.provider',
'dcim.device',
'dcim.devicetype',
'dcim.powerfeed',
'dcim.rack',
'dcim.site',
'ipam.aggregate',
'ipam.ipaddress',
'ipam.prefix',
'ipam.service',
'ipam.vlan',
'ipam.vrf',
'secrets.secret',
'tenancy.tenant',
'virtualization.cluster',
'virtualization.virtualmachine',
]
# Custom field types
CF_TYPE_TEXT = 100
@@ -35,27 +46,96 @@ CF_FILTER_CHOICES = (
(CF_FILTER_EXACT, 'Exact'),
)
# Custom links
CUSTOMLINK_MODELS = [
'circuits.circuit',
'circuits.provider',
'dcim.cable',
'dcim.device',
'dcim.devicetype',
'dcim.powerpanel',
'dcim.powerfeed',
'dcim.rack',
'dcim.site',
'ipam.aggregate',
'ipam.ipaddress',
'ipam.prefix',
'ipam.service',
'ipam.vlan',
'ipam.vrf',
'secrets.secret',
'tenancy.tenant',
'virtualization.cluster',
'virtualization.virtualmachine',
]
BUTTON_CLASS_DEFAULT = 'default'
BUTTON_CLASS_PRIMARY = 'primary'
BUTTON_CLASS_SUCCESS = 'success'
BUTTON_CLASS_INFO = 'info'
BUTTON_CLASS_WARNING = 'warning'
BUTTON_CLASS_DANGER = 'danger'
BUTTON_CLASS_LINK = 'link'
BUTTON_CLASS_CHOICES = (
(BUTTON_CLASS_DEFAULT, 'Default'),
(BUTTON_CLASS_PRIMARY, 'Primary (blue)'),
(BUTTON_CLASS_SUCCESS, 'Success (green)'),
(BUTTON_CLASS_INFO, 'Info (aqua)'),
(BUTTON_CLASS_WARNING, 'Warning (orange)'),
(BUTTON_CLASS_DANGER, 'Danger (red)'),
(BUTTON_CLASS_LINK, 'None (link)'),
)
# Graph types
GRAPH_TYPE_INTERFACE = 100
GRAPH_TYPE_DEVICE = 150
GRAPH_TYPE_PROVIDER = 200
GRAPH_TYPE_SITE = 300
GRAPH_TYPE_CHOICES = (
(GRAPH_TYPE_INTERFACE, 'Interface'),
(GRAPH_TYPE_DEVICE, 'Device'),
(GRAPH_TYPE_PROVIDER, 'Provider'),
(GRAPH_TYPE_SITE, 'Site'),
)
# Models which support export templates
EXPORTTEMPLATE_MODELS = [
'provider', 'circuit', # Circuits
'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM
'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
'circuits.circuit',
'circuits.provider',
'dcim.cable',
'dcim.consoleport',
'dcim.device',
'dcim.devicetype',
'dcim.interface',
'dcim.manufacturer',
'dcim.powerpanel',
'dcim.powerport',
'dcim.powerfeed',
'dcim.rack',
'dcim.rackgroup',
'dcim.region',
'dcim.site',
'dcim.virtualchassis',
'ipam.aggregate',
'ipam.ipaddress',
'ipam.prefix',
'ipam.service',
'ipam.vlan',
'ipam.vrf',
'secrets.secret',
'tenancy.tenant',
'virtualization.cluster',
'virtualization.virtualmachine',
]
# ExportTemplate language choices
TEMPLATE_LANGUAGE_DJANGO = 10
TEMPLATE_LANGUAGE_JINJA2 = 20
TEMPLATE_LANGUAGE_CHOICES = (
(TEMPLATE_LANGUAGE_DJANGO, 'Django'),
(TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
)
# Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2
@@ -117,13 +197,36 @@ WEBHOOK_CT_CHOICES = (
)
# Models which support registered webhooks
WEBHOOK_MODELS = (
'provider', 'circuit', # Circuits
'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM
'consoleport', 'consoleserverport', 'powerport', 'poweroutlet',
'interface', 'devicebay', 'inventoryitem',
'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM
'secret', # Secrets
'tenant', # Tenancy
'cluster', 'virtualmachine', # Virtualization
)
WEBHOOK_MODELS = [
'circuits.circuit',
'circuits.provider',
'dcim.cable',
'dcim.consoleport',
'dcim.consoleserverport',
'dcim.device',
'dcim.devicebay',
'dcim.devicetype',
'dcim.interface',
'dcim.inventoryitem',
'dcim.frontport',
'dcim.manufacturer',
'dcim.poweroutlet',
'dcim.powerpanel',
'dcim.powerport',
'dcim.powerfeed',
'dcim.rack',
'dcim.rearport',
'dcim.region',
'dcim.site',
'dcim.virtualchassis',
'ipam.aggregate',
'ipam.ipaddress',
'ipam.prefix',
'ipam.service',
'ipam.vlan',
'ipam.vrf',
'secrets.secret',
'tenancy.tenant',
'virtualization.cluster',
'virtualization.virtualmachine',
]

View File

@@ -1,12 +1,11 @@
import django_filters
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
class CustomFieldFilter(django_filters.Filter):
@@ -82,7 +81,7 @@ class ExportTemplateFilter(django_filters.FilterSet):
class Meta:
model = ExportTemplate
fields = ['content_type', 'name']
fields = ['content_type', 'name', 'template_language']
class TagFilter(django_filters.FilterSet):
@@ -208,6 +207,20 @@ class ConfigContextFilter(django_filters.FilterSet):
)
#
# Filter for Local Config Context Data
#
class LocalConfigContextFilter(django_filters.FilterSet):
local_context_data = django_filters.BooleanFilter(
method='_local_context_data',
label='Has local config context data',
)
def _local_context_data(self, queryset, name, value):
return queryset.exclude(local_context_data__isnull=value)
class ObjectChangeFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',

View File

@@ -4,21 +4,21 @@ from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.constants import COLOR_CHOICES
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
)
from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES,
)
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
#
@@ -113,8 +113,10 @@ class CustomFieldForm(forms.ModelForm):
# If editing an existing object, initialize values for all custom fields
if self.instance.pk:
existing_values = CustomFieldValue.objects.filter(obj_type=self.obj_type, obj_id=self.instance.pk)\
.select_related('field')
existing_values = CustomFieldValue.objects.filter(
obj_type=self.obj_type,
obj_id=self.instance.pk
).prefetch_related('field')
for cfv in existing_values:
self.initial['cf_{}'.format(str(cfv.field.name))] = cfv.serialized_value
@@ -122,9 +124,11 @@ class CustomFieldForm(forms.ModelForm):
for field_name in self.custom_fields:
try:
cfv = CustomFieldValue.objects.select_related('field').get(field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk)
cfv = CustomFieldValue.objects.prefetch_related('field').get(
field=self.fields[field_name].model,
obj_type=self.obj_type,
obj_id=self.instance.pk
)
except CustomFieldValue.DoesNotExist:
# Skip this field if none exists already and its value is empty
if self.cleaned_data[field_name] in [None, '']:
@@ -190,11 +194,12 @@ class CustomFieldFilterForm(forms.Form):
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
comments = CommentField()
class Meta:
model = Tag
fields = [
'name', 'slug',
'name', 'slug', 'color', 'comments'
]
@@ -216,12 +221,29 @@ class TagFilterForm(BootstrapMixin, forms.Form):
)
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Tag.objects.all(),
widget=forms.MultipleHiddenInput
)
color = forms.CharField(
max_length=6,
required=False,
widget=ColorSelect()
)
class Meta:
nullable_fields = []
#
# Config contexts
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
data = JSONField()
data = JSONField(
label=''
)
class Meta:
model = ConfigContext
@@ -330,6 +352,20 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
)
#
# Filter form for local config context data
#
class LocalConfigContextFilterForm(forms.Form):
local_context_data = forms.NullBooleanField(
required=False,
label='Has local config context data',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
# Image attachments
#
@@ -381,3 +417,34 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
widget=ContentTypeSelect(),
label='Object Type'
)
#
# Scripts
#
class ScriptForm(BootstrapMixin, forms.Form):
_commit = forms.BooleanField(
required=False,
initial=True,
label="Commit changes",
help_text="Commit changes to the database (uncheck for a dry-run)"
)
def __init__(self, vars, *args, **kwargs):
super().__init__(*args, **kwargs)
# Dynamically populate fields for variables
for name, var in vars.items():
self.fields[name] = var.as_field()
# Move _commit to the end of the form
self.fields.move_to_end('_commit', True)
@property
def requires_input(self):
"""
A boolean indicating whether the form requires user input (ignore the _commit field).
"""
return bool(len(self.fields) > 1)

View File

@@ -5,8 +5,8 @@ import sys
from django import get_version
from django.apps import apps
from django.conf import settings
from django.contrib.auth.models import User
from django.core.management.base import BaseCommand
from django.db.models import Model
APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization']
@@ -38,16 +38,10 @@ class Command(BaseCommand):
for app in APPS:
self.django_models[app] = []
# Models
app_models = sys.modules['{}.models'.format(app)]
for name in dir(app_models):
model = getattr(app_models, name)
try:
if issubclass(model, Model) and model._meta.app_label == app:
namespace[name] = model
self.django_models[app].append(name)
except TypeError:
pass
# Load models from each app
for model in apps.get_app_config(app).get_models():
namespace[model.__name__] = model
self.django_models[app].append(model.__name__)
# Constants
try:
@@ -57,6 +51,9 @@ class Command(BaseCommand):
except KeyError:
pass
# Additional objects to include
namespace['User'] = User
# Load convenience commands
namespace.update({
'lsmodels': self._lsmodels,

View File

@@ -7,47 +7,65 @@ from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.utils import timezone
from django.utils.functional import curry
from django_prometheus.models import model_deletes, model_inserts, model_updates
from extras.webhooks import enqueue_webhooks
from .constants import (
OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE,
)
from .models import ObjectChange
from .signals import purge_changelog
from .webhooks import enqueue_webhooks
_thread_locals = threading.local()
def cache_changed_object(instance, **kwargs):
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
# Cache the object for further processing was the response has completed.
_thread_locals.changed_objects.append(
(instance, action)
)
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated
"""
# Queue the object and a new ObjectChange for processing once the request completes
if hasattr(instance, 'to_objectchange'):
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
objectchange = instance.to_objectchange(action)
_thread_locals.changed_objects.append(
(instance, objectchange)
)
def _record_object_deleted(request, instance, **kwargs):
def _handle_deleted_object(request, sender, instance, **kwargs):
"""
Fires when an object is deleted
"""
# Record an Object Change
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen
# occasionally during tests, but haven't been able to determine why.
assert request.user.is_authenticated
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
# Record that the object was deleted
if hasattr(instance, 'log_change'):
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE)
def purge_objectchange_cache(sender, **kwargs):
"""
Delete any queued object changes waiting to be written.
"""
_thread_locals.changed_objects = []
class ObjectChangeMiddleware(object):
"""
This middleware performs two functions in response to an object being created, updated, or deleted:
This middleware performs three functions in response to an object being created, updated, or deleted:
1. Create an ObjectChange to reflect the modification to the object in the changelog.
2. Enqueue any relevant webhooks.
3. Increment the metric counter for the event type.
The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit
The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit
differently for each. Objects being saved are cached into thread-local storage for action *after* the response has
completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags)
have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the
@@ -65,28 +83,43 @@ class ObjectChangeMiddleware(object):
# the same request.
request.id = uuid.uuid4()
# Signals don't include the request context, so we're currying it into the pre_delete function ahead of time.
record_object_deleted = curry(_record_object_deleted, request)
# Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
handle_deleted_object = curry(_handle_deleted_object, request)
# Connect our receivers to the post_save and pre_delete signals.
post_save.connect(cache_changed_object, dispatch_uid='record_object_saved')
post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted')
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
# Provide a hook for purging the change cache
purge_changelog.connect(purge_objectchange_cache)
# Process the request
response = self.get_response(request)
# If the change cache is empty, there's nothing more we need to do.
if not _thread_locals.changed_objects:
return response
# Create records for any cached objects that were created/updated.
for obj, action in _thread_locals.changed_objects:
for obj, objectchange in _thread_locals.changed_objects:
# Record the change
if hasattr(obj, 'log_change'):
obj.log_change(request.user, request.id, action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(obj, action)
enqueue_webhooks(obj, request.user, request.id, objectchange.action)
# Housekeeping: 1% chance of clearing out expired ObjectChanges
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
# Increment metric counters
if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
model_inserts.labels(obj._meta.model_name).inc()
elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
model_updates.labels(obj._meta.model_name).inc()
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
# one or more changes being logged.
if settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
purged_count, _ = ObjectChange.objects.filter(
time__lt=cutoff

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.1.7 on 2019-04-08 14:49
from django.db import migrations, models
def set_template_language(apps, schema_editor):
"""
Set the language for all existing ExportTemplates to Django (Jinja2 is the default for new ExportTemplates).
"""
ExportTemplate = apps.get_model('extras', 'ExportTemplate')
ExportTemplate.objects.update(template_language=10)
class Migration(migrations.Migration):
dependencies = [
('extras', '0017_exporttemplate_mime_type_length'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='template_language',
field=models.PositiveSmallIntegerField(default=20),
),
migrations.RunPython(set_template_language),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0018_exporttemplate_add_jinja2'),
]
operations = [
migrations.CreateModel(
name='Tag',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('slug', models.SlugField(max_length=100, unique=True)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='TaggedItem',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('object_id', models.IntegerField(db_index=True)),
('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')),
('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')),
],
options={
'abstract': False,
},
),
migrations.AlterIndexTogether(
name='taggeditem',
index_together={('content_type', 'object_id')},
),
]

View File

@@ -0,0 +1,65 @@
# Generated by Django 2.1.4 on 2019-02-20 06:56
from django.db import migrations, models
import django.db.models.deletion
import utilities.fields
def copy_tags(apps, schema_editor):
"""
Copy data from taggit_tag to extras_tag
"""
TaggitTag = apps.get_model('taggit', 'Tag')
ExtrasTag = apps.get_model('extras', 'Tag')
tags_values = TaggitTag.objects.all().values('id', 'name', 'slug')
tags = [ExtrasTag(**tag) for tag in tags_values]
ExtrasTag.objects.bulk_create(tags)
def copy_taggeditems(apps, schema_editor):
"""
Copy data from taggit_taggeditem to extras_taggeditem
"""
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem')
tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id')
tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values]
ExtrasTaggedItem.objects.bulk_create(tagged_items)
def delete_taggit_taggeditems(apps, schema_editor):
"""
Delete all TaggedItem instances from taggit_taggeditem
"""
TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem')
TaggitTaggedItem.objects.all().delete()
def delete_taggit_tags(apps, schema_editor):
"""
Delete all Tag instances from taggit_tag
"""
TaggitTag = apps.get_model('taggit', 'Tag')
TaggitTag.objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('extras', '0019_tag_taggeditem'),
('circuits', '0015_custom_tag_models'),
('dcim', '0070_custom_tag_models'),
('ipam', '0025_custom_tag_models'),
('secrets', '0006_custom_tag_models'),
('tenancy', '0006_custom_tag_models'),
('virtualization', '0009_custom_tag_models'),
]
operations = [
migrations.RunPython(copy_tags),
migrations.RunPython(copy_taggeditems),
migrations.RunPython(delete_taggit_taggeditems),
migrations.RunPython(delete_taggit_tags),
]

View File

@@ -0,0 +1,34 @@
# Generated by Django 2.1.4 on 2019-02-20 07:38
from django.db import migrations, models
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('extras', '0020_tag_data'),
]
operations = [
migrations.AddField(
model_name='tag',
name='color',
field=utilities.fields.ColorField(max_length=6, default='9e9e9e'),
),
migrations.AddField(
model_name='tag',
name='comments',
field=models.TextField(blank=True, default=''),
),
migrations.AddField(
model_name='tag',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='tag',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -0,0 +1,48 @@
from django.db import migrations, models
import django.db.models.deletion
import extras.models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0021_add_color_comments_changelog_to_tag'),
]
operations = [
migrations.CreateModel(
name='CustomLink',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=100, unique=True)),
('text', models.CharField(max_length=500)),
('url', models.CharField(max_length=500)),
('weight', models.PositiveSmallIntegerField(default=100)),
('group_name', models.CharField(blank=True, max_length=50)),
('button_class', models.CharField(default='default', max_length=30)),
('new_window', models.BooleanField()),
('content_type', models.ForeignKey(limit_choices_to=extras.models.get_custom_link_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')),
],
options={
'ordering': ['group_name', 'weight', 'name'],
},
),
# Update limit_choices_to for CustomFields, ExportTemplates, and Webhooks
migrations.AlterField(
model_name='customfield',
name='obj_type',
field=models.ManyToManyField(limit_choices_to=extras.models.get_custom_field_models, related_name='custom_fields', to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='exporttemplate',
name='content_type',
field=models.ForeignKey(limit_choices_to=extras.models.get_export_template_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
),
migrations.AlterField(
model_name='webhook',
name='obj_type',
field=models.ManyToManyField(limit_choices_to=extras.models.get_webhook_models, related_name='webhooks', to='contenttypes.ContentType'),
),
]

View File

@@ -0,0 +1,14 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0022_custom_links'),
]
operations = [
# Update the last_value for tag Tag and TaggedItem ID sequences
migrations.RunSQL("SELECT setval('extras_tag_id_seq', (SELECT id FROM extras_tag ORDER BY id DESC LIMIT 1) + 1)"),
migrations.RunSQL("SELECT setval('extras_taggeditem_id_seq', (SELECT id FROM extras_taggeditem ORDER BY id DESC LIMIT 1) + 1)"),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.2 on 2019-08-12 15:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0023_fix_tag_sequences'),
]
operations = [
migrations.CreateModel(
name='Script',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'permissions': (('run_script', 'Can run script'),),
'managed': False,
},
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-08-28 14:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0024_scripts'),
]
operations = [
migrations.AlterField(
model_name='objectchange',
name='time',
field=models.DateTimeField(auto_now_add=True, db_index=True),
),
]

View File

@@ -1,7 +1,6 @@
from collections import OrderedDict
from datetime import date
import graphviz
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
@@ -12,9 +11,13 @@ from django.db.models import F, Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
import graphviz
from jinja2 import Environment
from taggit.models import TagBase, GenericTaggedItemBase
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import deepmerge, foreground_color
from utilities.fields import ColorField
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -23,6 +26,10 @@ from .querysets import ConfigContextQuerySet
# Webhooks
#
def get_webhook_models():
return model_names_to_filter_dict(WEBHOOK_MODELS)
class Webhook(models.Model):
"""
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
@@ -34,7 +41,7 @@ class Webhook(models.Model):
to=ContentType,
related_name='webhooks',
verbose_name='Object types',
limit_choices_to={'model__in': WEBHOOK_MODELS},
limit_choices_to=get_webhook_models,
help_text="The object(s) to which this Webhook applies."
)
name = models.CharField(
@@ -101,17 +108,22 @@ class Webhook(models.Model):
#
class CustomFieldModel(models.Model):
_cf = None
class Meta:
abstract = True
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if not hasattr(self, 'get_custom_fields'):
return dict()
return {field.name: value for field, value in self.get_custom_fields().items()}
if self._cf is None:
# Cache all custom field values for this instance
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
return self._cf
def get_custom_fields(self):
"""
@@ -124,19 +136,23 @@ class CustomFieldModel(models.Model):
# If the object exists, populate its custom fields with values
if hasattr(self, 'pk'):
values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field')
values = self.custom_field_values.all()
values_dict = {cfv.field_id: cfv.value for cfv in values}
return OrderedDict([(field, values_dict.get(field.pk)) for field in fields])
else:
return OrderedDict([(field, None) for field in fields])
def get_custom_field_models():
return model_names_to_filter_dict(CUSTOMFIELD_MODELS)
class CustomField(models.Model):
obj_type = models.ManyToManyField(
to=ContentType,
related_name='custom_fields',
verbose_name='Object(s)',
limit_choices_to={'model__in': CUSTOMFIELD_MODELS},
limit_choices_to=get_custom_field_models,
help_text='The object(s) to which this field applies.'
)
type = models.PositiveSmallIntegerField(
@@ -297,6 +313,62 @@ class CustomFieldChoice(models.Model):
CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete()
#
# Custom links
#
def get_custom_link_models():
return model_names_to_filter_dict(CUSTOMLINK_MODELS)
class CustomLink(models.Model):
"""
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
code to be rendered with an object as context.
"""
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to=get_custom_link_models
)
name = models.CharField(
max_length=100,
unique=True
)
text = models.CharField(
max_length=500,
help_text="Jinja2 template code for link text"
)
url = models.CharField(
max_length=500,
verbose_name='URL',
help_text="Jinja2 template code for link URL"
)
weight = models.PositiveSmallIntegerField(
default=100
)
group_name = models.CharField(
max_length=50,
blank=True,
help_text="Links with the same group will appear as a dropdown menu"
)
button_class = models.CharField(
max_length=30,
choices=BUTTON_CLASS_CHOICES,
default=BUTTON_CLASS_DEFAULT,
help_text="The class of the first link in a group will be used for the dropdown button"
)
new_window = models.BooleanField(
help_text="Force link to open in a new window"
)
class Meta:
ordering = ['group_name', 'weight', 'name']
def __str__(self):
return self.name
#
# Graphs
#
@@ -342,11 +414,15 @@ class Graph(models.Model):
# Export templates
#
def get_export_template_models():
return model_names_to_filter_dict(EXPORTTEMPLATE_MODELS)
class ExportTemplate(models.Model):
content_type = models.ForeignKey(
to=ContentType,
on_delete=models.CASCADE,
limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}
limit_choices_to=get_export_template_models
)
name = models.CharField(
max_length=100
@@ -355,6 +431,10 @@ class ExportTemplate(models.Model):
max_length=200,
blank=True
)
template_language = models.PositiveSmallIntegerField(
choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2
)
template_code = models.TextField()
mime_type = models.CharField(
max_length=50,
@@ -374,16 +454,36 @@ class ExportTemplate(models.Model):
def __str__(self):
return '{}: {}'.format(self.content_type, self.name)
def render(self, queryset):
"""
Render the contents of the template.
"""
context = {
'queryset': queryset
}
if self.template_language == TEMPLATE_LANGUAGE_DJANGO:
template = Template(self.template_code)
output = template.render(Context(context))
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
template = Environment().from_string(source=self.template_code)
output = template.render(**context)
else:
return None
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
return output
def render_to_response(self, queryset):
"""
Render the template to an HTTP response, delivered as a named file attachment
"""
template = Template(self.template_code)
output = self.render(queryset)
mime_type = 'text/plain' if not self.mime_type else self.mime_type
output = template.render(Context({'queryset': queryset}))
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
# Build the response
response = HttpResponse(output, content_type=mime_type)
@@ -469,7 +569,7 @@ class TopologyMap(models.Model):
# Add each device to the graph
devices = []
for query in device_set.strip(';').split(';'): # Split regexes on semicolons
devices += Device.objects.filter(name__regex=query).select_related('device_role')
devices += Device.objects.filter(name__regex=query).prefetch_related('device_role')
# Remove duplicate devices
devices = [d for d in devices if d.id not in seen]
seen.update([d.id for d in devices])
@@ -507,7 +607,7 @@ class TopologyMap(models.Model):
from dcim.models import Interface
# Add all interface connections to the graph
connected_interfaces = Interface.objects.select_related(
connected_interfaces = Interface.objects.prefetch_related(
'_connected_interface__device'
).filter(
Q(device__in=devices) | Q(_connected_interface__device__in=devices),
@@ -539,7 +639,7 @@ class TopologyMap(models.Model):
from dcim.models import PowerPort
# Add all power connections to the graph
for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices):
for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices):
style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed'
self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style)
@@ -720,12 +820,27 @@ class ConfigContextModel(models.Model):
data = deepmerge(data, context.data)
# If the object has local config context data defined, merge it last
if self.local_context_data is not None:
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
return data
#
# Custom scripts
#
class Script(models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
permissions = (
('run_script', 'Can run script'),
)
#
# Report results
#
@@ -767,7 +882,8 @@ class ObjectChange(models.Model):
"""
time = models.DateTimeField(
auto_now_add=True,
editable=False
editable=False,
db_index=True
)
user = models.ForeignKey(
to=User,
@@ -838,8 +954,10 @@ class ObjectChange(models.Model):
def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings
self.user_name = self.user.username
self.object_repr = str(self.changed_object)
if not self.user_name:
self.user_name = self.user.username
if not self.object_repr:
self.object_repr = str(self.changed_object)
return super().save(*args, **kwargs)
@@ -860,3 +978,37 @@ class ObjectChange(models.Model):
self.object_repr,
self.object_data,
)
#
# Tags
#
# TODO: figure out a way around this circular import for ObjectChange
from utilities.models import ChangeLoggedModel # noqa: E402
class Tag(TagBase, ChangeLoggedModel):
color = ColorField(
default='9e9e9e'
)
comments = models.TextField(
blank=True,
default=''
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(
to=Tag,
related_name="%(app_label)s_%(class)s_items",
on_delete=models.CASCADE
)
class Meta:
index_together = (
("content_type", "object_id")
)

View File

@@ -1,6 +1,24 @@
from collections import OrderedDict
from django.db.models import Q, QuerySet
class CustomFieldQueryset:
"""
Annotate custom fields on objects within a QuerySet.
"""
def __init__(self, queryset, custom_fields):
self.queryset = queryset
self.model = queryset.model
self.custom_fields = custom_fields
def __iter__(self):
for obj in self.queryset:
values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()}
obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields])
yield obj
class ConfigContextQuerySet(QuerySet):
def get_for_object(self, obj):

360
netbox/extras/scripts.py Normal file
View File

@@ -0,0 +1,360 @@
from collections import OrderedDict
import inspect
import json
import os
import pkgutil
import time
import traceback
import yaml
from django import forms
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import transaction
from mptt.forms import TreeNodeChoiceField
from mptt.models import MPTTModel
from ipam.formfields import IPFormField
from utilities.exceptions import AbortTransaction
from utilities.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from .forms import ScriptForm
from .signals import purge_changelog
__all__ = [
'BaseScript',
'BooleanVar',
'FileVar',
'IntegerVar',
'IPNetworkVar',
'ObjectVar',
'Script',
'StringVar',
'TextVar',
]
#
# Script variables
#
class ScriptVariable:
"""
Base model for script variables
"""
form_field = forms.CharField
def __init__(self, label='', description='', default=None, required=True):
# Default field attributes
self.field_attrs = {
'help_text': description,
'required': required
}
if label:
self.field_attrs['label'] = label
if default:
self.field_attrs['initial'] = default
def as_field(self):
"""
Render the variable as a Django form field.
"""
form_field = self.form_field(**self.field_attrs)
if not isinstance(form_field.widget, forms.CheckboxInput):
form_field.widget.attrs['class'] = 'form-control'
return form_field
class StringVar(ScriptVariable):
"""
Character string representation. Can enforce minimum/maximum length and/or regex validation.
"""
def __init__(self, min_length=None, max_length=None, regex=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum lengths
if min_length:
self.field_attrs['min_length'] = min_length
if max_length:
self.field_attrs['max_length'] = max_length
# Optional regular expression validation
if regex:
self.field_attrs['validators'] = [
RegexValidator(
regex=regex,
message='Invalid value. Must match regex: {}'.format(regex),
code='invalid'
)
]
class TextVar(ScriptVariable):
"""
Free-form text data. Renders as a <textarea>.
"""
form_field = forms.CharField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['widget'] = forms.Textarea
class IntegerVar(ScriptVariable):
"""
Integer representation. Can enforce minimum/maximum values.
"""
form_field = forms.IntegerField
def __init__(self, min_value=None, max_value=None, *args, **kwargs):
super().__init__(*args, **kwargs)
# Optional minimum/maximum values
if min_value:
self.field_attrs['min_value'] = min_value
if max_value:
self.field_attrs['max_value'] = max_value
class BooleanVar(ScriptVariable):
"""
Boolean representation (true/false). Renders as a checkbox.
"""
form_field = forms.BooleanField
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Boolean fields cannot be required
self.field_attrs['required'] = False
class ObjectVar(ScriptVariable):
"""
NetBox object representation. The provided QuerySet will determine the choices available.
"""
form_field = forms.ModelChoiceField
def __init__(self, queryset, *args, **kwargs):
super().__init__(*args, **kwargs)
# Queryset for field choices
self.field_attrs['queryset'] = queryset
# Update form field for MPTT (nested) objects
if issubclass(queryset.model, MPTTModel):
self.form_field = TreeNodeChoiceField
class FileVar(ScriptVariable):
"""
An uploaded file.
"""
form_field = forms.FileField
class IPNetworkVar(ScriptVariable):
"""
An IPv4 or IPv6 prefix.
"""
form_field = IPFormField
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
super().__init__(*args, **kwargs)
self.field_attrs['validators'] = list()
# Optional minimum/maximum prefix lengths
if min_prefix_length is not None:
self.field_attrs['validators'].append(
MinPrefixLengthValidator(min_prefix_length)
)
if max_prefix_length is not None:
self.field_attrs['validators'].append(
MaxPrefixLengthValidator(max_prefix_length)
)
#
# Scripts
#
class BaseScript:
"""
Base model for custom scripts. User classes should inherit from this model if they want to extend Script
functionality for use in other subclasses.
"""
class Meta:
pass
def __init__(self):
# Initiate the log
self.log = []
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
def __str__(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
def _get_vars(self):
vars = OrderedDict()
# Infer order from Meta.field_order (Python 3.5 and lower)
field_order = getattr(self.Meta, 'field_order', [])
for name in field_order:
vars[name] = getattr(self, name)
# Default to order of declaration on class
for name, attr in self.__class__.__dict__.items():
if name not in vars and issubclass(attr.__class__, ScriptVariable):
vars[name] = attr
return vars
def run(self, data):
raise NotImplementedError("The script must define a run() method.")
def as_form(self, data=None, files=None):
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
vars = self._get_vars()
form = ScriptForm(vars, data, files)
return form
# Logging
def log_debug(self, message):
self.log.append((LOG_DEFAULT, message))
def log_success(self, message):
self.log.append((LOG_SUCCESS, message))
def log_info(self, message):
self.log.append((LOG_INFO, message))
def log_warning(self, message):
self.log.append((LOG_WARNING, message))
def log_failure(self, message):
self.log.append((LOG_FAILURE, message))
# Convenience functions
def load_yaml(self, filename):
"""
Return data from a YAML file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = yaml.load(datafile)
return data
def load_json(self, filename):
"""
Return data from a JSON file
"""
file_path = os.path.join(settings.SCRIPTS_ROOT, filename)
with open(file_path, 'r') as datafile:
data = json.load(datafile)
return data
class Script(BaseScript):
"""
Classes which inherit this model will appear in the list of available scripts.
"""
pass
#
# Functions
#
def is_script(obj):
"""
Returns True if the object is a Script.
"""
try:
return issubclass(obj, Script) and obj != Script
except TypeError:
return False
def is_variable(obj):
"""
Returns True if the object is a ScriptVariable.
"""
return isinstance(obj, ScriptVariable)
def run_script(script, data, files, commit=True):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
"""
output = None
start_time = None
end_time = None
# Add files to form data
for field_name, fileobj in files.items():
data[field_name] = fileobj
try:
with transaction.atomic():
start_time = time.time()
output = script.run(data)
end_time = time.time()
if not commit:
raise AbortTransaction()
except AbortTransaction:
pass
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
"An exception occurred: `{}: {}`\n```\n{}\n```".format(type(e).__name__, e, stacktrace)
)
commit = False
finally:
if not commit:
# Delete all pending changelog entries
purge_changelog.send(Script)
script.log_info(
"Database changes have been reverted automatically."
)
# Calculate execution time
if end_time is not None:
execution_time = end_time - start_time
else:
execution_time = None
return output, execution_time
def get_scripts():
scripts = OrderedDict()
# Iterate through all modules within the reports path. These are the user-created files in which reports are
# defined.
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
module = importer.find_module(module_name).load_module(module_name)
if hasattr(module, 'name'):
module_name = module.name
module_scripts = OrderedDict()
for name, cls in inspect.getmembers(module, is_script):
module_scripts[name] = cls
scripts[module_name] = module_scripts
return scripts

34
netbox/extras/signals.py Normal file
View File

@@ -0,0 +1,34 @@
from cacheops.signals import cache_invalidated, cache_read
from django.dispatch import Signal
from prometheus_client import Counter
#
# Caching
#
cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits')
cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses')
cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations')
def cache_read_collector(sender, func, hit, **kwargs):
if hit:
cacheops_cache_hit.inc()
else:
cacheops_cache_miss.inc()
def cache_invalidated_collector(sender, obj_dict, **kwargs):
cacheops_cache_invalidated.inc()
cache_read.connect(cache_read_collector)
cache_invalidated.connect(cache_invalidated_collector)
#
# Change logging
#
purge_changelog = Signal()

View File

@@ -1,11 +1,13 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from taggit.models import Tag, TaggedItem
from utilities.tables import BaseTable, BooleanColumn, ToggleColumn
from .models import ConfigContext, ObjectChange
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
<i class="fa fa-history"></i>
</a>
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
@@ -71,10 +73,11 @@ class TagTable(BaseTable):
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
color = ColorColumn()
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'name', 'items', 'slug', 'actions')
fields = ('pk', 'name', 'items', 'slug', 'color', 'actions')
class TaggedItemTable(BaseTable):

View File

View File

@@ -0,0 +1,77 @@
from collections import OrderedDict
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from jinja2 import Environment
from extras.models import CustomLink
register = template.Library()
LINK_BUTTON = '<a href="{}"{} class="btn btn-sm btn-{}">{}</a>\n'
GROUP_BUTTON = '<div class="btn-group">\n' \
'<button type="button" class="btn btn-sm btn-{} dropdown-toggle" data-toggle="dropdown">\n' \
'{} <span class="caret"></span>\n' \
'</button>\n' \
'<ul class="dropdown-menu pull-right">\n' \
'{}</ul></div>'
GROUP_LINK = '<li><a href="{}"{}>{}</a></li>\n'
@register.simple_tag()
def custom_links(obj):
"""
Render all applicable links for the given object.
"""
content_type = ContentType.objects.get_for_model(obj)
custom_links = CustomLink.objects.filter(content_type=content_type)
if not custom_links:
return ''
context = {
'obj': obj,
}
template_code = ''
group_names = OrderedDict()
for cl in custom_links:
# Organize custom links by group
if cl.group_name and cl.group_name in group_names:
group_names[cl.group_name].append(cl)
elif cl.group_name:
group_names[cl.group_name] = [cl]
# Add non-grouped links
else:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, text_rendered
)
# Add grouped links to template
for group, links in group_names.items():
links_rendered = []
for cl in links:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
)
if links_rendered:
template_code += GROUP_BUTTON.format(
links[0].button_class, group, ''.join(links_rendered)
)
# Render template
rendered = Environment().from_string(source=template_code).render(**context)
return mark_safe(rendered)

View File

@@ -0,0 +1,37 @@
from django import template
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
register = template.Library()
@register.inclusion_tag('extras/templatetags/log_level.html')
def log_level(level):
"""
Display a label indicating a syslog severity (e.g. info, warning, etc.).
"""
levels = {
LOG_DEFAULT: {
'name': 'Default',
'class': 'default'
},
LOG_SUCCESS: {
'name': 'Success',
'class': 'success',
},
LOG_INFO: {
'name': 'Info',
'class': 'info'
},
LOG_WARNING: {
'name': 'Warning',
'class': 'warning'
},
LOG_FAILURE: {
'name': 'Failure',
'class': 'danger'
}
}
return levels[level]

View File

@@ -1,11 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from taggit.models import Tag
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from extras.constants import GRAPH_TYPE_SITE
from extras.models import ConfigContext, Graph, ExportTemplate
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from tenancy.models import Tenant, TenantGroup
from utilities.testing import APITestCase

View File

@@ -0,0 +1,72 @@
from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
from extras.models import ObjectChange
from utilities.testing import APITestCase
class ChangeLogTest(APITestCase):
def test_create_object(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ObjectChange.objects.count(), 1)
oc = ObjectChange.objects.first()
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
def test_update_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ObjectChange.objects.count(), 1)
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.name, data['name'])
oc = ObjectChange.objects.first()
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
def test_delete_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Site.objects.count(), 0)
oc = ObjectChange.objects.first()
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)

View File

@@ -6,9 +6,10 @@ from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL
from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from utilities.testing import APITestCase
from virtualization.models import VirtualMachine
class CustomFieldTest(TestCase):
@@ -299,3 +300,33 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice'])
cfv = self.site.custom_field_values.get(field=self.cf_select)
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
class CustomFieldChoiceAPITest(APITestCase):
def setUp(self):
super().setUp()
vm_content_type = ContentType.objects.get_for_model(VirtualMachine)
self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT)
self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT)
self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100)
self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50)
self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10)
def test_list_cfc(self):
url = reverse('extras-api:custom-field-choice-list')
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 2)
self.assertEqual(len(response.data[self.cf_1.name]), 2)
self.assertEqual(len(response.data[self.cf_2.name]), 1)
self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name])
self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name])
self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name])
self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value])
self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value])
self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value])

View File

@@ -0,0 +1,157 @@
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from netaddr import IPNetwork
from dcim.models import DeviceRole
from extras.scripts import *
class ScriptVariablesTest(TestCase):
def test_stringvar(self):
class TestScript(Script):
var1 = StringVar(
min_length=3,
max_length=3,
regex=r'[a-z]+'
)
# Validate min_length enforcement
data = {'var1': 'xx'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate max_length enforcement
data = {'var1': 'xxxx'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate regex enforcement
data = {'var1': 'ABC'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': 'abc'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_textvar(self):
class TestScript(Script):
var1 = TextVar()
# Validate valid data
data = {'var1': 'This is a test string'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_integervar(self):
class TestScript(Script):
var1 = IntegerVar(
min_value=5,
max_value=10
)
# Validate min_value enforcement
data = {'var1': 4}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate max_value enforcement
data = {'var1': 11}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': 7}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], data['var1'])
def test_booleanvar(self):
class TestScript(Script):
var1 = BooleanVar()
# Validate True
data = {'var1': True}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], True)
# Validate False
data = {'var1': False}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], False)
def test_objectvar(self):
class TestScript(Script):
var1 = ObjectVar(
queryset=DeviceRole.objects.all()
)
# Populate some objects
for i in range(1, 6):
DeviceRole(
name='Device Role {}'.format(i),
slug='device-role-{}'.format(i)
).save()
# Validate valid data
data = {'var1': DeviceRole.objects.first().pk}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'].pk, data['var1'])
def test_filevar(self):
class TestScript(Script):
var1 = FileVar()
# Dummy file
testfile = SimpleUploadedFile(
name='test_file.txt',
content=b'This is a dummy file for testing'
)
# Validate valid data
file_data = {'var1': testfile}
form = TestScript().as_form(None, file_data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], testfile)
def test_ipnetworkvar(self):
class TestScript(Script):
var1 = IPNetworkVar()
# Validate IP network enforcement
data = {'var1': '1.2.3'}
form = TestScript().as_form(data, None)
self.assertFalse(form.is_valid())
self.assertIn('var1', form.errors)
# Validate valid data
data = {'var1': '192.0.2.0/24'}
form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))

View File

@@ -35,9 +35,9 @@ class TaggedItemTest(APITestCase):
site = Site.objects.create(
name='Test Site',
slug='test-site',
tags=['Foo', 'Bar', 'Baz']
slug='test-site'
)
site.tags.add('Foo', 'Bar', 'Baz')
data = {
'tags': ['Foo', 'Bar', 'New Tag']

View File

@@ -4,17 +4,19 @@ import uuid
from django.contrib.auth.models import User
from django.test import Client, TestCase
from django.urls import reverse
from taggit.models import Tag
from dcim.models import Site
from extras.models import ConfigContext, ObjectChange
from extras.constants import OBJECTCHANGE_ACTION_UPDATE
from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import create_test_user
class TagTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['extras.view_tag'])
self.client = Client()
self.client.force_login(user)
Tag.objects.bulk_create([
Tag(name='Tag 1', slug='tag-1'),
@@ -36,8 +38,9 @@ class TagTestCase(TestCase):
class ConfigContextTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['extras.view_configcontext'])
self.client = Client()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
@@ -71,22 +74,19 @@ class ConfigContextTestCase(TestCase):
class ObjectChangeTestCase(TestCase):
def setUp(self):
user = create_test_user(permissions=['extras.view_objectchange'])
self.client = Client()
user = User(username='testuser', email='testuser@example.com')
user.save()
self.client.force_login(user)
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ObjectChanges
for i in range(1, 4):
site.log_change(
user=user,
request_id=uuid.uuid4(),
action=2
)
oc = site.to_objectchange(action=OBJECTCHANGE_ACTION_UPDATE)
oc.user = user
oc.request_id = uuid.uuid4()
oc.save()
def test_objectchange_list(self):

View File

@@ -1,37 +1,45 @@
from django.conf.urls import url
from django.urls import path
from extras import views
from extras.models import Tag
app_name = 'extras'
urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
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'),
path(r'tags/', views.TagListView.as_view(), name='tag_list'),
path(r'tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path(r'tags/<slug:slug>/', views.TagView.as_view(), name='tag'),
path(r'tags/<slug:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path(r'tags/<slug:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path(r'tags/<slug:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# 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/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
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'),
path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path(r'config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path(r'config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path(r'config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
# Image attachments
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
url(r'^image-attachments/(?P<pk>\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Reports
url(r'^reports/$', views.ReportListView.as_view(), name='report_list'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'),
url(r'^reports/(?P<name>[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'),
path(r'image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
path(r'image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
# Change logging
url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'),
url(r'^changelog/(?P<pk>\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'),
path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'),
path(r'changelog/<int:pk>/', views.ObjectChangeView.as_view(), name='objectchange'),
# Reports
path(r'reports/', views.ReportListView.as_view(), name='report_list'),
path(r'reports/<str:name>/', views.ReportView.as_view(), name='report'),
path(r'reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
# Scripts
path(r'scripts/', views.ScriptListView.as_view(), name='script_list'),
path(r'scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
]

View File

@@ -4,23 +4,19 @@ from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.http import Http404
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from django_tables2 import RequestConfig
from taggit.models import Tag, TaggedItem
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters
from .forms import (
ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm,
TagFilterForm, TagForm,
)
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports
from .scripts import get_scripts, run_script
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
@@ -28,14 +24,15 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
# Tags
#
class TagListView(ObjectListView):
class TagListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_tag'
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
filter = filters.TagFilter
filter_form = TagFilterForm
filter_form = forms.TagFilterForm
table = TagTable
template_name = 'extras/tag_list.html'
@@ -47,10 +44,8 @@ class TagView(View):
tag = get_object_or_404(Tag, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).select_related(
'content_type'
).prefetch_related(
'content_object'
'content_type', 'content_object'
)
# Generate a table of all items tagged with this Tag
@@ -69,22 +64,36 @@ class TagView(View):
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'taggit.change_tag'
permission_required = 'extras.change_tag'
model = Tag
model_form = TagForm
model_form = forms.TagForm
default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html'
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'taggit.delete_tag'
permission_required = 'extras.delete_tag'
model = Tag
default_return_url = 'extras:tag_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'taggit.delete_tag'
class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_tag'
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
# filter = filters.ProviderFilter
table = TagTable
form = forms.TagBulkEditForm
default_return_url = 'circuits:provider_list'
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_tag'
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(
'name'
)
@@ -96,15 +105,17 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
# Config contexts
#
class ConfigContextListView(ObjectListView):
class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_configcontext'
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
filter_form = forms.ConfigContextFilterForm
table = ConfigContextTable
template_name = 'extras/configcontext_list.html'
class ConfigContextView(View):
class ConfigContextView(PermissionRequiredMixin, View):
permission_required = 'extras.view_configcontext'
def get(self, request, pk):
@@ -118,7 +129,7 @@ class ConfigContextView(View):
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.add_configcontext'
model = ConfigContext
model_form = ConfigContextForm
model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html'
@@ -132,7 +143,7 @@ class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
table = ConfigContextTable
form = ConfigContextBulkEditForm
form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list'
@@ -143,7 +154,7 @@ class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_cconfigcontext'
permission_required = 'extras.delete_configcontext'
queryset = ConfigContext.objects.all()
table = ConfigContextTable
default_return_url = 'extras:configcontext_list'
@@ -173,15 +184,17 @@ class ObjectConfigContextView(View):
# Change logging
#
class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.select_related('user', 'changed_object_type')
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_objectchange'
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filter = filters.ObjectChangeFilter
filter_form = ObjectChangeFilterForm
filter_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable
template_name = 'extras/objectchange_list.html'
class ObjectChangeView(View):
class ObjectChangeView(PermissionRequiredMixin, View):
permission_required = 'extras.view_objectchange'
def get(self, request, pk):
@@ -212,7 +225,7 @@ class ObjectChangeLogView(View):
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.select_related(
objectchanges = ObjectChange.objects.prefetch_related(
'user', 'changed_object_type'
).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
@@ -223,6 +236,13 @@ class ObjectChangeLogView(View):
orderable=False
)
# Apply the request context
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(objectchanges_table)
# Check whether a header template exists for this model
base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name)
try:
@@ -234,7 +254,7 @@ class ObjectChangeLogView(View):
return render(request, 'extras/object_changelog.html', {
object_var: obj,
'objectchanges_table': objectchanges_table,
'table': objectchanges_table,
'base_template': base_template,
'active_tab': 'changelog',
})
@@ -247,7 +267,7 @@ class ObjectChangeLogView(View):
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_imageattachment'
model = ImageAttachment
model_form = ImageAttachmentForm
model_form = forms.ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs):
if not imageattachment.pk:
@@ -272,10 +292,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
# Reports
#
class ReportListView(View):
class ReportListView(PermissionRequiredMixin, View):
"""
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
"""
permission_required = 'extras.view_reportresult'
def get(self, request):
@@ -295,10 +316,11 @@ class ReportListView(View):
})
class ReportView(View):
class ReportView(PermissionRequiredMixin, View):
"""
Display a single Report and its associated ReportResult (if any).
"""
permission_required = 'extras.view_reportresult'
def get(self, request, name):
@@ -341,3 +363,62 @@ class ReportRunView(PermissionRequiredMixin, View):
messages.success(request, mark_safe(msg))
return redirect('extras:report', name=report.full_name)
#
# Scripts
#
class ScriptListView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get(self, request):
return render(request, 'extras/script_list.html', {
'scripts': get_scripts(),
})
class ScriptView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def _get_script(self, module, name):
scripts = get_scripts()
try:
return scripts[module][name]()
except KeyError:
raise Http404
def get(self, request, module, name):
script = self._get_script(module, name)
form = script.as_form()
return render(request, 'extras/script.html', {
'module': module,
'script': script,
'form': form,
})
def post(self, request, module, name):
# Permissions check
if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden()
script = self._get_script(module, name)
form = script.as_form(request.POST, request.FILES)
output = None
execution_time = None
if form.is_valid():
commit = form.cleaned_data.pop('_commit')
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
return render(request, 'extras/script.html', {
'module': module,
'script': script,
'form': form,
'output': output,
'execution_time': execution_time,
})

View File

@@ -9,12 +9,12 @@ from utilities.api import get_serializer_for_model
from .constants import WEBHOOK_MODELS
def enqueue_webhooks(instance, action):
def enqueue_webhooks(instance, user, request_id, action):
"""
Find Webhook(s) assigned to this instance + action and enqueue them
to be processed
"""
if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
if not settings.WEBHOOKS_ENABLED or instance._meta.label.lower() not in WEBHOOK_MODELS:
return
# Retrieve any applicable Webhooks
@@ -47,5 +47,7 @@ def enqueue_webhooks(instance, action):
serializer.data,
instance._meta.model_name,
action,
str(datetime.datetime.now())
str(datetime.datetime.now()),
user.username,
request_id
)

View File

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

View File

@@ -21,10 +21,11 @@ __all__ = [
class NestedVRFSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VRF
fields = ['id', 'url', 'name', 'rd']
fields = ['id', 'url', 'name', 'rd', 'prefix_count']
#
@@ -33,10 +34,11 @@ class NestedVRFSerializer(WritableNestedSerializer):
class NestedRIRSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True)
class Meta:
model = RIR
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'aggregate_count']
class NestedAggregateSerializer(WritableNestedSerializer):
@@ -53,18 +55,21 @@ class NestedAggregateSerializer(WritableNestedSerializer):
class NestedRoleSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Role
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count']
class NestedVLANGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
fields = ['id', 'url', 'name', 'slug']
fields = ['id', 'url', 'name', 'slug', 'vlan_count']
class NestedVLANSerializer(WritableNestedSerializer):

View File

@@ -25,12 +25,14 @@ from .nested_serializers import *
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VRF
fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
'created', 'last_updated',
'created', 'last_updated', 'ipaddress_count', 'prefix_count',
]
@@ -39,10 +41,11 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RIRSerializer(ValidatedModelSerializer):
aggregate_count = serializers.IntegerField(read_only=True)
class Meta:
model = RIR
fields = ['id', 'name', 'slug', 'is_private']
fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count']
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
@@ -63,18 +66,21 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RoleSerializer(ValidatedModelSerializer):
prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight']
fields = ['id', 'name', 'slug', 'weight', 'prefix_count', 'vlan_count']
class VLANGroupSerializer(ValidatedModelSerializer):
site = NestedSiteSerializer(required=False, allow_null=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
fields = ['id', 'name', 'slug', 'site']
fields = ['id', 'name', 'slug', 'site', 'vlan_count']
validators = []
def validate(self, data):
@@ -99,12 +105,13 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLAN
fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
'custom_fields', 'created', 'last_updated',
'custom_fields', 'created', 'last_updated', 'prefix_count',
]
validators = []
@@ -128,6 +135,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=AF_CHOICES, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -189,6 +197,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=AF_CHOICES, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
@@ -201,8 +210,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta:
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside',
'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']

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