Compare commits

...

133 Commits

Author SHA1 Message Date
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
rclerc
f4636537ad Add the decommissioning status for devices 2019-04-24 09:14:29 +02:00
Jeremy Stretch
f4aec1e7d0 Change VLAN view columns to a 4/8 split 2019-04-22 07:47:16 -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
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
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
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
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
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
d112b6027a Merge pull request #2990 from digitalocean/develop
Release v2.5.8
2019-03-11 13:37:07 -04:00
Jeremy Stretch
88933a3120 Release v2.5.8 2019-03-11 13:34:56 -04:00
Jeremy Stretch
e05871b467 Fixes #2982: Correct CSS class assignment on color picker 2019-03-11 13:31:48 -04:00
Jeremy Stretch
7e70bfaacc Fixes #2985: Fix pagination page length for rack elevations 2019-03-11 12:51:03 -04:00
Jeremy Stretch
f4b85751bb Fixes #2940: Allow CSV import of prefixes/IPs to VRF without an RD assigned 2019-03-11 12:34:19 -04:00
Jeremy Stretch
ea11e70e3f Fixes #2984: Fix logging of unlabeled cable ID on cable deletion 2019-03-11 12:26:40 -04:00
Jeremy Stretch
bd8b239e15 Fixes #2980: Improve rendering time for API docs 2019-03-08 10:28:11 -05:00
Jeremy Stretch
7a8fc8dfd5 Changelog for #2944 2019-03-07 14:08:36 -05:00
Jeremy Stretch
4334f1bc65 Merge pull request #2979 from TakeMeNL/fix/2944
Fixes: #2944 Adding/removing an IP address is not recorded on the associated interface's changelog
2019-03-07 14:06:00 -05:00
Jeremy Stretch
1995091169 Changelog & renaming for #2065 2019-03-07 13:58:34 -05:00
Jeremy Stretch
ef089d3722 Merge pull request #2957 from axnsan12/ref-name
Resolve drf-yasg `ref_name` conflicts
2019-03-07 13:52:29 -05:00
Marc
1d904b1722 Fix IP address is not recorded in the associated interface's changelog 2019-03-07 10:57:26 +01:00
Jeremy Stretch
ab02f26a0e Fixes #2972: Improve ContentTypeField serializer to elegantly handle invalid data 2019-03-06 12:42:47 -05:00
Jeremy Stretch
f35b4bf768 Fixes #2976: Add delete button to tag view 2019-03-06 12:11:01 -05:00
Jeremy Stretch
f2382dd255 Merge pull request #2969 from DanSheps/2435-PrintingCSS
Closes #2435: Printer friendly CSS
2019-03-06 09:44:04 -05:00
Daniel Sheppard
8df169b170 Remove .noprint from outside of the @media tag 2019-03-05 21:52:54 -06:00
Daniel Sheppard
88aeaaffb0 Remove .noprint from outside of the @media tag 2019-03-05 21:52:37 -06:00
dansheps
e9546b810c * Updated changelog 2019-03-05 15:46:41 -06:00
dansheps
c208d8fc2e * Added CSS to:
* Hide URLs
  * Hide elements with "noprint" class
* Added noprint to:
  * Header Panel
  * Search Panel, Tags Panel
  * Buttons
  * Various list elements
  * Related elements
2019-03-05 15:42:47 -06:00
Jeremy Stretch
7294f43fa3 Changelog for #2966 2019-03-05 13:18:56 -05:00
Jeremy Stretch
0572c66c17 Merge pull request #2967 from Anthony25/fix_cable_length_unit_api
Allow nullable length unit in cable API
2019-03-05 13:17:37 -05:00
Jeremy Stretch
6406e213bd Fixes #2961: Prevent exception when exporting inventory items belonging to unnamed devices 2019-03-05 13:15:09 -05:00
Jeremy Stretch
cfb56f7cfe Fixes #2962: Increase ExportTemplate mime_type field length 2019-03-05 13:08:40 -05:00
Anthony Ruhier
bd65e782bb Allow nullable length unit in cable API
Cables models define it as None by default, but the API rejects a
request containing a null length_unit. Allows it in the API
serializer.
2019-03-05 18:44:44 +01:00
Jeremy Stretch
0c142f2078 Changelog for #2954 2019-03-04 15:16:35 -05:00
Jeremy Stretch
7295c3554e Merge pull request #2955 from axnsan12/trailing-slashes
Remove trailing slashes from filesystem paths
2019-03-04 15:14:45 -05:00
Jeremy Stretch
78725b8483 Follow-up from #2781 2019-03-04 14:57:35 -05:00
Jeremy Stretch
e97ad3f066 Merge pull request #2935 from DanSheps/2781-fixsitelistfilterregion
Fixes: #2781 - Enable Filtering by Multiple Regions on Site and Device Lists
2019-03-04 14:42:04 -05:00
Cristi Vîjdea
3a62e9a322 Resolve drf-yasg ref_name conflicts
This solves the problem of distinct serializers being confused because they have the same class name (e.g. `InterfaceSerializer`)

Fixes #2065.
2019-03-04 03:49:26 +02:00
Cristi Vîjdea
4d18d9661b Remove trailing slashes from filesystem paths
Paths with trailing slashes do not work on windows, they cause errors such as `django.core.exceptions.SuspiciousFileOperation: The joined path (C:\Projects\netbox\netbox\static\clipboard-2.0.4.min.js) is located outside of the base path component (C:\Projects\netbox\netbox\static\)`.
2019-03-04 03:38:15 +02:00
John Anderson
b381bdec27 fixes #2952 - slug field absent from TenantFilter 2019-03-03 18:52:57 -05:00
Jeremy Stretch
6f5c35c278 Force resolution of request User object when logging an object deletion (resolves intermittent test failures) 2019-02-28 11:40:32 -05:00
Jeremy Stretch
3e6033e9ff Fixes #2938: Exclude circuit terminations from API interface connections endpoint 2019-02-28 09:59:17 -05:00
Jeremy Stretch
32f63a18ff Fixes #2938: Enforce deterministic ordering of device components returned by API 2019-02-28 09:49:23 -05:00
dansheps
00aaf500de Fixes #2781: Fixes filter by regions on site and device list
* Add Device filter
2019-02-27 14:46:11 -06:00
dansheps
f2471aedb2 Fixes #2781: Fixes filter by regions on site and device list 2019-02-27 11:41:12 -06:00
Jeremy Stretch
beff774295 Fixes #2705: Fix endpoint grouping in API docs 2019-02-26 16:38:49 -05:00
Jeremy Stretch
8dfef83f1a Changelog for #2923 2019-02-22 12:16:15 -05:00
Jeremy Stretch
d707844c30 Merge pull request #2928 from DanSheps/2923-changecircuitsitefiltermulti
Fixes #2923: Changes Site on ProviderFilterForm to APISelectMultiple
2019-02-22 12:13:56 -05:00
Daniel Sheppard
3ffea43253 Fixes #2923: Changes Site Filter on Provider List to APISelectMultiple 2019-02-22 07:13:39 -06:00
Jeremy Stretch
77c387a559 Post-release version bump 2019-02-21 14:45:23 -05:00
Jeremy Stretch
ac1e4b8e8f Merge pull request #2922 from digitalocean/develop
Release v2.5.7
2019-02-21 14:44:48 -05:00
Jeremy Stretch
36101f50c3 Release v2.5.7 2019-02-21 14:42:44 -05:00
Jeremy Stretch
08dad7f7af Changelog for #2638 2019-02-21 13:30:57 -05:00
hSaria
298152bc50 Closes #2638: Copy Button for Secrets (#2860)
* Added clipboard.min.js

* Added copy button to secrets

* renamed clipboard JS library
2019-02-21 13:26:19 -05:00
Jeremy Stretch
993e94c00c Fixes #2852: Allow filtering devices by null rack position 2019-02-21 10:26:31 -05:00
Jeremy Stretch
4c3a7b84c1 Merge pull request #2881 from candlerb/candlerb/doc-reports
Clarify use of "runreport" CLI
2019-02-20 12:04:35 -05:00
Jeremy Stretch
8961a54a03 Chnagelog for #2890 2019-02-20 12:01:37 -05:00
Jeremy Stretch
2184a9402f Merge pull request #2891 from gdelafond/add-port-types
Fixes #2890: add port type LC/APC, SC/APC, LSH/APC
2019-02-20 12:00:39 -05:00
Jeremy Stretch
b9f1d14d4e Closes #2870: Add Markdown rendering for provider NOC/admin contact fields 2019-02-20 11:48:11 -05:00
Jeremy Stretch
fba154386e Closes #2898: Enable filtering cables list by connection status 2019-02-20 11:36:55 -05:00
Jeremy Stretch
c8366eff8f Fixes #2915: Fix bulk editing of pass-through ports 2019-02-20 11:13:43 -05:00
Jeremy Stretch
161d9ed512 Fixes #2912: Cable type in filter form should be blank by default 2019-02-20 10:21:34 -05:00
Jeremy Stretch
fadc9521f0 Fixes #2913: Fix assigned prefixes link on VRF view 2019-02-20 10:17:36 -05:00
Jeremy Stretch
40cb47868f Fixes #2914: Fix empty connected circuit link on device interfaces list 2019-02-20 10:13:36 -05:00
John Anderson
9d055ff4fd fixed #2910 - config context list and edit views not using select2 2019-02-18 21:10:15 -05:00
Jeremy Stretch
9d69f14faa Implement basic view testing for all models (#2909) 2019-02-15 17:02:18 -05:00
Jeremy Stretch
8e3ea6c878 Closes #2878: Add cable types for OS1/OS2 singlemode fiber 2019-02-15 14:42:03 -05:00
Jeremy Stretch
971f3cd63c Closes #2357: Enable filtering of devices by rack face 2019-02-15 11:31:57 -05:00
Jeremy Stretch
a9d7a7e306 Closes #2903: Clarify purpose of tags field on interface edit form 2019-02-15 10:23:46 -05:00
Jeremy Stretch
8797298a71 Fixes #2884: Don't display connect button for wireless interfaces 2019-02-15 10:14:44 -05:00
Jeremy Stretch
3bc182e453 Fixes #2901: Fix ordering regions by site count 2019-02-15 10:10:26 -05:00
Jeremy Stretch
7a0ab3aa15 Fixes #2895: Fix filtering of nullable character fields 2019-02-14 09:43:56 -05:00
Jeremy Stretch
fdbef8ee71 Closes #2836: Deprecate NullableCharField 2019-02-14 09:39:04 -05:00
Jeremy Stretch
d95b127378 Changelog for #2888 and #2893 2019-02-14 09:20:27 -05:00
Jeremy Stretch
008138cd02 Merge pull request #2889 from stuntguy3000/patch-1
Fixes #2888 - Render correct foreground colour for rack devices
2019-02-14 09:14:36 -05:00
Jeremy Stretch
2a1630e068 Merge pull request #2894 from TakeMeNL/bug/duplicate-vrf
Remove duplicate VRF RD on ip address view
2019-02-14 09:09:32 -05:00
TakeMeNL
31611203eb ipaddress.vrf already includes ipaddress.vrf.rd 2019-02-14 14:48:25 +01:00
Guillaume de Lafond
1ee6d16d78 add port type LC/APC, SC/APC, LSH/APC 2019-02-14 10:17:25 +01:00
Luke Anderson
0e8c3a8efe Render correct foreground colour for rack devices 2019-02-14 16:37:27 +10:30
Jeremy Stretch
d084d19675 Post-release version bump 2019-02-13 17:11:51 -05:00
Brian Candler
8d86e97247 Clarify use of "runreport" CLI 2019-02-13 08:54:04 +00:00
154 changed files with 2164 additions and 437 deletions

View File

@@ -1,3 +1,126 @@
2.5.11 (2019-04-29)
## Notes
This release upgrades the Django framework to version 2.2.
## Enhancements
* [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components
* [#3023](https://github.com/digitalocean/netbox/issues/3023) - Add support for filtering cables by connected device
* [#3070](https://github.com/digitalocean/netbox/issues/3070) - Add decommissioning status for devices
## Bug Fixes
* [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware
* [#3072](https://github.com/digitalocean/netbox/issues/3072) - Preserve multiselect filter values when updating per-page count for list views
* [#3112](https://github.com/digitalocean/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device
* [#3116](https://github.com/digitalocean/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint
* [#3118](https://github.com/digitalocean/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled
---
v2.5.10 (2019-04-08)
## Enhancements
* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates
## Bug Fixes
* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view
* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces
* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API
* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update
* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search
* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint
* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API
---
v2.5.9 (2019-04-01)
## Enhancements
* [#2933](https://github.com/digitalocean/netbox/issues/2933) - Add username to outbound webhook requests
* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+)
* [#3025](https://github.com/digitalocean/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request)
## Bug Fixes
* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes deterministic ordering of interfaces
* [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering
* [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE
* [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry
* [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable
* [#3001](https://github.com/digitalocean/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type`
* [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering
* [#3019](https://github.com/digitalocean/netbox/issues/3019) - Fix tag population when running NetBox within a path
* [#3022](https://github.com/digitalocean/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint
* [#3026](https://github.com/digitalocean/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher
* [#3027](https://github.com/digitalocean/netbox/issues/3027) - Ignore empty local context data when rendering config contexts
* [#3032](https://github.com/digitalocean/netbox/issues/3032) - Save assigned tags when creating a new secret
---
v2.5.8 (2019-03-11)
## Enhancements
* [#2435](https://github.com/digitalocean/netbox/issues/2435) - Printer friendly CSS
## Bug Fixes
* [#2065](https://github.com/digitalocean/netbox/issues/2065) - Correct documentation for VM interface serializer
* [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs
* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions
* [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default
* [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API
* [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint
* [#2940](https://github.com/digitalocean/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned
* [#2944](https://github.com/digitalocean/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any)
* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function
* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows
* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices
* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length
* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API
* [#2972](https://github.com/digitalocean/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data
* [#2976](https://github.com/digitalocean/netbox/issues/2976) - Add delete button to tag view
* [#2980](https://github.com/digitalocean/netbox/issues/2980) - Improve rendering time for API docs
* [#2982](https://github.com/digitalocean/netbox/issues/2982) - Correct CSS class assignment on color picker
* [#2984](https://github.com/digitalocean/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion
* [#2985](https://github.com/digitalocean/netbox/issues/2985) - Fix pagination page length for rack elevations
---
v2.5.7 (2019-02-21)
## Enhancements
* [#2357](https://github.com/digitalocean/netbox/issues/2357) - Enable filtering of devices by rack face
* [#2638](https://github.com/digitalocean/netbox/issues/2638) - Add button to copy unlocked secret to clipboard
* [#2870](https://github.com/digitalocean/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields
* [#2878](https://github.com/digitalocean/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber
* [#2890](https://github.com/digitalocean/netbox/issues/2890) - Add port types for APC fiber
* [#2898](https://github.com/digitalocean/netbox/issues/2898) - Enable filtering cables list by connection status
* [#2903](https://github.com/digitalocean/netbox/issues/2903) - Clarify purpose of tags field on interface edit form
## Bug Fixes
* [#2852](https://github.com/digitalocean/netbox/issues/2852) - Allow filtering devices by null rack position
* [#2884](https://github.com/digitalocean/netbox/issues/2884) - Don't display connect button for wireless interfaces
* [#2888](https://github.com/digitalocean/netbox/issues/2888) - Correct foreground color of device roles in rack elevations
* [#2893](https://github.com/digitalocean/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view
* [#2895](https://github.com/digitalocean/netbox/issues/2895) - Fix filtering of nullable character fields
* [#2901](https://github.com/digitalocean/netbox/issues/2901) - Fix ordering regions by site count
* [#2910](https://github.com/digitalocean/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements
* [#2912](https://github.com/digitalocean/netbox/issues/2912) - Cable type in filter form should be blank by default
* [#2913](https://github.com/digitalocean/netbox/issues/2913) - Fix assigned prefixes link on VRF view
* [#2914](https://github.com/digitalocean/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list
* [#2915](https://github.com/digitalocean/netbox/issues/2915) - Fix bulk editing of pass-through ports
---
v2.5.6 (2019-02-13)
## Enhancements

View File

@@ -45,13 +45,13 @@ and run `upgrade.sh`.
## Supported SDK
- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox.
- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox
## Community SDK
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2.
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox
- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox
## Ansible Inventory
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox.
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for Netbox

View File

@@ -128,4 +128,4 @@ Reports can be run on the CLI by invoking the management command:
python3 manage.py runreport <module>
```
One or more report modules may be specified.
where ``<module>`` is the name of the python file in the ``reports`` directory without the ``.py`` extension. One or more report modules may be specified.

View File

@@ -261,7 +261,7 @@ A list of objects retrieved via the API can be filtered by passing one or more q
GET /api/ipam/prefixes/?status=1
```
The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes:
```
GET /api/ipam/prefixes/?status=1&status=2

View File

@@ -283,6 +283,7 @@ REDIS = {
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
```
@@ -315,3 +316,9 @@ The TCP port to use when connecting to the Redis server.
Default: None
The password to use when authenticating to the Redis server (optional).
### SSL
Default: False
Use secure sockets layer to encrypt the connections to the Redis server.

View File

@@ -107,7 +107,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
widget=APISelect(
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)

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 %}
"""
@@ -59,7 +59,7 @@ class CircuitTypeTable(BaseTable):
name = tables.LinkColumn()
circuit_count = tables.Column(verbose_name='Circuits')
actions = tables.TemplateColumn(
template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
)
class Meta(BaseTable.Meta):

View File

@@ -0,0 +1,91 @@
import urllib.parse
from django.test import Client, TestCase
from django.urls import reverse
from circuits.models import Circuit, CircuitType, Provider
class ProviderTestCase(TestCase):
def setUp(self):
self.client = Client()
Provider.objects.bulk_create([
Provider(name='Provider 1', slug='provider-1', asn=65001),
Provider(name='Provider 2', slug='provider-2', asn=65002),
Provider(name='Provider 3', slug='provider-3', asn=65003),
])
def test_provider_list(self):
url = reverse('circuits:provider_list')
params = {
"q": "test",
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_provider(self):
provider = Provider.objects.first()
response = self.client.get(provider.get_absolute_url())
self.assertEqual(response.status_code, 200)
class CircuitTypeTestCase(TestCase):
def setUp(self):
self.client = Client()
CircuitType.objects.bulk_create([
CircuitType(name='Circuit Type 1', slug='circuit-type-1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
])
def test_circuittype_list(self):
url = reverse('circuits:circuittype_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class CircuitTestCase(TestCase):
def setUp(self):
self.client = Client()
provider = Provider(name='Provider 1', slug='provider-1', asn=65001)
provider.save()
circuittype = CircuitType(name='Circuit Type 1', slug='circuit-type-1')
circuittype.save()
Circuit.objects.bulk_create([
Circuit(cid='Circuit 1', provider=provider, type=circuittype),
Circuit(cid='Circuit 2', provider=provider, type=circuittype),
Circuit(cid='Circuit 3', provider=provider, type=circuittype),
])
def test_circuit_list(self):
url = reverse('circuits:circuit_list')
params = {
"provider": Provider.objects.first().slug,
"type": CircuitType.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_provider(self):
provider = Provider.objects.first()
response = self.client.get(provider.get_absolute_url())
self.assertEqual(response.status_code, 200)

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
@@ -502,12 +503,16 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
#
class CableSerializer(ValidatedModelSerializer):
termination_a_type = ContentTypeField()
termination_b_type = ContentTypeField()
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)
length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False)
length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False, allow_null=True)
class Meta:
model = Cable

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 F
from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404
from drf_yasg import openapi
@@ -35,7 +35,7 @@ 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']),
@@ -419,7 +419,9 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
class InterfaceViewSet(CableTraceMixin, ModelViewSet):
queryset = Interface.objects.select_related(
queryset = Interface.objects.filter(
device__isnull=False
).select_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable'
).prefetch_related(
'ip_addresses', 'tags'
@@ -496,11 +498,11 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.select_related(
'device', '_connected_interface', '_connected_circuittermination'
'device', '_connected_interface__device'
).filter(
# Avoid duplicate connections by only selecting the lower PK in a connected pair
Q(_connected_interface__isnull=False, pk__lt=F('_connected_interface')) |
Q(_connected_circuittermination__isnull=False)
_connected_interface__isnull=False,
pk__lt=F('_connected_interface')
)
serializer_class = serializers.InterfaceConnectionSerializer
filterset_class = filters.InterfaceConnectionFilter

View File

@@ -43,6 +43,12 @@ RACK_STATUS_CHOICES = [
[RACK_STATUS_DEPRECATED, 'Deprecated'],
]
# Device rack position
DEVICE_POSITION_CHOICES = [
# Rack.u_height is limited to 100
(i, 'Unit {}'.format(i)) for i in range(1, 101)
]
# Parent/child device roles
SUBDEVICE_ROLE_PARENT = True
SUBDEVICE_ROLE_CHILD = False
@@ -77,6 +83,7 @@ IFACE_FF_10GE_XENPAK = 1310
IFACE_FF_10GE_X2 = 1320
IFACE_FF_25GE_SFP28 = 1350
IFACE_FF_40GE_QSFP_PLUS = 1400
IFACE_FF_50GE_QSFP28 = 1420
IFACE_FF_100GE_CFP = 1500
IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520
@@ -158,6 +165,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_10GE_X2, 'X2 (10GE)'],
[IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'],
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_50GE_QSFP28, 'QSFP28 (50GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
@@ -270,11 +278,14 @@ PORT_TYPE_8P8C = 1000
PORT_TYPE_110_PUNCH = 1100
PORT_TYPE_ST = 2000
PORT_TYPE_SC = 2100
PORT_TYPE_SC_APC = 2110
PORT_TYPE_FC = 2200
PORT_TYPE_LC = 2300
PORT_TYPE_LC_APC = 2310
PORT_TYPE_MTRJ = 2400
PORT_TYPE_MPO = 2500
PORT_TYPE_LSH = 2600
PORT_TYPE_LSH_APC = 2610
PORT_TYPE_CHOICES = [
[
'Copper',
@@ -288,10 +299,13 @@ PORT_TYPE_CHOICES = [
[
[PORT_TYPE_FC, 'FC'],
[PORT_TYPE_LC, 'LC'],
[PORT_TYPE_LC_APC, 'LC/APC'],
[PORT_TYPE_LSH, 'LSH'],
[PORT_TYPE_LSH_APC, 'LSH/APC'],
[PORT_TYPE_MPO, 'MPO'],
[PORT_TYPE_MTRJ, 'MTRJ'],
[PORT_TYPE_SC, 'SC'],
[PORT_TYPE_SC_APC, 'SC/APC'],
[PORT_TYPE_ST, 'ST'],
]
]
@@ -304,6 +318,7 @@ DEVICE_STATUS_PLANNED = 2
DEVICE_STATUS_STAGED = 3
DEVICE_STATUS_FAILED = 4
DEVICE_STATUS_INVENTORY = 5
DEVICE_STATUS_DECOMMISSIONING = 6
DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_ACTIVE, 'Active'],
[DEVICE_STATUS_OFFLINE, 'Offline'],
@@ -311,6 +326,7 @@ DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_STAGED, 'Staged'],
[DEVICE_STATUS_FAILED, 'Failed'],
[DEVICE_STATUS_INVENTORY, 'Inventory'],
[DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'],
]
# Site statuses
@@ -331,6 +347,7 @@ STATUS_CLASSES = {
3: 'primary',
4: 'danger',
5: 'default',
6: 'warning',
}
# Console/power/interface connection statuses
@@ -355,11 +372,14 @@ CABLE_TYPE_CAT6A = 1610
CABLE_TYPE_CAT7 = 1700
CABLE_TYPE_DAC_ACTIVE = 1800
CABLE_TYPE_DAC_PASSIVE = 1810
CABLE_TYPE_MMF = 3000
CABLE_TYPE_MMF_OM1 = 3010
CABLE_TYPE_MMF_OM2 = 3020
CABLE_TYPE_MMF_OM3 = 3030
CABLE_TYPE_MMF_OM4 = 3040
CABLE_TYPE_SMF = 3500
CABLE_TYPE_SMF_OS1 = 3510
CABLE_TYPE_SMF_OS2 = 3520
CABLE_TYPE_AOC = 3800
CABLE_TYPE_POWER = 5000
CABLE_TYPE_CHOICES = (
@@ -377,11 +397,14 @@ CABLE_TYPE_CHOICES = (
),
(
'Fiber', (
(CABLE_TYPE_MMF, 'Multimode Fiber'),
(CABLE_TYPE_MMF_OM1, 'Multimode Fiber (OM1)'),
(CABLE_TYPE_MMF_OM2, 'Multimode Fiber (OM2)'),
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
(CABLE_TYPE_SMF, 'Singlemode Fiber'),
(CABLE_TYPE_SMF_OS1, 'Singlemode Fiber (OS1)'),
(CABLE_TYPE_SMF_OS2, 'Singlemode Fiber (OS2)'),
(CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
),
),

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,5 +1,6 @@
import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q
from netaddr import EUI
@@ -8,7 +9,9 @@ from netaddr.core import AddrFormatError
from extras.filters import CustomFieldFilterSet
from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES
from utilities.filters import NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter
from utilities.filters import (
NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
)
from virtualization.models import Cluster
from .constants import *
from .models import (
@@ -49,14 +52,15 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=SITE_STATUS_CHOICES,
null_value=None
)
region_id = django_filters.NumberFilter(
method='filter_region',
field_name='pk',
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='region__in',
label='Region (ID)',
)
region = django_filters.CharFilter(
method='filter_region',
field_name='slug',
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='region__in',
to_field_name='slug',
label='Region (slug)',
)
tenant_id = django_filters.ModelMultipleChoiceFilter(
@@ -95,16 +99,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def filter_region(self, queryset, name, value):
try:
region = Region.objects.get(**{name: value})
except ObjectDoesNotExist:
return queryset.none()
return queryset.filter(
Q(region=region) |
Q(region__in=region.get_descendants())
)
class RackGroupFilter(NameSlugSearchFilterSet):
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -513,14 +507,15 @@ class DeviceFilter(CustomFieldFilterSet):
)
name = NullableCharFieldFilter()
asset_tag = NullableCharFieldFilter()
region_id = django_filters.NumberFilter(
method='filter_region',
field_name='pk',
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = django_filters.CharFilter(
method='filter_region',
field_name='slug',
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
@@ -543,6 +538,10 @@ class DeviceFilter(CustomFieldFilterSet):
queryset=Rack.objects.all(),
label='Rack (ID)',
)
position = django_filters.ChoiceFilter(
choices=DEVICE_POSITION_CHOICES,
null_label='Non-racked'
)
cluster_id = django_filters.ModelMultipleChoiceFilter(
queryset=Cluster.objects.all(),
label='VM cluster (ID)',
@@ -602,7 +601,7 @@ class DeviceFilter(CustomFieldFilterSet):
class Meta:
model = Device
fields = ['serial', 'position']
fields = ['serial', 'face']
def search(self, queryset, name, value):
if not value.strip():
@@ -615,16 +614,6 @@ class DeviceFilter(CustomFieldFilterSet):
Q(comments__icontains=value)
).distinct()
def filter_region(self, queryset, name, value):
try:
region = Region.objects.get(**{name: value})
except ObjectDoesNotExist:
return queryset.none()
return queryset.filter(
Q(site__region=region) |
Q(site__region__in=region.get_descendants())
)
def _mac_address(self, queryset, name, value):
value = value.strip()
if not value:
@@ -980,6 +969,14 @@ class CableFilter(django_filters.FilterSet):
color = django_filters.MultipleChoiceFilter(
choices=COLOR_CHOICES
)
device = django_filters.CharFilter(
method='filter_connected_device',
field_name='name'
)
device_id = django_filters.CharFilter(
method='filter_connected_device',
field_name='pk'
)
class Meta:
model = Cable
@@ -990,6 +987,16 @@ class CableFilter(django_filters.FilterSet):
return queryset
return queryset.filter(label__icontains=value)
def filter_connected_device(self, queryset, name, value):
if not value.strip():
return queryset
try:
device = Device.objects.get(**{name: value})
except ObjectDoesNotExist:
return queryset.none()
cable_pks = device.get_cables(pk_list=True)
return queryset.filter(pk__in=cable_pks)
class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter(

View File

@@ -1700,7 +1700,6 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm):
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/",
value_field="slug",
null_option=True,
)
)
tenant = FilterChoiceField(
@@ -2362,7 +2361,7 @@ class FrontPortCreateForm(ComponentForm):
class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(),
queryset=FrontPort.objects.all(),
widget=forms.MultipleHiddenInput()
)
type = forms.ChoiceField(
@@ -2436,7 +2435,7 @@ class RearPortCreateForm(ComponentForm):
class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Interface.objects.all(),
queryset=RearPort.objects.all(),
widget=forms.MultipleHiddenInput()
)
type = forms.ChoiceField(
@@ -2707,12 +2706,12 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
status = forms.ChoiceField(
choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
required=False,
widget=StaticSelect2(),
initial=''
)
label = forms.CharField(
max_length=100,
required=False,
widget=StaticSelect2()
required=False
)
color = forms.CharField(
max_length=6,
@@ -2753,15 +2752,24 @@ class CableFilterForm(BootstrapMixin, forms.Form):
label='Search'
)
type = forms.MultipleChoiceField(
choices=CABLE_TYPE_CHOICES,
choices=add_blank_choice(CABLE_TYPE_CHOICES),
required=False,
widget=StaticSelect2()
)
status = forms.ChoiceField(
required=False,
choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
widget=StaticSelect2()
)
color = forms.CharField(
max_length=6,
required=False,
widget=ColorSelect()
)
device = forms.CharField(
required=False,
label='Device name'
)
#

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')
class InterfaceQuerySet(QuerySet):
def connectable(self):
@@ -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

@@ -0,0 +1,38 @@
# Generated by Django 2.1.5 on 2019-02-14 14:26
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0068_rack_new_fields'),
]
operations = [
migrations.AlterField(
model_name='device',
name='asset_tag',
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
),
migrations.AlterField(
model_name='device',
name='name',
field=models.CharField(blank=True, max_length=64, null=True, unique=True),
),
migrations.AlterField(
model_name='inventoryitem',
name='asset_tag',
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
),
migrations.AlterField(
model_name='rack',
name='asset_tag',
field=models.CharField(blank=True, max_length=50, null=True, unique=True),
),
migrations.AlterField(
model_name='rack',
name='facility_id',
field=models.CharField(blank=True, max_length=50, null=True),
),
]

View File

@@ -16,14 +16,14 @@ from taggit.managers import TaggableManager
from timezone_field import TimeZoneField
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange
from utilities.fields import ColorField, NullableCharField
from utilities.fields import ColorField
from utilities.managers import NaturalOrderingManager
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters
from .constants import *
from .exceptions import LoopDetected
from .fields import ASNField, MACAddressField
from .managers import DeviceComponentManager, InterfaceManager
from .managers import InterfaceManager
class ComponentTemplateModel(models.Model):
@@ -217,8 +217,7 @@ class Region(MPTTModel, ChangeLoggedModel):
self.parent.name if self.parent else None,
)
@property
def site_count(self):
def get_site_count(self):
return Site.objects.filter(
Q(region=self) |
Q(region__in=self.get_descendants())
@@ -470,7 +469,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
name = models.CharField(
max_length=50
)
facility_id = NullableCharField(
facility_id = models.CharField(
max_length=50,
blank=True,
null=True,
@@ -511,7 +510,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
blank=True,
verbose_name='Serial number'
)
asset_tag = NullableCharField(
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
@@ -1005,7 +1004,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
max_length=50
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1028,7 +1027,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
max_length=50
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1051,7 +1050,7 @@ class PowerPortTemplate(ComponentTemplateModel):
max_length=50
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1074,7 +1073,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
max_length=50
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1140,7 +1139,7 @@ class FrontPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1189,7 +1188,7 @@ class RearPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1212,7 +1211,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
max_length=50
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
class Meta:
ordering = ['device_type', 'name']
@@ -1354,7 +1353,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
blank=True,
null=True
)
name = NullableCharField(
name = models.CharField(
max_length=64,
blank=True,
null=True,
@@ -1365,7 +1364,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
blank=True,
verbose_name='Serial number'
)
asset_tag = NullableCharField(
asset_tag = models.CharField(
max_length=50,
blank=True,
null=True,
@@ -1705,6 +1704,21 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
return Interface.objects.filter(filter)
def get_cables(self, pk_list=False):
"""
Return a QuerySet or PK list matching all Cables connected to a component of this Device.
"""
cable_pks = []
for component_model in [
ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
]:
cable_pks += component_model.objects.filter(
device=self, cable__isnull=False
).values_list('cable', flat=True)
if pk_list:
return cable_pks
return Cable.objects.filter(pk__in=cable_pks)
def get_children(self):
"""
Return the set of child Devices installed in DeviceBays within this Device.
@@ -1743,7 +1757,7 @@ class ConsolePort(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
@@ -1786,7 +1800,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
@@ -1835,7 +1849,7 @@ class PowerPort(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
@@ -1878,7 +1892,7 @@ class PowerOutlet(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name']
@@ -2199,7 +2213,7 @@ class FrontPort(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
@@ -2265,7 +2279,7 @@ class RearPort(CableTermination, ComponentModel):
blank=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'positions', 'description']
@@ -2312,7 +2326,7 @@ class DeviceBay(ComponentModel):
null=True
)
objects = DeviceComponentManager()
objects = NaturalOrderingManager()
tags = TaggableManager()
csv_headers = ['device', 'name', 'installed_device']
@@ -2389,7 +2403,7 @@ class InventoryItem(ComponentModel):
verbose_name='Serial number',
blank=True
)
asset_tag = NullableCharField(
asset_tag = models.CharField(
max_length=50,
unique=True,
blank=True,
@@ -2424,7 +2438,7 @@ class InventoryItem(ComponentModel):
def to_csv(self):
return (
self.device.name or '{' + self.device.pk + '}',
self.device.name or '{{{}}}'.format(self.device.pk),
self.name,
self.manufacturer.name if self.manufacturer else None,
self.part_id,
@@ -2558,16 +2572,15 @@ class Cable(ChangeLoggedModel):
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Create an ID string for use by __str__(). We have to save a copy of pk since it's nullified after .delete()
# is called.
self.id_string = '#{}'.format(self.pk)
def __str__(self):
return self.label or self.id_string
if self.label:
return self.label
# Save a copy of the PK on the instance since it's nullified if .delete() is called
if not hasattr(self, 'id_string'):
self.id_string = '#{}'.format(self.pk)
return self.id_string
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
@@ -2652,6 +2665,9 @@ class Cable(ChangeLoggedModel):
self.length_unit,
)
def get_status_class(self):
return 'success' if self.status else 'info'
def get_path_endpoints(self):
"""
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be

View File

@@ -44,7 +44,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 +56,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 +67,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 +88,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 +97,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 +106,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 +131,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 %}
"""
@@ -168,7 +168,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 %}
"""
@@ -196,7 +196,7 @@ class RegionTable(BaseTable):
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=REGION_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
@@ -239,7 +239,7 @@ class RackGroupTable(BaseTable):
slug = tables.Column()
actions = tables.TemplateColumn(
template_code=RACKGROUP_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
@@ -258,7 +258,7 @@ class RackRoleTable(BaseTable):
rack_count = tables.Column(verbose_name='Racks')
color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}},
actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
verbose_name='')
class Meta(BaseTable.Meta):
@@ -309,7 +309,7 @@ class RackReservationTable(BaseTable):
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
unit_list = tables.Column(orderable=False, verbose_name='Units')
actions = tables.TemplateColumn(
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
)
class Meta(BaseTable.Meta):
@@ -327,7 +327,7 @@ class ManufacturerTable(BaseTable):
devicetype_count = tables.Column(verbose_name='Device Types')
platform_count = tables.Column(verbose_name='Platforms')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}},
actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
verbose_name='')
class Meta(BaseTable.Meta):
@@ -463,7 +463,7 @@ class DeviceRoleTable(BaseTable):
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=DEVICEROLE_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
@@ -492,7 +492,7 @@ class PlatformTable(BaseTable):
)
actions = tables.TemplateColumn(
template_code=PLATFORM_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
@@ -647,6 +647,9 @@ class CableTable(BaseTable):
orderable=False,
verbose_name=''
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL
)
length = tables.TemplateColumn(
template_code=CABLE_LENGTH,
order_by='_abs_length'
@@ -730,18 +733,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'
)
@@ -776,7 +779,7 @@ class VirtualChassisTable(BaseTable):
member_count = tables.Column(verbose_name='Members')
actions = tables.TemplateColumn(
template_code=VIRTUALCHASSIS_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)

View File

@@ -0,0 +1,458 @@
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.models import (
Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup,
RackReservation, RackRole, Site, Region, VirtualChassis,
)
class RegionTestCase(TestCase):
def setUp(self):
self.client = Client()
# Create three Regions
for i in range(1, 4):
Region(name='Region {}'.format(i), slug='region-{}'.format(i)).save()
def test_region_list(self):
url = reverse('dcim:region_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class SiteTestCase(TestCase):
def setUp(self):
self.client = Client()
region = Region(name='Region 1', slug='region-1')
region.save()
Site.objects.bulk_create([
Site(name='Site 1', slug='site-1', region=region),
Site(name='Site 2', slug='site-2', region=region),
Site(name='Site 3', slug='site-3', region=region),
])
def test_site_list(self):
url = reverse('dcim:site_list')
params = {
"region": Region.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_site(self):
site = Site.objects.first()
response = self.client.get(site.get_absolute_url())
self.assertEqual(response.status_code, 200)
class RackGroupTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
RackGroup.objects.bulk_create([
RackGroup(name='Rack Group 1', slug='rack-group-1', site=site),
RackGroup(name='Rack Group 2', slug='rack-group-2', site=site),
RackGroup(name='Rack Group 3', slug='rack-group-3', site=site),
])
def test_rackgroup_list(self):
url = reverse('dcim:rackgroup_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class RackTypeTestCase(TestCase):
def setUp(self):
self.client = Client()
RackRole.objects.bulk_create([
RackRole(name='Rack Role 1', slug='rack-role-1'),
RackRole(name='Rack Role 2', slug='rack-role-2'),
RackRole(name='Rack Role 3', slug='rack-role-3'),
])
def test_rackrole_list(self):
url = reverse('dcim:rackrole_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class RackReservationTestCase(TestCase):
def setUp(self):
self.client = Client()
User = get_user_model()
user = User(username='testuser', email='testuser@example.com')
user.save()
site = Site(name='Site 1', slug='site-1')
site.save()
rack = Rack(name='Rack 1', site=site)
rack.save()
RackReservation.objects.bulk_create([
RackReservation(rack=rack, user=user, units=[1, 2, 3], description='Reservation 1'),
RackReservation(rack=rack, user=user, units=[4, 5, 6], description='Reservation 2'),
RackReservation(rack=rack, user=user, units=[7, 8, 9], description='Reservation 3'),
])
def test_rackreservation_list(self):
url = reverse('dcim:rackreservation_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class RackTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
Rack.objects.bulk_create([
Rack(name='Rack 1', site=site),
Rack(name='Rack 2', site=site),
Rack(name='Rack 3', site=site),
])
def test_rack_list(self):
url = reverse('dcim:rack_list')
params = {
"site": Site.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_rack(self):
rack = Rack.objects.first()
response = self.client.get(rack.get_absolute_url())
self.assertEqual(response.status_code, 200)
class ManufacturerTypeTestCase(TestCase):
def setUp(self):
self.client = Client()
Manufacturer.objects.bulk_create([
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
])
def test_manufacturer_list(self):
url = reverse('dcim:manufacturer_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class DeviceTypeTestCase(TestCase):
def setUp(self):
self.client = Client()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
DeviceType.objects.bulk_create([
DeviceType(model='Device Type 1', slug='device-type-1', manufacturer=manufacturer),
DeviceType(model='Device Type 2', slug='device-type-2', manufacturer=manufacturer),
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturer),
])
def test_devicetype_list(self):
url = reverse('dcim:devicetype_list')
params = {
"manufacturer": Manufacturer.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_devicetype(self):
devicetype = DeviceType.objects.first()
response = self.client.get(devicetype.get_absolute_url())
self.assertEqual(response.status_code, 200)
class DeviceRoleTestCase(TestCase):
def setUp(self):
self.client = Client()
DeviceRole.objects.bulk_create([
DeviceRole(name='Device Role 1', slug='device-role-1'),
DeviceRole(name='Device Role 2', slug='device-role-2'),
DeviceRole(name='Device Role 3', slug='device-role-3'),
])
def test_devicerole_list(self):
url = reverse('dcim:devicerole_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class PlatformTestCase(TestCase):
def setUp(self):
self.client = Client()
Platform.objects.bulk_create([
Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'),
])
def test_platform_list(self):
url = reverse('dcim:platform_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class DeviceTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
Device.objects.bulk_create([
Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole),
Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole),
Device(name='Device 3', site=site, device_type=devicetype, device_role=devicerole),
])
def test_device_list(self):
url = reverse('dcim:device_list')
params = {
"device_type_id": DeviceType.objects.first().pk,
"role": DeviceRole.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_device(self):
device = Device.objects.first()
response = self.client.get(device.get_absolute_url())
self.assertEqual(response.status_code, 200)
class InventoryItemTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device.save()
InventoryItem.objects.bulk_create([
InventoryItem(device=device, name='Inventory Item 1'),
InventoryItem(device=device, name='Inventory Item 2'),
InventoryItem(device=device, name='Inventory Item 3'),
])
def test_inventoryitem_list(self):
url = reverse('dcim:inventoryitem_list')
params = {
"device_id": Device.objects.first().pk,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
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):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(model='Device Type 1', manufacturer=manufacturer)
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device1 = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device1.save()
device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole)
device2.save()
iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
iface1.save()
iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
iface2.save()
iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
iface3.save()
iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED)
iface4.save()
iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED)
iface5.save()
iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED)
iface6.save()
Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save()
Cable(termination_a=iface2, termination_b=iface5, type=CABLE_TYPE_CAT6).save()
Cable(termination_a=iface3, termination_b=iface6, type=CABLE_TYPE_CAT6).save()
def test_cable_list(self):
url = reverse('dcim:cable_list')
params = {
"type": CABLE_TYPE_CAT6,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_cable(self):
cable = Cable.objects.first()
response = self.client.get(cable.get_absolute_url())
self.assertEqual(response.status_code, 200)
class VirtualMachineTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1')
device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
)
device_role = DeviceRole.objects.create(
name='Device Role', slug='device-role-1'
)
# Create 9 member Devices
device1 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 1', site=site
)
device2 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 2', site=site
)
device3 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 3', site=site
)
device4 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 4', site=site
)
device5 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 5', site=site
)
device6 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 6', site=site
)
# Create three VirtualChassis with two members each
vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1')
Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2)
vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2')
Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
def test_virtualchassis_list(self):
url = reverse('dcim:virtualchassis_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
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,5 +1,6 @@
import re
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.core.paginator import EmptyPage, PageNotAnInteger
@@ -135,7 +136,13 @@ class BulkDisconnectView(GetReturnURLMixin, View):
#
class RegionListView(ObjectListView):
queryset = Region.objects.all()
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
'region',
'site_count',
cumulative=True
)
filter = filters.RegionFilter
filter_form = forms.RegionFilterForm
table = tables.RegionTable
@@ -347,8 +354,9 @@ class RackElevationListView(View):
total_count = racks.count()
# Pagination
paginator = EnhancedPaginator(racks, 25)
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
page_number = request.GET.get('page', 1)
paginator = EnhancedPaginator(racks, per_page)
try:
page = paginator.page(page_number)
except PageNotAnInteger:

View File

@@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers
from taggit.models import Tag
@@ -15,7 +16,8 @@ from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantG
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 +55,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',
]
#
@@ -88,7 +97,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:
@@ -205,14 +216,25 @@ 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',
]
def get_changed_object(self, obj):
@@ -221,9 +243,14 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
"""
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

@@ -10,7 +10,7 @@ from taggit.models import Tag
from extras import filters
from extras.models import (
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
)
from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@@ -23,8 +23,9 @@ from . import serializers
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
fields = (
(CustomField, ['type']),
(ExportTemplate, ['template_language']),
(Graph, ['type']),
(ObjectChange, ['action']),
)
@@ -115,7 +116,9 @@ class TopologyMapViewSet(ModelViewSet):
#
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items'))
queryset = Tag.objects.annotate(
tagged_items=Count('taggit_taggeditem_items', distinct=True)
)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilter

View File

@@ -22,6 +22,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

@@ -56,6 +56,14 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization
]
# 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

View File

@@ -82,7 +82,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):

View File

@@ -4,15 +4,14 @@ from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField
from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
FilterChoiceField, LaxURLField, JSONField, SlugField,
)
from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
@@ -221,10 +220,6 @@ class TagFilterForm(BootstrapMixin, forms.Form):
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
regions = TreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
required=False
)
data = JSONField()
class Meta:
@@ -233,6 +228,26 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'tenant_groups',
'tenants', 'data',
]
widgets = {
'regions': APISelectMultiple(
api_url="/api/dcim/regions/"
),
'sites': APISelectMultiple(
api_url="/api/dcim/sites/"
),
'roles': APISelectMultiple(
api_url="/api/dcim/device-roles/"
),
'platforms': APISelectMultiple(
api_url="/api/dcim/platforms/"
),
'tenant_groups': APISelectMultiple(
api_url="/api/tenancy/tenant-groups/"
),
'tenants': APISelectMultiple(
api_url="/api/tenancy/tenants/"
)
}
class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm):
@@ -264,29 +279,53 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
region = FilterTreeNodeMultipleChoiceField(
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
role = FilterChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/device-roles/",
value_field="slug",
)
)
platform = FilterChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/platforms/",
value_field="slug",
)
)
tenant_group = FilterChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/tenancy/tenant-groups/",
value_field="slug",
)
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field="slug",
)
)

View File

@@ -29,11 +29,15 @@ def cache_changed_object(instance, **kwargs):
def _record_object_deleted(request, instance, **kwargs):
# Record that the object was deleted.
# Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen
# occasionally during tests, but haven't been able to determine why.
assert request.user.is_authenticated
# Record that the object was deleted
if hasattr(instance, 'log_change'):
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE)
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
class ObjectChangeMiddleware(object):
@@ -79,7 +83,7 @@ class ObjectChangeMiddleware(object):
obj.log_change(request.user, request.id, action)
# Enqueue webhooks
enqueue_webhooks(obj, action)
enqueue_webhooks(obj, request.user, request.id, action)
# Housekeeping: 1% chance of clearing out expired ObjectChanges
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.1.7 on 2019-03-05 18:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0016_exporttemplate_add_cable'),
]
operations = [
migrations.AlterField(
model_name='exporttemplate',
name='mime_type',
field=models.CharField(blank=True, max_length=50),
),
]

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

@@ -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,6 +11,8 @@ 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 dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import deepmerge, foreground_color
@@ -355,9 +356,13 @@ class ExportTemplate(models.Model):
max_length=200,
blank=True
)
template_language = models.PositiveSmallIntegerField(
choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2
)
template_code = models.TextField()
mime_type = models.CharField(
max_length=15,
max_length=50,
blank=True
)
file_extension = models.CharField(
@@ -374,16 +379,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)
@@ -720,7 +745,7 @@ class ConfigContextModel(models.Model):
data = deepmerge(data, context.data)
# If the object has local config context data defined, merge it last
if self.local_context_data is not None:
if self.local_context_data:
data = deepmerge(data, self.local_context_data)
return data

View File

@@ -68,7 +68,7 @@ class TagTable(BaseTable):
)
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)

View File

@@ -0,0 +1,105 @@
import urllib.parse
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
class TagTestCase(TestCase):
def setUp(self):
self.client = Client()
Tag.objects.bulk_create([
Tag(name='Tag 1', slug='tag-1'),
Tag(name='Tag 2', slug='tag-2'),
Tag(name='Tag 3', slug='tag-3'),
])
def test_tag_list(self):
url = reverse('extras:tag_list')
params = {
"q": "tag",
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
class ConfigContextTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ConfigContexts
for i in range(1, 4):
configcontext = ConfigContext(
name='Config Context {}'.format(i),
data='{{"foo": {}}}'.format(i)
)
configcontext.save()
configcontext.sites.add(site)
def test_configcontext_list(self):
url = reverse('extras:configcontext_list')
params = {
"q": "foo",
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_configcontext(self):
configcontext = ConfigContext.objects.first()
response = self.client.get(configcontext.get_absolute_url())
self.assertEqual(response.status_code, 200)
class ObjectChangeTestCase(TestCase):
def setUp(self):
self.client = Client()
user = User(username='testuser', email='testuser@example.com')
user.save()
site = Site(name='Site 1', slug='site-1')
site.save()
# Create three ObjectChanges
for i in range(1, 4):
site.log_change(
user=user,
request_id=uuid.uuid4(),
action=2
)
def test_objectchange_list(self):
url = reverse('extras:objectchange_list')
params = {
"user": User.objects.first(),
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_objectchange(self):
objectchange = ObjectChange.objects.first()
response = self.client.get(objectchange.get_absolute_url())
self.assertEqual(response.status_code, 200)

View File

@@ -30,7 +30,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
items=Count('taggit_taggeditem_items', distinct=True)
).order_by(
'name'
)

View File

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

@@ -128,6 +128,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 +190,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)

View File

@@ -349,11 +349,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class PrefixCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(
vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text='Route distinguisher of parent VRF',
required=False,
help_text='Route distinguisher of parent VRF (or {ID})',
error_messages={
'invalid_choice': 'VRF not found.',
}
@@ -524,14 +524,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='Mask length',
widget=StaticSelect2()
)
vrf = FilterChoiceField(
vrf_id = FilterChoiceField(
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF',
null_label='-- Global --',
widget=APISelectMultiple(
api_url="/api/ipam/vrfs/",
value_field="rd",
null_option=True,
)
)
@@ -764,11 +762,11 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm):
class IPAddressCSVForm(forms.ModelForm):
vrf = forms.ModelChoiceField(
vrf = FlexibleModelChoiceField(
queryset=VRF.objects.all(),
required=False,
to_field_name='rd',
help_text='Route distinguisher of the assigned VRF',
required=False,
help_text='Route distinguisher of parent VRF (or {ID})',
error_messages={
'invalid_choice': 'VRF not found.',
}
@@ -973,14 +971,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='Mask length',
widget=StaticSelect2()
)
vrf = FilterChoiceField(
vrf_id = FilterChoiceField(
queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF',
null_label='-- Global --',
widget=APISelectMultiple(
api_url="/api/ipam/vrfs/",
value_field="rd",
null_option=True,
)
)

View File

@@ -1,7 +1,7 @@
import netaddr
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError
from django.core.exceptions import ValidationError, ObjectDoesNotExist
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Q
@@ -10,8 +10,9 @@ from django.urls import reverse
from taggit.managers import TaggableManager
from dcim.models import Interface
from extras.models import CustomFieldModel
from extras.models import CustomFieldModel, ObjectChange
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object
from .constants import *
from .fields import IPNetworkField, IPAddressField
from .querysets import PrefixQuerySet
@@ -629,6 +630,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.family = self.address.version
super().save(*args, **kwargs)
def log_change(self, user, request_id, action):
"""
Include the connected Interface (if any).
"""
# It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve
# the interface will raise DoesNotExist.
try:
parent_obj = self.interface
except ObjectDoesNotExist:
parent_obj = None
ObjectChange(
user=user,
request_id=request_id,
changed_object=self,
related_object=parent_obj,
action=action,
object_data=serialize_object(self)
).save()
def to_csv(self):
# Determine if this IP is primary for a Device

View File

@@ -30,7 +30,7 @@ RIR_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'ipam:rir_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -52,7 +52,7 @@ ROLE_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'ipam:role_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """
{% endif %}
{% endwith %}
{% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
@@ -203,7 +203,7 @@ class RIRTable(BaseTable):
name = tables.LinkColumn(verbose_name='Name')
is_private = BooleanColumn(verbose_name='Private')
aggregate_count = tables.Column(verbose_name='Aggregates')
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='')
class Meta(BaseTable.Meta):
model = RIR
@@ -288,7 +288,7 @@ class RoleTable(BaseTable):
orderable=False,
verbose_name='VLANs'
)
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='')
actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='')
class Meta(BaseTable.Meta):
model = Role
@@ -392,7 +392,7 @@ class VLANGroupTable(BaseTable):
site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site')
vlan_count = tables.Column(verbose_name='VLANs')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}},
actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}},
verbose_name='')
class Meta(BaseTable.Meta):
@@ -437,7 +437,7 @@ class VLANMemberTable(BaseTable):
)
actions = tables.TemplateColumn(
template_code=VLAN_MEMBER_ACTIONS,
attrs={'td': {'class': 'text-right'}},
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)

View File

@@ -0,0 +1,282 @@
from netaddr import IPNetwork
import urllib.parse
from django.test import Client, TestCase
from django.urls import reverse
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from ipam.constants import IP_PROTOCOL_TCP
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFTestCase(TestCase):
def setUp(self):
self.client = Client()
VRF.objects.bulk_create([
VRF(name='VRF 1', rd='65000:1'),
VRF(name='VRF 2', rd='65000:2'),
VRF(name='VRF 3', rd='65000:3'),
])
def test_vrf_list(self):
url = reverse('ipam:vrf_list')
params = {
"q": "65000",
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_configcontext(self):
vrf = VRF.objects.first()
response = self.client.get(vrf.get_absolute_url())
self.assertEqual(response.status_code, 200)
class RIRTestCase(TestCase):
def setUp(self):
self.client = Client()
RIR.objects.bulk_create([
RIR(name='RIR 1', slug='rir-1'),
RIR(name='RIR 2', slug='rir-2'),
RIR(name='RIR 3', slug='rir-3'),
])
def test_rir_list(self):
url = reverse('ipam:rir_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_rir(self):
rir = RIR.objects.first()
response = self.client.get(rir.get_absolute_url())
self.assertEqual(response.status_code, 200)
class AggregateTestCase(TestCase):
def setUp(self):
self.client = Client()
rir = RIR(name='RIR 1', slug='rir-1')
rir.save()
Aggregate.objects.bulk_create([
Aggregate(family=4, prefix=IPNetwork('10.1.0.0/16'), rir=rir),
Aggregate(family=4, prefix=IPNetwork('10.2.0.0/16'), rir=rir),
Aggregate(family=4, prefix=IPNetwork('10.3.0.0/16'), rir=rir),
])
def test_aggregate_list(self):
url = reverse('ipam:aggregate_list')
params = {
"rir": RIR.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_aggregate(self):
aggregate = Aggregate.objects.first()
response = self.client.get(aggregate.get_absolute_url())
self.assertEqual(response.status_code, 200)
class RoleTestCase(TestCase):
def setUp(self):
self.client = Client()
Role.objects.bulk_create([
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'),
])
def test_role_list(self):
url = reverse('ipam:role_list')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
class PrefixTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
Prefix.objects.bulk_create([
Prefix(family=4, prefix=IPNetwork('10.1.0.0/16'), site=site),
Prefix(family=4, prefix=IPNetwork('10.2.0.0/16'), site=site),
Prefix(family=4, prefix=IPNetwork('10.3.0.0/16'), site=site),
])
def test_prefix_list(self):
url = reverse('ipam:prefix_list')
params = {
"site": Site.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_prefix(self):
prefix = Prefix.objects.first()
response = self.client.get(prefix.get_absolute_url())
self.assertEqual(response.status_code, 200)
class IPAddressTestCase(TestCase):
def setUp(self):
self.client = Client()
vrf = VRF(name='VRF 1', rd='65000:1')
vrf.save()
IPAddress.objects.bulk_create([
IPAddress(family=4, address=IPNetwork('10.1.0.0/16'), vrf=vrf),
IPAddress(family=4, address=IPNetwork('10.2.0.0/16'), vrf=vrf),
IPAddress(family=4, address=IPNetwork('10.3.0.0/16'), vrf=vrf),
])
def test_ipaddress_list(self):
url = reverse('ipam:ipaddress_list')
params = {
"vrf": VRF.objects.first().rd,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_ipaddress(self):
ipaddress = IPAddress.objects.first()
response = self.client.get(ipaddress.get_absolute_url())
self.assertEqual(response.status_code, 200)
class VLANGroupTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
VLANGroup.objects.bulk_create([
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site),
])
def test_vlangroup_list(self):
url = reverse('ipam:vlangroup_list')
params = {
"site": Site.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
class VLANTestCase(TestCase):
def setUp(self):
self.client = Client()
vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1')
vlangroup.save()
VLAN.objects.bulk_create([
VLAN(group=vlangroup, vid=101, name='VLAN101'),
VLAN(group=vlangroup, vid=102, name='VLAN102'),
VLAN(group=vlangroup, vid=103, name='VLAN103'),
])
def test_vlan_list(self):
url = reverse('ipam:vlan_list')
params = {
"group": VLANGroup.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_vlan(self):
vlan = VLAN.objects.first()
response = self.client.get(vlan.get_absolute_url())
self.assertEqual(response.status_code, 200)
class ServiceTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device.save()
Service.objects.bulk_create([
Service(device=device, name='Service 1', protocol=IP_PROTOCOL_TCP, port=101),
Service(device=device, name='Service 2', protocol=IP_PROTOCOL_TCP, port=102),
Service(device=device, name='Service 3', protocol=IP_PROTOCOL_TCP, port=103),
])
def test_service_list(self):
url = reverse('ipam:service_list')
params = {
"device_id": Device.objects.first(),
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
self.assertEqual(response.status_code, 200)
def test_service(self):
service = Service.objects.first()
response = self.client.get(service.get_absolute_url())
self.assertEqual(response.status_code, 200)

View File

@@ -147,18 +147,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
# Miscellaneous
#
def get_view_name(view_cls, suffix=None):
def get_view_name(view, suffix=None):
"""
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
"""
if hasattr(view_cls, 'queryset'):
if hasattr(view, 'queryset'):
# Determine the model name from the queryset.
name = view_cls.queryset.model._meta.verbose_name
name = view.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
else:
# Replicate DRF's built-in behavior.
name = view_cls.__name__
name = view.__class__.__name__
name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name)

View File

@@ -132,6 +132,7 @@ REDIS = {
'PASSWORD': '',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
# The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of

View File

@@ -22,7 +22,7 @@ except ImportError:
)
VERSION = '2.5.6'
VERSION = '2.5.11'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -131,6 +131,7 @@ REDIS_PORT = REDIS.get('PORT', 6379)
REDIS_PASSWORD = REDIS.get('PASSWORD', '')
REDIS_DATABASE = REDIS.get('DATABASE', 0)
REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300)
REDIS_SSL = REDIS.get('SSL', False)
# Email
EMAIL_HOST = EMAIL.get('SERVER')
@@ -197,7 +198,7 @@ ROOT_URLCONF = 'netbox.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR + '/templates/'],
'DIRS': [BASE_DIR + '/templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -223,7 +224,7 @@ USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_ROOT = BASE_DIR + '/static/'
STATIC_ROOT = BASE_DIR + '/static'
STATIC_URL = '/{}static/'.format(BASE_PATH)
STATICFILES_DIRS = (
os.path.join(BASE_DIR, "project-static"),
@@ -291,6 +292,7 @@ RQ_QUEUES = {
'DB': REDIS_DATABASE,
'PASSWORD': REDIS_PASSWORD,
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
'SSL': REDIS_SSL,
}
}
@@ -315,6 +317,7 @@ SWAGGER_SETTINGS = {
'utilities.custom_inspectors.IdInFilterInspector',
'drf_yasg.inspectors.CoreAPICompatInspector',
],
'DEFAULT_MODEL_DEPTH': 1,
'DEFAULT_PAGINATOR_INSPECTORS': [
'utilities.custom_inspectors.NullablePaginatorInspector',
'drf_yasg.inspectors.DjangoRestResponsePagination',

View File

@@ -267,6 +267,7 @@ class SearchView(View):
class APIRootView(APIView):
_ignore_model_permissions = True
exclude_from_schema = True
swagger_schema = None
def get_view_name(self):
return "API Root"

File diff suppressed because one or more lines are too long

View File

@@ -49,6 +49,19 @@ footer p {
}
}
/* Printer friendly CSS class and various fixes for printing. */
@media print {
body {
padding-top: 0px;
}
a[href]:after {
content: none !important;
}
.noprint {
display: none !important;
}
}
/* Collapse the nav menu on displays less than 960px wide */
@media (max-width: 959px) {
.navbar-header {
@@ -575,4 +588,4 @@ td .progress {
}
textarea {
font-family: Consolas, Lucida Console, monospace;
}
}

View File

@@ -90,6 +90,10 @@ $(document).ready(function() {
// Assign color picker selection classes
function colorPickerClassCopy(data, container) {
if (data.element) {
// Remove any existing color-selection classes
$(container).attr('class', function(i, c) {
return c.replace(/(^|\s)color-selection-\S+/g, '');
});
$(container).addClass($(data.element).attr("class"));
}
return data.text;
@@ -151,10 +155,14 @@ $(document).ready(function() {
filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name);
var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val();
if (param_name && value) {
if (param_name && is_visible && value) {
parameters[param_name] = value;
} else if (param_name && is_visible && is_nullable) {
parameters[param_name] = "null";
}
});
@@ -243,7 +251,7 @@ $(document).ready(function() {
ajax: {
delay: 250,
url: "/api/extras/tags/",
url: netbox_api_path + "extras/tags/",
data: function(params) {
// Paging. Note that `params.page` indexes at 1

View File

@@ -1,4 +1,6 @@
$(document).ready(function() {
// Instantiate ClipboardJS on all copy buttons
new ClipboardJS('button.copy-secret');
// Unlocking a secret
$('button.unlock-secret').click(function(event) {
@@ -45,6 +47,7 @@ $(document).ready(function() {
console.log("Secret retrieved successfully");
$('#secret_' + secret_id).text(response.plaintext);
$('button.unlock-secret[secret-id=' + secret_id + ']').hide();
$('button.copy-secret[secret-id=' + secret_id + ']').show();
$('button.lock-secret[secret-id=' + secret_id + ']').show();
} else {
console.log("Secret was not decrypted. Prompt user for private key.");
@@ -67,6 +70,7 @@ $(document).ready(function() {
var secret_div = $('#secret_' + secret_id);
secret_div.html('********');
$('button.lock-secret[secret-id=' + secret_id + ']').hide();
$('button.copy-secret[secret-id=' + secret_id + ']').hide();
$('button.unlock-secret[secret-id=' + secret_id + ']').show();
}

View File

@@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """
<i class="fa fa-history"></i>
</a>
{% if perms.secrets.change_secretrole %}
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
<a href="{% url 'secrets:secretrole_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 %}
"""
@@ -23,7 +23,7 @@ class SecretRoleTable(BaseTable):
secret_count = tables.Column(verbose_name='Secrets')
slug = tables.Column(verbose_name='Slug')
actions = tables.TemplateColumn(
template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name=''
template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
)
class Meta(BaseTable.Meta):

View File

@@ -0,0 +1,82 @@
import urllib.parse
from django.contrib.auth import get_user_model
from django.test import Client, TestCase
from django.urls import reverse
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.models import Secret, SecretRole
class SecretRoleTestCase(TestCase):
def setUp(self):
TEST_USERNAME = 'testuser'
TEST_PASSWORD = 'testpassword'
User = get_user_model()
User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD)
self.client = Client()
self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD)
SecretRole.objects.bulk_create([
SecretRole(name='Secret Role 1', slug='secret-role-1'),
SecretRole(name='Secret Role 2', slug='secret-role-2'),
SecretRole(name='Secret Role 3', slug='secret-role-3'),
])
def test_secretrole_list(self):
url = reverse('secrets:secret_list')
response = self.client.get(url, follow=True)
self.assertEqual(response.status_code, 200)
class SecretTestCase(TestCase):
def setUp(self):
self.client = Client()
site = Site(name='Site 1', slug='site-1')
site.save()
manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1')
manufacturer.save()
devicetype = DeviceType(manufacturer=manufacturer, model='Device Type 1')
devicetype.save()
devicerole = DeviceRole(name='Device Role 1', slug='device-role-1')
devicerole.save()
device = Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole)
device.save()
secretrole = SecretRole(name='Secret Role 1', slug='secret-role-1')
secretrole.save()
Secret.objects.bulk_create([
Secret(device=device, role=secretrole, name='Secret 1', ciphertext=b'1234567890'),
Secret(device=device, role=secretrole, name='Secret 2', ciphertext=b'1234567890'),
Secret(device=device, role=secretrole, name='Secret 3', ciphertext=b'1234567890'),
])
def test_secret_list(self):
url = reverse('secrets:secret_list')
params = {
"role": SecretRole.objects.first().slug,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True)
self.assertEqual(response.status_code, 200)
def test_configcontext(self):
secret = Secret.objects.first()
response = self.client.get(secret.get_absolute_url(), follow=True)
self.assertEqual(response.status_code, 200)

View File

@@ -120,6 +120,8 @@ def secret_add(request, pk):
secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key)
secret.save()
form.save_m2m()
messages.success(request, "Added new secret: {}.".format(secret))
if '_addanother' in request.POST:
return redirect('dcim:device_addsecret', pk=device.pk)

View File

@@ -54,7 +54,7 @@
<div class="col-xs-4 text-center">
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
</div>
<div class="col-xs-4 text-right">
<div class="col-xs-4 text-right noprint">
<p class="text-muted">
<i class="fa fa-fw fa-book text-primary"></i> <a href="http://netbox.readthedocs.io/">Docs</a> &middot;
<i class="fa fa-fw fa-cloud text-primary"></i> <a href="{% url 'api_docs' %}">API</a> &middot;
@@ -69,6 +69,7 @@
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.3.7-dist/js/bootstrap.min.js' %}"></script>
<script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
<script src="{% static 'clipboard-2.0.4.min.js' %}"></script>
<script src="{% static 'js/forms.js' %}?v{{ settings.VERSION }}"></script>
<script type="text/javascript">
var netbox_api_path = "/{{ settings.BASE_PATH }}api/";

View File

@@ -4,7 +4,7 @@
{% block title %}{{ circuit }}{% endblock %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:circuit_list' %}">Circuits</a></li>
@@ -25,7 +25,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=circuit.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.circuits.add_circuit %}
{% add_button 'circuits:circuit_add' %}
{% import_button 'circuits:circuit_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.circuits.add_circuittype %}
{% add_button 'circuits:circuittype_add' %}
{% import_button 'circuits:circuittype_import' %}

View File

@@ -5,7 +5,7 @@
{% block title %}{{ provider }}{% endblock %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'circuits:provider_list' %}">Providers</a></li>
@@ -25,7 +25,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ provider.name }}" data-url="{% url 'circuits-api:provider-graphs' pk=provider.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
@@ -85,11 +85,11 @@
</tr>
<tr>
<td>NOC Contact</td>
<td>{{ provider.noc_contact|linebreaksbr|placeholder }}</td>
<td class="rendered-markdown">{{ provider.noc_contact|gfm|placeholder }}</td>
</tr>
<tr>
<td>Admin Contact</td>
<td>{{ provider.admin_contact|linebreaksbr|placeholder }}</td>
<td class="rendered-markdown">{{ provider.admin_contact|gfm|placeholder }}</td>
</tr>
<tr>
<td>Circuits</td>
@@ -172,7 +172,7 @@
{% endfor %}
</table>
{% if perms.circuits.add_circuit %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add circuit
</a>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.circuits.add_provider %}
{% add_button 'circuits:provider_add' %}
{% import_button 'circuits:provider_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='circuits:provider_bulk_edit' bulk_delete_url='circuits:provider_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:cable_list' %}">Cables</a></li>
@@ -10,7 +10,7 @@
</ol>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=cable.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this cable

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_cable %}
{% import_button 'dcim:cable_import' %}
{% endif %}
@@ -13,7 +13,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:cable_bulk_edit' bulk_delete_url='dcim:cable_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Console Connections{% endblock %}</h1>
@@ -11,7 +11,7 @@
{% include 'responsive_table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -5,7 +5,7 @@
{% block title %}{{ device }}{% endblock %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
@@ -33,7 +33,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.change_device %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
@@ -199,7 +199,7 @@
</tr>
{% endfor %}
</table>
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
{% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_add_member' pk=device.virtual_chassis.pk %}?site={{ device.site.pk }}&rack={{ device.rack.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add Member
@@ -317,7 +317,7 @@
{% endfor %}
</table>
{% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
{% if perms.dcim.add_consoleport %}
<a href="{% url 'dcim:consoleport_add' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Add console port
@@ -352,7 +352,7 @@
<form id="secret_form">
{% csrf_token %}
</form>
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add secret
@@ -377,7 +377,7 @@
</div>
{% endif %}
{% if perms.ipam.add_service %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:device_service_assign' device=device.pk %}" class="btn btn-xs btn-primary">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span> Assign service
</a>
@@ -390,7 +390,7 @@
</div>
{% include 'inc/image_attachments.html' with images=device.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:device_add_image' object_id=device.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Attach an image
@@ -398,7 +398,7 @@
</div>
{% endif %}
</div>
<div class="panel panel-default">
<div class="panel panel-default noprint">
<div class="panel-heading">
<strong>Related Devices</strong>
</div>
@@ -459,7 +459,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if device_bays and perms.dcim.change_devicebay %}
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
@@ -493,7 +493,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interfaces</strong>
<div class="pull-right">
<div class="pull-right noprint">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
@@ -521,7 +521,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if interfaces and perms.dcim.change_interface %}
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
@@ -581,7 +581,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if consoleserverports and perms.dcim.change_consoleport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
@@ -636,7 +636,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if poweroutlets and perms.dcim.change_powerport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
@@ -693,7 +693,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if front_ports and perms.dcim.change_frontport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
@@ -750,7 +750,7 @@
{% endfor %}
</tbody>
</table>
<div class="panel-footer">
<div class="panel-footer noprint">
{% if rear_ports and perms.dcim.change_rearport %}
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename

View File

@@ -53,7 +53,7 @@
</tbody>
</table>
{% if perms.dcim.add_inventoryitem %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:inventoryitem_add' device=device.pk %}" class="btn btn-primary btn-xs">
<span class="fa fa-plus" aria-hidden="true"></span> Add Inventory Item
</a>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_device %}
{% add_button 'dcim:device_add' %}
{% import_button 'dcim:device_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'dcim/inc/device_table.html' with bulk_edit_url='dcim:device_bulk_edit' bulk_delete_url='dcim:device_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_devicerole %}
{% add_button 'dcim:devicerole_add' %}
{% import_button 'dcim:devicerole_import' %}

View File

@@ -4,7 +4,7 @@
{% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:devicetype_list' %}">Device Types</a></li>
@@ -14,7 +14,7 @@
</div>
</div>
{% if perms.dcim.change_devicetype or perms.dcim.delete_devicetype %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.change_devicetype %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_devicetype %}
{% add_button 'dcim:devicetype_add' %}
{% import_button 'dcim:devicetype_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -29,7 +29,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if cp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -36,7 +36,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if csp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -23,7 +23,7 @@
<span class="text-muted">Vacant</span>
</td>
{% endif %}
<td class="text-right">
<td class="text-right noprint">
{% if perms.dcim.change_devicebay %}
{% if devicebay.installed_device %}
<a href="{% url 'dcim:devicebay_depopulate' pk=devicebay.pk %}" class="btn btn-danger btn-xs">

View File

@@ -6,7 +6,7 @@
<strong>{{ title }}</strong>
</div>
{% include 'responsive_table.html' %}
<div class="panel-footer">
<div class="panel-footer noprint">
{% if table.rows %}
{% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">

View File

@@ -54,7 +54,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if frontport.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -96,7 +96,7 @@
{{ peer_termination.connected_endpoint.device }}
</a><br/>
<small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{{ iface.connected_endpoint.circuit.get_absolure_url }}">
<a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
{{ iface.connected_endpoint.circuit.provider }}
{{ iface.connected_endpoint.circuit }}
</a>
@@ -134,7 +134,7 @@
{% endif %}
{# Buttons #}
<td class="text-right text-nowrap">
<td class="text-right text-nowrap noprint">
{% if show_graphs %}
{% if iface.connected_endpoint %}
<button type="button" class="btn btn-primary btn-xs" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ device.name }} - {{ iface.name }}" data-url="{% url 'dcim-api:interface-graphs' pk=iface.pk %}" title="Show graphs">
@@ -150,7 +150,7 @@
{% if perms.dcim.change_interface %}
{% if iface.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
{% elif not iface.is_virtual and perms.dcim.add_cable %}
{% elif iface.is_connectable and perms.dcim.add_cable %}
<a href="{% url 'dcim:interface_connect' termination_a_id=iface.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i>
</a>
@@ -231,7 +231,7 @@
</td>
{# Buttons #}
<td class="text-right text-nowrap">
<td class="text-right text-nowrap noprint">
{% if perms.ipam.change_ipaddress %}
<a href="{% url 'ipam:ipaddress_edit' pk=ip.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit IP address"></i>

View File

@@ -6,7 +6,7 @@
<td>{{ item.serial }}</td>
<td>{{ item.asset_tag|default:"" }}</td>
<td>{{ item.description }}</td>
<td class="text-right">
<td class="text-right noprint">
{% if perms.dcim.change_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}" class="btn btn-xs btn-warning"><span class="glyphicon glyphicon-pencil" aria-hidden="true"></span></a>
{% endif %}

View File

@@ -36,7 +36,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if po.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -29,7 +29,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if pp.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -25,7 +25,7 @@
{% if u.device %}
<li class="occupied h{{ u.device.device_type.u_height }}u"{% ifequal u.device.face face_id %} style="background-color: #{{ u.device.device_role.color }}"{% endifequal %}>
{% ifequal u.device.face face_id %}
<a href="{% url 'dcim:device' pk=u.device.pk %}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
<a href="{% url 'dcim:device' pk=u.device.pk %}" style="color: {{ u.device.device_role.color|fgcolor }}" data-toggle="popover" data-trigger="hover" data-container="body" data-html="true"
data-content="{{ u.device.device_role }}<br />{{ u.device.device_type.display_name }} ({{ u.device.device_type.u_height }}U){% if u.device.asset_tag %}<br />{{ u.device.asset_tag }}{% endif %}{% if u.device.serial %}<br />{{ u.device.serial }}{% endif %}">
{{ u.device }}
{% if u.device.devicebay_count %}

View File

@@ -53,7 +53,7 @@
{% endif %}
{# Actions #}
<td class="text-right">
<td class="text-right noprint">
{% if rearport.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %}
{% elif perms.dcim.add_cable %}

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
{% if interface.device %}
@@ -15,7 +15,7 @@
</ol>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.change_interface %}
<a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Interface Connections{% endblock %}</h1>
@@ -11,7 +11,7 @@
{% include 'responsive_table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -14,20 +14,29 @@
{% render_field form.mgmt_only %}
{% render_field form.description %}
{% render_field form.mode %}
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if obj.mode %}
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
<div class="panel panel-default" id="vlans_panel">
<div class="panel-heading"><strong>802.1Q VLANs</strong></div>
{% if obj.mode %}
{% include 'dcim/inc/interface_vlans_table.html' %}
<div class="panel-footer text-right">
<a href="{% url 'dcim:interface_assign_vlans' pk=obj.pk %}?return_url={% url 'dcim:interface_edit' pk=obj.pk %}" class="btn btn-primary btn-xs{% if form.instance.mode == 100 and form.instance.untagged_vlan %} disabled{% endif %}">
<i class="glyphicon glyphicon-plus"></i> Add VLANs
</a>
</div>
</div>
{% endif %}
{% else %}
<div class="panel-body text-center text-muted">
<p>802.1Q mode not set</p>
</div>
{% endif %}
</div>
{% endblock %}
{% block buttons %}

View File

@@ -3,7 +3,7 @@
{% load helpers %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_devicetype %}
{% import_button 'dcim:inventoryitem_import' %}
{% endif %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:inventoryitem_bulk_edit' bulk_delete_url='dcim:inventoryitem_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_manufacturer %}
{% add_button 'dcim:manufacturer_add' %}
{% import_button 'dcim:manufacturer_import' %}

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_platform %}
{% add_button 'dcim:platform_add' %}
{% import_button 'dcim:platform_import' %}

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Power Connections{% endblock %}</h1>
@@ -11,7 +11,7 @@
{% include 'responsive_table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
@@ -23,7 +23,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
<a {% if prev_rack %}href="{% url 'dcim:rack' pk=prev_rack.pk %}"{% else %}disabled="disabled"{% endif %} class="btn btn-primary">
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous Rack
</a>
@@ -223,7 +223,7 @@
<div class="panel-body text-muted">None</div>
{% endif %}
{% if perms.dcim.add_device %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:device_add' %}?site={{ rack.site.pk }}&rack={{ rack.pk }}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a non-racked device
@@ -237,7 +237,7 @@
</div>
{% include 'inc/image_attachments.html' with images=rack.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:rack_add_image' object_id=rack.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Attach an image
@@ -271,7 +271,7 @@
{{ resv.description }}<br />
<small>{{ resv.user }} &middot; {{ resv.created }}</small>
</td>
<td class="text-right">
<td class="text-right noprint">
{% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}" class="btn btn-warning btn-xs" title="Edit reservation">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
@@ -290,7 +290,7 @@
<div class="panel-body text-muted">None</div>
{% endif %}
{% if perms.dcim.add_rackreservation %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:rack_add_reservation' rack=rack.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Add a reservation

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% block content %}
<div class="btn-group pull-right" role="group">
<div class="btn-group pull-right noprint" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=0 %}" class="btn btn-default{% if request.GET.face != '1' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face=1 %}" class="btn btn-default{% if request.GET.face == '1' %} active{% endif %}">Rear</a>
</div>
@@ -38,7 +38,7 @@
<p>No racks found</p>
</div>
{% endif %}
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_rack %}
{% add_button 'dcim:rack_add' %}
{% import_button 'dcim:rack_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rack_bulk_edit' bulk_delete_url='dcim:rack_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_rackgroup %}
{% add_button 'dcim:rackgroup_add' %}
{% import_button 'dcim:rackgroup_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackgroup_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -7,7 +7,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:rackreservation_bulk_edit' bulk_delete_url='dcim:rackreservation_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_rackrole %}
{% add_button 'dcim:rackrole_add' %}
{% import_button 'dcim:rackrole_import' %}

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_region %}
{% add_button 'dcim:region_add' %}
{% import_button 'dcim:region_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:region_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -4,7 +4,7 @@
{% load helpers %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
@@ -30,7 +30,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if show_graphs %}
<button type="button" class="btn btn-primary" data-toggle="modal" data-target="#graphs_modal" data-obj="{{ site.name }}" data-url="{% url 'dcim-api:site-graphs' pk=site.pk %}" title="Show graphs">
<i class="fa fa-signal" aria-hidden="true"></i>
@@ -138,7 +138,7 @@
<td>Physical Address</td>
<td>
{% if site.physical_address %}
<div class="pull-right">
<div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ site.physical_address|oneline|urlencode }}" target="_blank" class="btn btn-primary btn-xs">
<i class="glyphicon glyphicon-map-marker"></i> Map it
</a>
@@ -157,7 +157,7 @@
<td>GPS Coordinates</td>
<td>
{% if site.latitude and site.longitude %}
<div class="pull-right">
<div class="pull-right noprint">
<a href="http://maps.google.com/?q={{ site.latitude }},{{ site.longitude }}" target="_blank" class="btn btn-primary btn-xs">
<i class="glyphicon glyphicon-map-marker"></i> Map it
</a>
@@ -251,7 +251,7 @@
<tr>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
<td>{{ rg.rack_count }}</td>
<td class="text-right">
<td class="text-right noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i>
</a>
@@ -271,7 +271,7 @@
</div>
{% include 'inc/image_attachments.html' with images=site.images.all %}
{% if perms.extras.add_imageattachment %}
<div class="panel-footer text-right">
<div class="panel-footer text-right noprint">
<a href="{% url 'dcim:site_add_image' object_id=site.pk %}" class="btn btn-primary btn-xs">
<span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
Attach an image

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.dcim.add_site %}
{% add_button 'dcim:site_add' %}
{% import_button 'dcim:site_import' %}
@@ -14,7 +14,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -3,7 +3,7 @@
{% load helpers %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Virtual Chassis{% endblock %}</h1>
@@ -11,7 +11,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>

View File

@@ -2,7 +2,7 @@
{% load helpers %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'extras:configcontext_list' %}">Config Contexts</a></li>
@@ -22,7 +22,7 @@
</form>
</div>
</div>
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.extras.change_configcontext %}
<a href="{% url 'extras:configcontext_edit' pk=configcontext.pk %}" class="btn btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% if perms.extras.add_configcontext %}
{% add_button 'extras:configcontext_add' %}
{% endif %}
@@ -12,7 +12,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='extras:configcontext_bulk_edit' bulk_delete_url='extras:configcontext_bulk_delete' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -4,7 +4,7 @@
{% block title %}{{ objectchange }}{% endblock %}
{% block header %}
<div class="row">
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
@@ -97,7 +97,7 @@
</div>
<div class="row">
<div class="col-md-12">
{% include 'panel_table.html' with table=related_changes_table heading='Related Changes' %}
{% include 'panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='noprint' %}
{% if related_changes_count > related_changes_table.rows|length %}
<div class="pull-right">
<a href="{% url 'extras:objectchange_list' %}?request_id={{ objectchange.request_id }}" class="btn btn-primary">See all {{ related_changes_count|add:"1" }} changes</a>

View File

@@ -2,7 +2,7 @@
{% load buttons %}
{% block content %}
<div class="pull-right">
<div class="pull-right noprint">
{% export_button content_type %}
</div>
<h1>{% block title %}Changelog{% endblock %}</h1>
@@ -10,7 +10,7 @@
<div class="col-md-9">
{% include 'utilities/obj_table.html' %}
</div>
<div class="col-md-3">
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
</div>
</div>

View File

@@ -4,7 +4,7 @@
{% block title %}{{ report.name }}{% endblock %}
{% block content %}
<div class="row">
<div class="row noprint">
<div class="col-md-12">
<ol class="breadcrumb">
<li><a href="{% url 'extras:report_list' %}">Reports</a></li>
@@ -14,7 +14,7 @@
</div>
</div>
{% if perms.extras.add_reportresult %}
<div class="pull-right">
<div class="pull-right noprint">
<form action="{% url 'extras:report_run' name=report.full_name %}" method="post">
{% csrf_token %}
{{ run_form }}

View File

@@ -29,6 +29,12 @@
Edit this tag
</a>
{% endif %}
{% if perms.taggit.delete_tag %}
<a href="{% url 'extras:tag_delete' slug=tag.slug %}" class="btn btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span>
Delete this tag
</a>
{% endif %}
</div>
<h1>{% block title %}Tag: {{ tag }}{% endblock %}</h1>
{% endblock %}

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