Compare commits

..

325 Commits

Author SHA1 Message Date
Jeremy Stretch
946779000f Merge pull request #3908 from netbox-community/develop
Release v2.6.12
2020-01-13 13:25:21 -05:00
Jeremy Stretch
c3c3000a53 Release v2.6.12 2020-01-13 13:17:43 -05:00
Jeremy Stretch
66daeda85f Merge pull request #3887 from hSaria/3021-cable-tenant-filter
Fixes #3021: Added tenancy filter for cables
2020-01-13 12:09:53 -05:00
Jeremy Stretch
0fd0e76183 Merge pull request #3888 from hSaria/3491-webhook-error-message
Fixes #3491: Include content of webhook error response
2020-01-13 11:33:25 -05:00
Jeremy Stretch
cf480375f6 Merge pull request #3899 from hSaria/3898-cable-str-pk
Fixes #3898: Call str of cable on delete to save PK in id_string
2020-01-13 11:31:18 -05:00
hSaria
736cd709d9 Merge branch 'develop' into 3898-cable-str-pk 2020-01-13 15:40:22 +00:00
Jeremy Stretch
0f42219b4b Merge pull request #3906 from hSaria/3905-powerfeed-divide-by-zero
Fixes #3905: divide by zero on power feeds with low values
2020-01-13 10:32:59 -05:00
Jeremy Stretch
b64f4b93eb Merge pull request #3903 from hSaria/3902-relax-connect
Fixes #3902: relax non-essential required fields
2020-01-13 10:32:09 -05:00
Saria Hajjar
32ffc1b54b Fixes #3905: divide by zero on power feeds with low values 2020-01-13 15:31:35 +00:00
Saria Hajjar
608006ee77 Set the private pk after super save 2020-01-13 15:21:37 +00:00
Saria Hajjar
3d78a67343 Store a private copy of the pk during init and use that with __str__ 2020-01-13 14:57:21 +00:00
Jeremy Stretch
2f98f133bb Merge pull request #3896 from hSaria/3895-elevations-keyerror
Fixes #3895: Elevations filter regression
2020-01-13 09:44:57 -05:00
Saria Hajjar
fe0fbeab49 Fixes #3902: relax non-essential required fields 2020-01-13 12:05:06 +00:00
Saria Hajjar
49fa243b4f Added post-delete cable ID test 2020-01-12 11:21:02 +00:00
Saria Hajjar
a2308b9c99 Fixes #3898: Call str of cable on delete to save PK in id_string 2020-01-12 11:08:13 +00:00
Saria Hajjar
422c6bad5b Fixes #3895: Elevations filter regression 2020-01-11 15:36:58 +00:00
Jeremy Stretch
b7e78028ce Closes #3891: Add local_context_data filter for virtual machines 2020-01-10 15:34:38 -05:00
Jeremy Stretch
509a115f68 Extend section regarding test adaptation 2020-01-10 12:24:47 -05:00
Saria Hajjar
f20d16f188 Fixes #3491: include content of webhook error response 2020-01-10 16:42:02 +00:00
Saria Hajjar
f4514034b8 Fixes #3021: Added tenancy filter to cables 2020-01-10 15:59:56 +00:00
Jeremy Stretch
6bc8f2e50b Fixes #3882: Fix filtering of devices by rack group 2020-01-10 10:21:11 -05:00
Jeremy Stretch
fc1245c49d Merge pull request #3867 from hSaria/3668-address-assign-dns-filter
Fixes #3668: use `q` to search when assigning IP
2020-01-10 10:08:31 -05:00
Jeremy Stretch
4be7ca0c78 Merge branch 'develop' into 3668-address-assign-dns-filter 2020-01-10 09:43:35 -05:00
Jeremy Stretch
cb91c9231d Merge pull request #3865 from hSaria/3623-interface-word-expansion
Fixes #3623: Word expansion for interfaces
2020-01-10 09:41:42 -05:00
hSaria
a5413a5484 Merge branch 'develop' into 3623-interface-word-expansion 2020-01-10 11:55:27 +00:00
Saria Hajjar
71120d9899 Added tests for alphanumeric 2020-01-10 11:54:43 +00:00
Saria Hajjar
acb66c7dc0 Negative tests for expand_ipaddress_pattern 2020-01-10 11:21:37 +00:00
Saria Hajjar
2eba84dad5 Added tests for IPv6 2020-01-10 11:06:01 +00:00
Saria Hajjar
fe89982d4e Removed redundant list call 2020-01-10 10:26:46 +00:00
Jeremy Stretch
0296aa240a Clean up Stale bot config formatting 2020-01-09 20:14:31 -05:00
Jeremy Stretch
d88b3456c4 Add configuration file for GitHub Stale bot 2020-01-09 20:13:21 -05:00
Jeremy Stretch
789cf827f2 Merge pull request #3879 from hSaria/3876-asn-field-bounds
Fixes #3876: Min/max values for ASN field
2020-01-09 17:03:29 -05:00
Saria Hajjar
6c19c88e99 Replaced ASN bounds with constants 2020-01-09 21:58:38 +00:00
Jeremy Stretch
c1ed2b6068 Merge pull request #3875 from hSaria/3393-provider-circuit-paginate
Fixes #3393: Paginate circuits at the provider details view
2020-01-09 16:52:19 -05:00
Saria Hajjar
4eacc57522 Fixes #3876: set min and max values for ASN field 2020-01-09 21:12:35 +00:00
Saria Hajjar
1e740a70f7 Corrected placement of changelog 2020-01-09 20:23:16 +00:00
Saria Hajjar
94a7d8e493 Hid the provider column 2020-01-09 20:15:22 +00:00
Saria Hajjar
883655ce71 Fixes #3393: Paginate circuits at the provider details view 2020-01-09 20:10:51 +00:00
Jeremy Stretch
6019260374 Merge pull request #3873 from hSaria/3872-limit-related-ips
Fixes #3872: Limit related IPs table
2020-01-09 13:53:59 -05:00
Jeremy Stretch
e5c5a1a101 Fixes #3849: Fix ordering of models when dumping data to JSON 2020-01-09 13:28:39 -05:00
Saria Hajjar
c13b9d8798 Added tests for IPv4 2020-01-09 18:26:10 +00:00
hSaria
03b10b6f73 Merge branch 'develop' into 3872-limit-related-ips 2020-01-09 17:18:42 +00:00
Saria Hajjar
67f4d8fab5 Replaced with pagination 2020-01-09 17:16:58 +00:00
Jeremy Stretch
7e0073d6f5 Merge pull request #3863 from hSaria/2113-napalm-settings
Fixes #2113: NAPALM driver settings
2020-01-09 11:56:32 -05:00
Jeremy Stretch
c66dca399b Merge pull request #3860 from hSaria/1982-swagger-napalm
Fixes #1982: Swagger NAPALM documentation
2020-01-09 11:53:20 -05:00
Saria Hajjar
9d085ad83a Changed NAPALM- prefix to X-NAPALM- 2020-01-09 16:48:26 +00:00
Saria Hajjar
ad565e55f1 Removed exception for empty methods
I'll create a seperate ticket for that
2020-01-09 16:40:13 +00:00
Saria Hajjar
46c712e735 Moved NAPALM parameter to decorator 2020-01-09 16:39:13 +00:00
Jeremy Stretch
989d6f5af3 Merge pull request #3871 from hSaria/3864-disallow-0-masks
Fixes #3864: Disallow /0 masks
2020-01-09 11:37:19 -05:00
Saria Hajjar
86865b91f8 Added changelog for 3009
again as I accidentally removed it while merging
2020-01-09 16:32:01 +00:00
hSaria
b53480dd6a Merge branch 'develop' into 3668-address-assign-dns-filter 2020-01-09 16:31:09 +00:00
Saria Hajjar
581ed52b24 Added changelog for 3009 2020-01-09 16:30:13 +00:00
Saria Hajjar
472486acd6 Changed to q filter 2020-01-09 16:26:11 +00:00
Jeremy Stretch
92ec4bd22f Merge pull request #3859 from hSaria/3440-total-cable-length
Fixes #3440: Total cable trace length
2020-01-09 10:55:30 -05:00
Jeremy Stretch
959a0da0ed Merge pull request #3838 from hSaria/3090-interface-filtering
Fixes #3090: Interface filtering
2020-01-09 10:05:04 -05:00
hSaria
b23eaeca54 Merge branch 'develop' into 3090-interface-filtering 2020-01-09 14:56:36 +00:00
hSaria
4f9271e9ff Merge branch 'develop' into 3440-total-cable-length 2020-01-09 14:55:34 +00:00
hSaria
094553dbe7 Merge branch 'develop' into 3623-interface-word-expansion 2020-01-09 14:54:58 +00:00
hSaria
60e4812b32 Merge branch 'develop' into 2113-napalm-settings 2020-01-09 14:54:31 +00:00
hSaria
40625d1299 Merge branch 'develop' into 3668-address-assign-dns-filter 2020-01-09 14:53:32 +00:00
Jeremy Stretch
c9ec8b71e0 Merge pull request #3821 from hSaria/2365-show-available-toggle
Fixes #2598: Toggle for showing available prefixes/ip addresses
2020-01-09 09:52:10 -05:00
Saria Hajjar
73e456495f Fixes #3872: Limit related IPs table 2020-01-09 14:48:21 +00:00
Jeremy Stretch
54227ca9c7 Fixes #3851: Allow passing initial data to custom script forms 2020-01-09 09:41:10 -05:00
hSaria
f3b323536e Merge branch 'develop' into 3864-disallow-0-masks 2020-01-09 14:35:26 +00:00
Saria Hajjar
6537f35176 Fixes #3864: Disallow /0 masks 2020-01-09 14:33:49 +00:00
Jeremy Stretch
b36d0ca3fc Merge pull request #3858 from hSaria/3857-group-custom-link
Fixes #3857: Fix group custom links rendering
2020-01-09 09:28:03 -05:00
Jeremy Stretch
99809109ab Merge pull request #3866 from netbox-community/3834-filter-tests
Add FilterSet tests for all apps
2020-01-09 08:57:27 -05:00
Saria Hajjar
1cdbfd6d60 Fixes #3668: search address by DNS name when assigning 2020-01-09 10:00:02 +00:00
Jeremy Stretch
4030e5ec24 Add filter tests for extras 2020-01-08 21:41:32 -05:00
Jeremy Stretch
4151e52802 Clean up filter imports 2020-01-08 17:20:31 -05:00
Jeremy Stretch
b1e8145ffb Standardize usage of self.filterset for test cases 2020-01-08 17:06:39 -05:00
Jeremy Stretch
c04d8ca5a7 Add tests for PowerPanel and PowerFeed filters 2020-01-08 15:56:42 -05:00
Jeremy Stretch
e312c30822 Add CableFilter test 2020-01-08 15:30:56 -05:00
Jeremy Stretch
40c30baffa Add tests for InventoryItem, VirtualChassis filters 2020-01-08 14:31:59 -05:00
Jeremy Stretch
bca7435a5a Add remaining tests for device component filters 2020-01-08 14:01:31 -05:00
Saria Hajjar
396bb28967 Added example and handled invalid ranges gracefully 2020-01-08 17:28:31 +00:00
Saria Hajjar
eb40275427 Fixes #3623: Word expansion for interfaces 2020-01-08 17:23:09 +00:00
hSaria
8519f546a6 Merge branch 'develop' into 3857-group-custom-link 2020-01-08 16:47:57 +00:00
Jeremy Stretch
e9b2ad9f5c Fix DeviceTestCase.test_cluster 2020-01-08 11:22:07 -05:00
Jeremy Stretch
39fba4f05d Fix InterfaceTestCase.test_mac_address 2020-01-08 11:19:13 -05:00
Jeremy Stretch
38c16d71b4 Merge branch 'develop' into 3834-filter-tests 2020-01-08 11:14:52 -05:00
Jeremy Stretch
8fef6edb27 Fixes #3862: Allow filtering device components by multiple device names 2020-01-08 11:12:44 -05:00
Jeremy Stretch
5cac900380 Add console port, console server port filter tests 2020-01-08 11:04:50 -05:00
Saria Hajjar
f49467bcb5 Corrected optional arg assignment 2020-01-08 16:01:18 +00:00
Saria Hajjar
98a66f7fbe NAPALM settings changelog 2020-01-08 15:55:36 +00:00
Saria Hajjar
ce8d470860 Added NAPALM documentation 2020-01-08 15:54:09 +00:00
Saria Hajjar
dc475f4755 Fixes #2113: Adjust NAPALM settings with headers 2020-01-08 15:53:48 +00:00
Jeremy Stretch
a7982bb0e1 Add device filter tests 2020-01-08 09:50:22 -05:00
Saria Hajjar
ea05b5b606 Fixes #1982: Swagger NAPALM documentation 2020-01-08 13:34:46 +00:00
Saria Hajjar
996d49de67 Fixes #3440: Total cable trace length 2020-01-08 10:49:58 +00:00
Saria Hajjar
74997a18a5 Fixes #3857: Fix group custom links rendering 2020-01-08 10:14:48 +00:00
Jeremy Stretch
832fd49339 Add DeviceType filter tests 2020-01-07 17:13:05 -05:00
Jeremy Stretch
acb2f32304 Add tests for DCIM filters 2020-01-07 13:53:26 -05:00
Saria Hajjar
32f39e10c9 Changed default to showing available 2020-01-07 17:58:30 +00:00
hSaria
190e683654 Removed cookie-based storage; now based on request 2020-01-07 17:18:36 +00:00
Jeremy Stretch
770f4c962c Fixes #3856: Allow filtering VM interfaces by multiple MAC addresses 2020-01-07 10:31:44 -05:00
Jeremy Stretch
227921e0a0 Add tests for virtualization filters 2020-01-07 10:19:21 -05:00
Jeremy Stretch
39d0261d8a Add tests for tenancy filters 2020-01-07 09:41:43 -05:00
Jeremy Stretch
f267a532f6 Add filter tests for secrets 2020-01-07 09:35:22 -05:00
Jeremy Stretch
e7ee4486a5 Fixes #3853: Fix device role link on config context view 2020-01-07 09:08:31 -05:00
Saria Hajjar
6a3cd83efc Moved regex note to tooltip 2020-01-07 11:09:39 +00:00
Saria Hajjar
a7ec0c14f7 Move toggles js code to static 2020-01-07 11:09:31 +00:00
Jeremy Stretch
05bfe94d3e Add remaining IPAM filter tests 2020-01-06 21:35:34 -05:00
Jeremy Stretch
8d0aaa4ec1 Add IPAM filter tests (WIP) 2020-01-06 17:42:17 -05:00
Jeremy Stretch
4b5c4b7be5 Initial work on filter tests 2020-01-06 15:39:02 -05:00
Saria Hajjar
18333973aa Merge branch '3090-interface-filtering' of https://github.com/hSaria/netbox into 3090-interface-filtering 2020-01-06 20:05:25 +00:00
Saria Hajjar
07a1baef13 Limit toggle selector to visible input fields 2020-01-06 20:05:07 +00:00
Jeremy Stretch
15545b70d6 Merge pull request #3814 from hSaria/3589-interface-tagged-vlans
Fixes #3589: Interface VLAN filtering
2020-01-06 13:20:14 -05:00
hSaria
cfb8b3cf56 Merge branch 'develop' into 3090-interface-filtering 2020-01-06 15:19:46 +00:00
hSaria
206732eb62 Merge branch 'develop' into 2365-show-available-toggle 2020-01-06 15:18:57 +00:00
Jeremy Stretch
258cc4b50e Merge pull request #3846 from hSaria/2050-image-preview
Fixes #2050: Image preview
2020-01-06 10:18:18 -05:00
hSaria
d1f81783ef Merge branch 'develop' into 3589-interface-tagged-vlans 2020-01-06 15:18:06 +00:00
Jeremy Stretch
9bd2af48a3 Merge branch 'develop' into 2050-image-preview 2020-01-06 10:12:13 -05:00
Jeremy Stretch
d4df965f46 Merge pull request #3845 from hSaria/3187-elevation-rack-filter
Fixes #3187: Elevation rack filter
2020-01-06 10:06:20 -05:00
Saria Hajjar
7dddd4734c Updated changelog 2020-01-05 09:11:58 +00:00
Saria Hajjar
c45daca5f2 Removed changes that will be covered in #3840 2020-01-05 09:10:46 +00:00
Saria Hajjar
067af26892 Changelog (may conflict with other merges) 2020-01-04 18:19:05 +00:00
Saria Hajjar
792f38334a Fixes #2050: Image preview for attachments 2020-01-04 18:17:41 +00:00
Saria Hajjar
dba40cd6bc Changelog (may conflict because adding headers) 2020-01-04 13:32:07 +00:00
Saria Hajjar
4b19073b8b Fixed #3187: Rack multi-selection field 2020-01-04 13:30:31 +00:00
Saria Hajjar
28eca9a026 Forgot le seperator 2020-01-03 20:12:21 +00:00
Saria Hajjar
3556051d14 Height was a touch off 2020-01-03 19:58:41 +00:00
Saria Hajjar
e1d1f522ff Closes #3090: Filter field for interface 2020-01-03 19:38:51 +00:00
Saria Hajjar
04f3e58ab4 Line seperator 2020-01-03 19:27:02 +00:00
Saria Hajjar
e1c61c5019 Changelog 2020-01-03 19:25:33 +00:00
Saria Hajjar
1c0de0093b Merge remote-tracking branch 'netbox-community/develop' into 2365-show-available-toggle 2020-01-03 19:24:44 +00:00
Saria Hajjar
9d8ab81e3a Removed changelog (temporarily while merging) 2020-01-03 19:24:39 +00:00
Saria Hajjar
fa55571503 Merge remote-tracking branch 'netbox-community/develop' into 3589-interface-tagged-vlans 2020-01-03 19:19:12 +00:00
Jeremy Stretch
feb04f0401 Post-release version bump 2020-01-03 14:01:10 -05:00
Jeremy Stretch
5c07b6dc1d Merge pull request #3837 from netbox-community/develop
Release v2.6.11
2020-01-03 14:00:10 -05:00
Jeremy Stretch
e3b448b7ad Release v2.6.11 2020-01-03 13:55:43 -05:00
Jeremy Stretch
57f199f899 Fixes #3833: Add region and region_id filters where missing (#3836) 2020-01-03 13:52:50 -05:00
Jeremy Stretch
b38bb64c81 Fixes #3831: Fix API-driven filter field rendering (#3812 regression) 2020-01-03 11:25:22 -05:00
Jeremy Stretch
c2dc243c7c Post-release version bump 2020-01-02 17:08:04 -05:00
Jeremy Stretch
25c3c1b431 Merge pull request #3829 from netbox-community/develop
Fix v2.6.10 release date
2020-01-02 17:05:21 -05:00
Jeremy Stretch
156fbae7d3 Fix v2.6.10 release date 2020-01-02 17:04:20 -05:00
Jeremy Stretch
a0ae7a227d Merge pull request #3828 from netbox-community/develop
Release v2.6.10
2020-01-02 17:02:52 -05:00
Jeremy Stretch
9ef3e68479 Release v2.6.10 2020-01-02 17:01:05 -05:00
Jeremy Stretch
435d248645 #3122: Allow multiple selections 2020-01-02 16:56:14 -05:00
Jeremy Stretch
53db5090c1 #3827: Fix erroneous filter class 2020-01-02 16:55:36 -05:00
Jeremy Stretch
caa062c8ba Merge pull request #3826 from hSaria/3122-connection-device-select2
Fixes #3122: Select2 for device field
2020-01-02 16:39:50 -05:00
Saria Hajjar
240bbc2944 Select2 site widget 2020-01-02 21:28:06 +00:00
Saria Hajjar
a3861ed492 Update device filters to use IDs 2020-01-02 21:21:52 +00:00
Saria Hajjar
82c70302fd Merge branch 'develop' into 3122-connection-device-select2 2020-01-02 20:40:19 +00:00
Jeremy Stretch
80d1f80b61 Fixes #3827: Allow filtering console/power/interface connections by device ID 2020-01-02 13:44:18 -05:00
hSaria
d3c6caf8a8 Limit tagged validation to tagged interfaces
Co-Authored-By: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-01-02 17:52:22 +00:00
Saria Hajjar
a707204f98 Select2 for device field 2020-01-02 17:46:37 +00:00
Jeremy Stretch
523a1388db Correct release notes 2020-01-02 11:56:29 -05:00
Jeremy Stretch
970586b07b Merge pull request #3818 from hSaria/2233-move-inventoryitem
Closes #2233: Ability to move inventory items between devices
2020-01-02 11:55:18 -05:00
Saria Hajjar
28ae6849b4 Templatized show_available toggle 2020-01-02 16:29:11 +00:00
Saria Hajjar
8e3a371688 Added default to cookie 2020-01-02 16:19:12 +00:00
Saria Hajjar
2a219eff23 is not None not needed as the value 'false' is a string 2020-01-02 16:13:47 +00:00
Saria Hajjar
f81641ae96 Corrected ticket number 2020-01-02 15:48:30 +00:00
Jeremy Stretch
4f0d3e6b32 Merge pull request #3820 from hSaria/3819-cf-boolean-select2
Select2 for custom fields
2020-01-02 10:11:32 -05:00
Jeremy Stretch
77d1ac8b07 Merge pull request #3817 from hSaria/2988-group-create-warn
Closes #2988
2020-01-02 10:10:11 -05:00
Jeremy Stretch
5d0ac02704 Merge pull request #3816 from hSaria/3815-select2-width
Fixes #3815: Select2 width handling via theme
2020-01-02 10:08:56 -05:00
Jeremy Stretch
fc5d07bb13 Merge pull request #3813 from hSaria/3812-optimize-select-api
Fixes #3812: Only preload selected options for API-based select
2020-01-02 10:05:06 -05:00
Jeremy Stretch
395f23e1d3 Fix for #3822 2020-01-02 09:47:02 -05:00
Jeremy Stretch
dd85448451 Fixes #3822: Fix exception when editing a device bay (regression from #3596) 2020-01-02 09:26:38 -05:00
hSaria
6a7af22dea Merge branch 'develop' into 2365-show-available-toggle 2020-01-02 09:18:53 +00:00
Saria Hajjar
37bc17d3a2 Fixes #2365: Toggle for showing available prefixes/ip addresses 2020-01-02 09:16:18 +00:00
Saria Hajjar
ca131e5b2a Select2 for custom fields 2020-01-01 23:46:51 +00:00
Saria Hajjar
c75795ceda Ability to move inventory items between devices 2020-01-01 23:28:20 +00:00
Saria Hajjar
7dc0591e3e Note about the group existance 2020-01-01 22:38:02 +00:00
Saria Hajjar
cfa078c929 Added theme to select2 of tags 2020-01-01 21:14:38 +00:00
Saria Hajjar
57ea73db46 Turn off Select2 static width calculation 2020-01-01 21:01:08 +00:00
Saria Hajjar
7ad9e8a2fb More informative error message 2020-01-01 19:53:13 +00:00
Saria Hajjar
1a57120b78 3589 changelog 2020-01-01 17:43:39 +00:00
Saria Hajjar
d267aeb621 Filter VLANs to only those in the current site or global 2020-01-01 17:34:26 +00:00
Saria Hajjar
a110b0badb Removed no-longer-used choices (now handled via API-based select) 2020-01-01 16:43:21 +00:00
Saria Hajjar
242ae9eb91 Comment clarification 2020-01-01 16:04:08 +00:00
Saria Hajjar
53625e0dea Fixes #3812: Only preload selected options for API-based select 2020-01-01 15:54:00 +00:00
Saria Hajjar
aa4f73ffbf Removed trailing space 2020-01-01 12:09:51 +00:00
Jeremy Stretch
8c7b0cf670 Close #2892: Extend admin UI to allow deleting old report results 2019-12-31 16:11:47 -05:00
Saria Hajjar
05570ae4ad Clean tagged VLANs 2019-12-31 20:47:13 +00:00
Jeremy Stretch
0cf94cff16 Closes #3062: Add assigned_to_interface filter for IP addresses 2019-12-31 15:24:00 -05:00
Jeremy Stretch
b5455ed882 Closes #3461: Fail gracefully on custom link rendering exception 2019-12-31 15:04:56 -05:00
Jeremy Stretch
8a4293a4cc Introduce render_jinja2() convenience function 2019-12-31 14:00:55 -05:00
Jeremy Stretch
f649b9f04f Fixes #3106: Restrict queryset of chained fields when form validation fails 2019-12-31 12:41:02 -05:00
Jeremy Stretch
5caa04ef2b Fixes #3811: Fix filtering of racks by group on device list 2019-12-31 11:35:18 -05:00
Jeremy Stretch
f2c49063f8 Fixes #3809: Filter platform by manufacturer when editing devices 2019-12-31 11:25:42 -05:00
Jeremy Stretch
ea2351e902 Merge pull request #3804 from hSaria/3803-svg-logo
Fixes 3803
2019-12-30 14:12:44 -05:00
hSaria
8effb54c89 Converted text to path 2019-12-30 18:28:39 +00:00
hSaria
66c99acb9e Converted text to path 2019-12-30 18:28:12 +00:00
Jeremy Stretch
2e5a326315 Merge pull request #3802 from hSaria/3762-datetime-selectors
Fixes 3762
2019-12-30 12:32:08 -05:00
Jeremy Stretch
b5177c608d Merge branch 'develop' into 3762-datetime-selectors 2019-12-30 12:31:50 -05:00
Jeremy Stretch
be7d5a2310 Merge pull request #3807 from hSaria/3712-scroll-offset
Fixes 3712
2019-12-30 11:40:43 -05:00
Jeremy Stretch
5230127fde Merge pull request #3800 from kobayashi/3788
implement 3788
2019-12-30 11:23:51 -05:00
Saria Hajjar
405320d8ab Changelog 2019-12-29 21:22:14 +00:00
Saria Hajjar
2f2e193cf9 Account for the header when hash-scrolling 2019-12-29 21:20:02 +00:00
Saria Hajjar
1fc206b9c5 Replace doc logo with svg 2019-12-29 14:10:31 +00:00
Saria Hajjar
44e1a477f2 Fixed Y-coordinate off by 10 2019-12-29 14:06:41 +00:00
Saria Hajjar
e62302c979 Fixes #3803 2019-12-29 13:41:00 +00:00
Saria Hajjar
51b0fe4596 Changelog for 3762 2019-12-29 08:50:08 +00:00
Saria Hajjar
7399aa0c5e Add datetime widgets 2019-12-28 22:55:00 +00:00
Saria Hajjar
aa4588f9ba Load flatpickr using selectors (classes) 2019-12-28 21:33:07 +00:00
Saria Hajjar
4c2e2e0fa3 Include Flatpickr library globally 2019-12-28 21:32:40 +00:00
Saria Hajjar
6dea8ddbce Flatpickr library statics 2019-12-28 21:31:21 +00:00
kobayashi
e6623a6ca8 implement 3788 2019-12-27 16:17:17 -05:00
Jeremy Stretch
0c5f535689 Merge pull request #3793 from struppinet/develop
Closes #3663: add Filter Tests
2019-12-27 14:14:55 -05:00
Jeremy Stretch
ae9d0d894a Fixes #3695: Include A/Z termination sites for circuits in global search 2019-12-27 14:04:03 -05:00
Jeremy Stretch
14401c30b6 Update contributing guide to reference the issue intake policy 2019-12-27 13:48:41 -05:00
struppi
fbb93c72d0 Closes #3663: improve tests 2019-12-26 22:21:05 +01:00
Jeremy Stretch
5fedcd1f4e Fixes #3789: Typo 2019-12-26 10:19:32 -05:00
Jeremy Stretch
adeee0bf5c Docs & changelog for #3705 2019-12-26 10:16:53 -05:00
Jeremy Stretch
84a2b726f5 Merge pull request #3775 from steffann/3705-make-current-user-available-in-custom-scripts
Add request to Custom Script run, if receiver supports it
2019-12-26 10:04:07 -05:00
struppi
407a60dcc4 Closes #3663: fix PEP errors 2019-12-26 12:26:41 +01:00
struppi
d31507985b Closes #3663: add Filter Tests 2019-12-25 18:41:59 +01:00
Sander Steffann
0174c9747b Implement request passing as a property of Script 2019-12-19 23:35:18 +01:00
Jeremy Stretch
55b503da5b Fixes #3780: Fix AttributeError exception in API docs 2019-12-19 14:04:18 -05:00
Jeremy Stretch
aff4ad0f97 Post-release version bump 2019-12-16 16:33:31 -05:00
Jeremy Stretch
50df3acd26 Merge pull request #3774 from netbox-community/develop
Release v2.6.9
2019-12-16 16:32:00 -05:00
Jeremy Stretch
e53e1e31de Release v2.6.9 2019-12-16 16:30:20 -05:00
Jeremy Stretch
1acdf58a4b Merge pull request #3764 from kobayashi/3679
fix 3757
2019-12-13 15:57:13 -05:00
kobayashi
1d1cb867cd fix 3679 2019-12-13 14:42:10 -05:00
Jeremy Stretch
b46bfaebc1 Merge pull request #3763 from hSaria/3761-token-copy-button
Fixes #3761: copy button for tokens
2019-12-13 14:16:41 -05:00
Jeremy Stretch
a22c7c1539 Fixes #2358: Respect custom field default values when creating objects via the REST API 2019-12-13 14:15:48 -05:00
hSaria
ea51aa97b7 Update version-2.6.md 2019-12-13 18:08:34 +00:00
hSaria
6a6959d041 Fixes #3761: copy button for tokens 2019-12-13 18:06:14 +00:00
Jeremy Stretch
462cede863 Fixes #2170: Prevent the deletion of a virtual chassis when a cross-member LAG is present 2019-12-13 11:36:31 -05:00
Jeremy Stretch
85c11bbd83 Closes #3441: Move virtual machine results near devices in global search 2019-12-13 10:37:58 -05:00
Jeremy Stretch
77e0564d13 Closes #3152: Include direct link to rack elevations on site view 2019-12-13 10:12:46 -05:00
Jeremy Stretch
3b03d68ac7 Merge pull request #3751 from hSaria/3749-attribute-error
Fixes 3749 attribute error
2019-12-11 08:50:09 -05:00
hSaria
b57d64c72d Changelog for #3751 2019-12-11 07:24:44 +00:00
hSaria
3b76e0203a Fixes 3749 attribute error 2019-12-11 07:03:39 +00:00
Jeremy Stretch
3c36bec298 Post-release version bump 2019-12-10 10:50:46 -05:00
Jeremy Stretch
425670f52a Merge pull request #3745 from netbox-community/develop
Release v2.6.8
2019-12-10 10:47:48 -05:00
Jeremy Stretch
6a2651991a Release v2.6.8 2019-12-10 10:42:48 -05:00
Jeremy Stretch
f25e2a1922 Fixes #3644: Fix exception when connecting a cable to a RearPort with no corresponding FrontPort 2019-12-09 15:42:04 -05:00
Jeremy Stretch
95edec5448 #3722: Tweak ordering of permitted characters to avoid creating a regex range 2019-12-09 10:02:56 -05:00
Jeremy Stretch
f2076c9572 Fixed git commit hook 2019-12-06 12:54:13 -05:00
Jeremy Stretch
d71e6698f4 Extend git commit hook to check for missing schema migrations 2019-12-06 12:42:59 -05:00
Jeremy Stretch
9b26225fdd #3720: Update migration to add powerfeeds to termination_type limit list (does not impact database) 2019-12-06 12:29:31 -05:00
Jeremy Stretch
f8c5ca942a #3722: Update migration with new validator (does not impact database) 2019-12-06 12:19:29 -05:00
Jeremy Stretch
16353ce63c Correct stalebot config file name 2019-12-05 21:43:22 -05:00
Jeremy Stretch
7a4b202064 Fixes #3725: Enforce client validation for minimum service port number 2019-12-05 21:22:34 -05:00
Jeremy Stretch
a97ebc6d4c Fixes #3722: Allow the underscore character in IPAddress DNS names 2019-12-05 21:14:29 -05:00
Jeremy Stretch
9e765b1704 Fixes #3730: Add link to docs page for custom links 2019-12-05 21:07:43 -05:00
John Anderson
1048d3909b fixes #3724 - allow filtering interfaces by more than one device name 2019-12-04 02:00:08 -05:00
Jeremy Stretch
b2caaa6733 Fixes #3312: Fix validation error when editing power cables in bulk 2019-11-27 09:19:34 -05:00
Jeremy Stretch
15722c1871 Fixes #3720: Correctly indicate power feed terminations on cable list 2019-11-26 16:56:11 -05:00
Jeremy Stretch
1d18948307 Fixes #3709: Prevent exception when importing an invalid cable definition 2019-11-26 16:46:51 -05:00
Jeremy Stretch
6a451e0c0e Merge pull request #3701 from hSaria/3697-custom-scripts-example-typo
Fixes #3697: Correct field_order attribute name
2019-11-22 08:45:21 -05:00
hSaria
b46e52eccc Fixed #3697: Correct field_order attribute name 2019-11-20 17:03:12 +00:00
Jeremy Stretch
de986ec392 Merge pull request #3687 from kobayashi/3679
fix url expressions
2019-11-14 22:03:03 -05:00
Jeremy Stretch
4b415caad2 Changelog for #3663 2019-11-14 22:01:37 -05:00
Jeremy Stretch
bfede60f3d Rename CreatedUpdatedFilter to CreatedUpdatedFilterSet 2019-11-14 22:00:12 -05:00
Jeremy Stretch
03b8759597 'base_name' deprecated in DRF v3.9.0 2019-11-14 21:58:37 -05:00
Jeremy Stretch
bdd623f82c Merge pull request #3680 from struppinet/develop
Closes #3663: API filter by created, last_updated
2019-11-14 21:57:26 -05:00
kobayashi
3c8083ed7f fix url expressions 2019-11-13 00:48:47 -05:00
kobayashi
5904f1f8ee Changelog for #3329 2019-11-12 09:47:31 -05:00
kobayashi
cc848a3f01 Merge pull request #3684 from bbock/docs-remove-p3p
Docs: Remove obsolete P3P policy
2019-11-12 09:38:46 -05:00
Bernhard Bock
1aaa101fb5 Docs: Remove obsolete P3P policy
P3P is obsolete (https://www.w3.org/TR/P3P11/), therefore the HTTP header
should be removed from the recommended config in the installation docs.
2019-11-08 15:13:32 +01:00
struppi
0319450643 Closes #3663: rename filter class 2019-11-07 22:41:09 +01:00
struppi
099774d667 Closes #3663: PEP8 fixes 2019-11-07 22:38:51 +01:00
Jeremy Stretch
b1761f7856 #3139: Add a message indicating why the user is redirected 2019-11-06 10:01:42 -05:00
Jeremy Stretch
1bc38f66ae Changelog entries for #3139 and #3457 2019-11-06 09:58:30 -05:00
Jeremy Stretch
b4433a8471 Merge pull request #3667 from steffann/3139-disable-user-password-change-if-come-in-with-ldap-auth
Hide password change page when user is logged in using LDAP
2019-11-06 09:57:25 -05:00
Jeremy Stretch
053f49c76a Merge pull request #3673 from ananace/cable-color-display
3457 Display cable colors in device interface list
2019-11-06 09:53:59 -05:00
Jeremy Stretch
fbde6187ea Fixes #3669: Include weight field in prefix/VLAN role form 2019-11-06 09:39:21 -05:00
Jeremy Stretch
5eb5c4bac5 Fixes #3674: Include comments on PowerFeed view 2019-11-06 09:26:49 -05:00
Jeremy Stretch
fb4283ed53 Move alternative installations to the GitHub wiki 2019-11-06 09:05:12 -05:00
Alexander Olofsson
bbd65988f9 3457 Display cable colors in device interface list 2019-11-06 10:15:55 +01:00
struppi
a11fa44170 Closes #3663: fix inheritance error 2019-11-04 21:00:44 +01:00
struppi
99a542e4e4 Closes #3663: API filter by created, last_updated 2019-11-04 20:51:56 +01:00
Sander Steffann
7f779e3942 Hide password change page when user is logged in using LDAP 2019-11-03 16:05:53 +03:00
Jeremy Stretch
a2a83a4a4c Post-release version bump 2019-11-01 15:54:17 -04:00
Jeremy Stretch
9f7313e492 Merge pull request #3661 from netbox-community/develop
Release v2.6.7
2019-11-01 15:43:38 -04:00
Jeremy Stretch
f6fbf66c8c Merge branch 'master' into develop 2019-11-01 15:39:24 -04:00
Jeremy Stretch
3deedfd177 Release v2.6.7 2019-11-01 15:37:05 -04:00
Jeremy Stretch
e9d5ff095c Suppress migration messages during tests; fix typo 2019-11-01 15:08:59 -04:00
Jeremy Stretch
43d6bfa15f Corrected test 2019-11-01 15:08:20 -04:00
Jeremy Stretch
875e09013c Move TreeNodeMultipleChoiceFilter tests to utilities (follow-up to #3616) 2019-11-01 15:01:24 -04:00
Jeremy Stretch
ee854eff2c Changelog for #3357 2019-11-01 14:31:03 -04:00
Jeremy Stretch
b176e0fafd Merge pull request #3616 from kobayashi/3357
allow null region filtering
2019-11-01 14:29:32 -04:00
Jeremy Stretch
fb24a4d420 Merge pull request #3660 from tyler-8/gunicorn_options
Add max_requests details to example config
2019-11-01 12:32:39 -04:00
Jeremy Stretch
391c42300e Closes #3659: Add filtering for objects in admin UI 2019-11-01 12:22:39 -04:00
Tyler Bigler
2a8fa01a57 Increase request counts for max_requests 2019-11-01 12:09:44 -04:00
Jeremy Stretch
bd4b496d14 Tweak PR template to indicate placement of issue number 2019-11-01 11:52:43 -04:00
Jeremy Stretch
66f4aac0e8 Changelog for #3619 2019-11-01 11:45:22 -04:00
Jeremy Stretch
267caa348e Merge pull request #3657 from Netnod/3619-new-400G-osfp-interface-type
3619 new 400 g osfp interface type
2019-11-01 11:43:43 -04:00
Tyler Bigler
7bf09a3085 Remove redundant word 2019-11-01 10:26:53 -04:00
Tyler Bigler
a0de0696c5 Add max_requests details to example config 2019-11-01 10:23:25 -04:00
Emil Palm
aa6b2b8407 Merge remote-tracking branch 'upstream/develop' into 3619-new-400G-osfp-interface-type 2019-10-30 14:31:17 -05:00
Emil Palm
3e6726ff97 Add IFACE_TYPE_400GE_OSPF 2019-10-30 14:30:23 -05:00
Jeremy Stretch
60ec3fde9d Closes #3652: Limit next/previous rack by assigned rack group 2019-10-29 15:17:00 -04:00
Jeremy Stretch
846acf7e9d Changelog for #3629 2019-10-28 13:19:01 -04:00
kobayashi
fb9279cc74 Merge pull request #3645 from BegBlev/llpd-port-id
Retrieve port-id in LLDP tab
2019-10-28 12:19:10 -04:00
kobayashi
d2aa9b8e79 filtering multiple regions with null 2019-10-28 02:24:44 -04:00
Tyler Bigler
eaeb52de20 Add CONN_MAX_AGE to documentation (#3642)
* Add CONN_MAX_AGE to sample configurations

* Correct alignment

* Restore ghost space

* Correct alignment.

* Use stable docs url
2019-10-25 13:11:48 -04:00
Jeremy Stretch
fdbf41e9fd Fixes #3643: Update all Django documentation links to 'stable' version 2019-10-25 11:09:30 -04:00
Jeremy Stretch
092346c819 Changelog for #3309 2019-10-25 09:57:17 -04:00
Jeremy Stretch
5a5f51fe48 Merge pull request #3632 from netbox-community/3309-changelog-middleware
Rewrite change logging middleware
2019-10-25 09:47:32 -04:00
Jeremy Stretch
800df1ebbe Fixes #3636: Add missing rack_group field to PowerFeed CSV export 2019-10-25 09:27:33 -04:00
John Anderson
8eec67e73a #3635 changelog 2019-10-24 23:31:06 -04:00
John Anderson
33b420652d Merge pull request #3639 from sdktr/patch-1
Fix #3635 - cache circuits.*
2019-10-24 21:16:28 -04:00
Stefan de Kooter
42b679d9a3 Fix #3635 - cache circuits.*
Enable caching for items under the circuits app
2019-10-25 00:46:05 +02:00
Jeremy Stretch
ae4f17264f Fixes #3460: Extend upgrade script to validate Python dependencies 2019-10-23 17:12:32 -04:00
Jeremy Stretch
c7d8083ac6 Closes #3594: Add ChoiceVar for custom scripts 2019-10-23 15:59:27 -04:00
Jeremy Stretch
95fec1a87c Merge pull request #3627 from bdlamprecht/pg_dump-documentation
Adding note on invalidating cache
2019-10-23 12:24:27 -04:00
Jeremy Stretch
7467347af1 Fixes #3596: Prevent server error when reassigning a device to a new device bay 2019-10-23 09:28:00 -04:00
Jeremy Stretch
0ebc2e4ac0 Fix reporting of custom fields in webhook data on object deletion 2019-10-22 16:12:25 -04:00
Jeremy Stretch
ccb9f7bfe2 Rewrote ObjectChangeMiddleware to remove the curried handle_deleted_object() function 2019-10-22 15:10:49 -04:00
kobayashi
766b5dff24 allow null region filtering 2019-10-22 00:41:49 -04:00
Vincent Catros
68e0329c1b Retrieve port-id in LLDP tab 2019-10-21 11:57:23 +02:00
Brady Lamprecht
fd99994fdb Adding note on invalidating cache 2019-10-18 14:59:55 -06:00
Jeremy Stretch
f21a63382c Changelog for #3340 2019-10-18 09:08:56 -04:00
Jeremy Stretch
6f0581598b Merge pull request #3613 from kobayashi/3340
modified front port connection type list
2019-10-18 09:07:22 -04:00
kobayashi
244e85e836 modify patch panel port connection type list 2019-10-18 00:01:21 -04:00
Jeremy Stretch
1df6713ad5 Minor improvements pertaining to CII best practices 2019-10-17 20:56:37 -04:00
Jeremy Stretch
af73ce75ce Merge pull request #3607 from netbox-community/3606-stale-bot
implemented #3606 - added stale bot config
2019-10-17 14:32:40 -04:00
Jeremy Stretch
f08968da49 Exempt issues tagged with "status: blocked" 2019-10-17 14:28:27 -04:00
John Anderson
24344ccfaf Merge pull request #3615 from markkuleinio/develop
Update examples in webhooks.md
2019-10-15 14:27:20 -04:00
Markku Leiniö
91f045a2e4 Update examples in webhooks.md 2019-10-15 20:51:57 +03:00
Jeremy Stretch
050dfb279d Merge pull request #3597 from dgarros/patch-1
Update pillow version to 6.2.0
2019-10-15 11:54:44 -04:00
Jeremy Stretch
f9b7f18be6 Merge pull request #3609 from ScanPlusGmbH/missing-variable
Add SCRIPTS_ROOT to configuration.example.py
2019-10-14 11:58:28 -04:00
Tobias Genannt
a7380ba353 Add SCRIPTS_ROOT to configuration.example.py
Fixes #3608 by adding the new variable to the example configuration.
2019-10-14 09:29:04 +02:00
John Anderson
b8feba1070 implemented #3606 - added stale bot config 2019-10-13 04:12:58 -04:00
John Anderson
64575fec42 Merge pull request #3605 from netbox-community/3445-webhooks-additional-headers
implemented #3445 - Add support for additional user defined headers t…
2019-10-13 03:15:43 -04:00
John Anderson
c7d9bf839e implemented #3445 - Add support for additional user defined headers to be added to webhook requests 2019-10-13 03:09:58 -04:00
John Anderson
8480c0da53 Merge pull request #3603 from netbox-community/3499-webhooks-ca-filepath
implemented #3499 - Add  to Webhook model to support user supplied CA certificate verification of webhook requests
2019-10-13 01:50:10 -04:00
John Anderson
5e88313276 typo in change log 2019-10-13 01:45:20 -04:00
John Anderson
09d7d38b04 implemented #3499 - Add to Webhook model to support user supplied CA certificate verrification of webhook requests 2019-10-13 01:43:08 -04:00
Damien Garros
4cc29729f9 Update pillow version to 6.2.0
A new CVE just got reporter regarding Pillow 
http://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2019-16865
it's affecting all version prior to 6.2.0
2019-10-11 13:45:37 -04:00
Jeremy Stretch
b25faec159 Post-release version bump 2019-10-10 12:42:57 -04:00
117 changed files with 8420 additions and 822 deletions

View File

@@ -9,8 +9,7 @@
IF YOUR PULL REQUEST DOES NOT REFERENCE AN ACCEPTED BUG REPORT OR
FEATURE REQUEST, IT WILL BE MARKED AS INVALID AND CLOSED.
-->
### Fixes:
### Fixes: <ISSUE NUMBER GOES HERE>
<!--
Please include a summary of the proposed changes below.
-->

23
.github/lock.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
# Configuration for Lock (https://github.com/apps/lock)
# Number of days of inactivity before a closed issue or pull request is locked
daysUntilLock: 90
# Skip issues and pull requests created before a given timestamp. Timestamp must
# follow ISO 8601 (`YYYY-MM-DD`). Set to `false` to disable
skipCreatedBefore: 2020-01-01
# Issues and pull requests with these labels will be ignored. Set to `[]` to disable
exemptLabels: []
# Label to add before locking, such as `outdated`. Set to `false` to disable
lockLabel: false
# Comment to post before locking. Set to `false` to disable
lockComment: false
# Assign `resolved` as the reason for locking. Set to `false` to disable
setLockReason: true
# Limit to only `issues` or `pulls`
# only: issues

30
.github/stale.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# Configuration for Stale (https://github.com/apps/stale)
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 14
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Issues with these labels will never be considered stale
exemptLabels:
- "status: accepted"
- "status: gathering feedback"
- "status: blocked"
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. NetBox
is governed by a small group of core maintainers which means not all opened
issues may receive direct feedback. Please see our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: >
This issue has been automatically closed due to lack of activity. In an
effort to reduce noise, please do not comment any further. Note that the
core maintainers may elect to reopen this issue at a later date if deemed
necessary.

View File

@@ -16,7 +16,7 @@ For real-time discussion, you can join the #netbox Slack channel on [NetworkToCo
## Reporting Bugs
* First, ensure that you've installed the [latest stable version](https://github.com/netbox-community/netbox/releases)
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases)
of NetBox. If you're running an older version, it's possible that the bug has
already been fixed.
@@ -24,31 +24,30 @@ already been fixed.
to see if the bug you've found has already been reported. If you think you may
be experiencing a reported issue that hasn't already been resolved, please
click "add a reaction" in the top right corner of the issue and add a thumbs
up (+1). You mightalso want to add a comment describing how it's affecting your
up (+1). You might also want to add a comment describing how it's affecting your
installation. This will allow us to prioritize bugs based on how many users are
affected.
* If you haven't found an existing issue that describes your suspected bug,
please inquire about it on the mailing list. **Do not** file an issue until you
have received confirmation that it is in fact a bug. Invalid issues are very
distracting and slow the pace at which NetBox is developed.
* When submitting an issue, please be as descriptive as possible. Be sure to
include:
provide all information request in the issue template, including:
* The environment in which NetBox is running
* The exact steps that can be taken to reproduce the issue (if applicable)
* The exact steps that can be taken to reproduce the issue
* Expected and observed behavior
* Any error messages generated
* Screenshots (if applicable)
* Please avoid prepending any sort of tag (e.g. "[Bug]") to the issue title.
The issue will be reviewed by a moderator after submission and the appropriate
The issue will be reviewed by a maintainer after submission and the appropriate
labels will be applied for categorization.
* Keep in mind that we prioritize bugs based on their severity and how much
work is required to resolve them. It may take some time for someone to address
your issue.
* For more information on how bug reports are handled, please see our [issue
intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
## Feature Requests
* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues)
@@ -61,10 +60,10 @@ free to add a comment with any additional justification for the feature.
(However, note that comments with no substance other than a "+1" will be
deleted. Please use GitHub's reactions feature to indicate your support.)
* Due to an excessive backlog of feature requests, we are not currently
accepting any proposals which substantially extend NetBox's functionality
beyond its current feature set. This includes the introduction of any new views
or models which have not already been proposed in an existing feature request.
* Due to a large backlog of feature requests, we are not currently accepting
any proposals which substantially extend NetBox's functionality beyond its
current feature set. This includes the introduction of any new views or models
which have not already been proposed in an existing feature request.
* Before filing a new feature request, consider raising your idea on the
mailing list first. Feedback you receive there will help validate and shape the
@@ -75,8 +74,8 @@ describe the functionality and data model(s) being proposed. The more effort
you put into writing a feature request, the better its chance is of being
implemented. Overly broad feature requests will be closed.
* When submitting a feature request on GitHub, be sure to include the
following:
* When submitting a feature request on GitHub, be sure to include all
information requested by the issue template, including:
* A detailed description of the proposed functionality
* A use case for the feature; who would use it and what value it would add
@@ -89,6 +88,9 @@ following:
title. The issue will be reviewed by a moderator after submission and the
appropriate labels will be applied for categorization.
* For more information on how feature requests are handled, please see our
[issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
## Submitting Pull Requests
* Be sure to open an issue **before** starting work on a pull request, and
@@ -99,9 +101,11 @@ any work that's already in progress.
* Any pull request which does _not_ relate to an accepted issue will be closed.
* All major new functionality must include relevant tests where applicable.
* When submitting a pull request, please be sure to work off of the `develop`
branch, rather than `master`. The `develop` branch is used for ongoing
development, while `master` is used for tagging new stable releases.
development, while `master` is used for tagging stable releases.
* All code submissions should meet the following criteria (CI will enforce
these checks):
@@ -118,6 +122,29 @@ feedback. **Do not** comment on an issue just to show your support (give the
top post a :+1: instead) or ask for an ETA. These comments will be deleted to
reduce noise in the discussion.
## Issue Lifecycle
New issues are handled according to our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy).
Maintainers will assign label(s) and/or close new issues as the policy
dictates. This helps ensure a productive development environment and avoid
accumulating a large backlog of work.
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
to aid in issue management.
* Issues will be marked as stale after 14 days of no activity.
* Then after 7 more days of inactivity, the issue will be closed.
* Any issue bearing one of the following labels will be exempt from all Stale
bot actions:
* `status: accepted`
* `status: gathering feedback`
* `status: blocked`
It is natural that some new issues get more attention than others. Often this
is a metric of an issues's overall value to the project. In other cases in
which issues merely get lost in the shuffle, notifications from Stale bot can
bring renewed attention to potentially meaningful issues.
## Maintainer Guidance
* Maintainers are expected to contribute at least four hours per week to the

View File

@@ -1,9 +1,10 @@
![NetBox](docs/netbox_logo.png "NetBox logo")
![NetBox](docs/netbox_logo.svg "NetBox logo")
NetBox is an IP address management (IPAM) and data center infrastructure
management (DCIM) tool. Initially conceived by the network engineering team at
[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically
to address the needs of network and infrastructure engineers.
to address the needs of network and infrastructure engineers. It is intended to
function as a domain-specific source of truth for network operations.
NetBox runs as a web application atop the [Django](https://www.djangoproject.com/)
Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a
@@ -35,12 +36,14 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
and run `upgrade.sh`.
## Alternative Installations
# Providing Feedback
* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
Feature requests and bug reports must be submitted as GiHub issues. (Please be
sure to use the [appropriate template](https://github.com/netbox-community/netbox/issues/new/choose).)
For general discussion, please consider joining our [mailing list](https://groups.google.com/forum/#!forum/netbox-discuss).
If you are interested in contributing to the development of NetBox, please read
our [contributing guide](CONTRIBUTING.md) prior to beginning any work.
# Related projects

View File

@@ -71,6 +71,18 @@ The checkbox to commit database changes when executing a script is checked by de
commit_default = False
```
## Accessing Request Data
Details of the current HTTP request (the one being made to execute the script) are available as the instance attribute `self.request`. This can be used to infer, for example, the user executing the script and the client IP address:
```python
username = self.request.user.username
ip_address = self.request.META.get('HTTP_X_FORWARDED_FOR') or self.request.META.get('REMOTE_ADDR')
self.log_info("Running as user {} (IP: {})...".format(username, ip_address))
```
For a complete list of available request parameters, please see the [Django documentation](https://docs.djangoproject.com/en/stable/ref/request-response/).
## Reading Data from Files
The Script class provides two convenience methods for reading data from files:
@@ -119,6 +131,23 @@ Stored a numeric integer. Options include:
A true/false flag. This field has no options beyond the defaults.
### ChoiceVar
A set of choices from which the user can select one.
* `choices` - A list of `(value, label)` tuples representing the available choices. For example:
```python
CHOICES = (
('n', 'North'),
('s', 'South'),
('e', 'East'),
('w', 'West')
)
direction = ChoiceVar(choices=CHOICES)
```
### ObjectVar
A NetBox object. The list of available objects is defined by the queryset parameter. Each instance of this variable is limited to a single object type.
@@ -165,7 +194,7 @@ class NewBranchScript(Script):
class Meta:
name = "New Branch"
description = "Provision a new branch site"
fields = ['site_name', 'switch_count', 'switch_model']
field_order = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar(
description="Name of the new site"

View File

@@ -4,7 +4,7 @@ NetBox allows users to define custom templates that can be used when exporting o
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list.
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/1.9/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
Export templates are written in [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2. The list of objects returned from the database is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
```
{% for rack in queryset %}

View File

@@ -0,0 +1,65 @@
# NAPALM
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API.
!!! info
To enable the integration, the NAPALM library must be installed. See [installation steps](../../installation/2-netbox/#napalm-automation-optional) for more information.
```
GET /api/dcim/devices/1/napalm/?method=get_environment
{
"get_environment": {
...
}
}
```
## Authentication
By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#napalm_password) are used for NAPALM authentication. They can be overridden for an individual API call through the `X-NAPALM-Username` and `X-NAPALM-Password` headers.
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-Username: foo" \
-H "X-NAPALM-Password: bar"
```
## Method Support
The list of supported NAPALM methods depends on the [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html#general-support-matrix) configured for the platform of a device. NetBox only supports [get](https://napalm.readthedocs.io/en/latest/support/index.html#getters-support-matrix) methods.
## Multiple Methods
More than one method in an API call can be invoked by adding multiple `method` parameters. For example:
```
GET /api/dcim/devices/1/napalm/?method=get_ntp_servers&method=get_ntp_peers
{
"get_ntp_servers": {
...
},
"get_ntp_peers": {
...
}
}
```
## Optional Arguments
The behavior of NAPALM drivers can be adjusted according to the [optional arguments](https://napalm.readthedocs.io/en/latest/support/index.html#optional-arguments). NetBox exposes those arguments using headers prefixed with `X-NAPALM-`.
For instance, the SSH port is changed to 2222 in this API call:
```
$ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \
-H "Authorization: Token f4b378553dacfcfd44c5a0b9ae49b57e29c552b5" \
-H "Content-Type: application/json" \
-H "Accept: application/json; indent=4" \
-H "X-NAPALM-port: 2222"
```

View File

@@ -11,8 +11,10 @@ The webhook POST request is structured as so (assuming `application/json` as the
```no-highlight
{
"event": "created",
"signal_received_timestamp": 1508769597,
"model": "Site"
"timestamp": "2019-10-12 12:51:29.746944",
"username": "admin",
"model": "site",
"request_id": "43d8e212-94c7-4f67-b544-0dcde4fc0f43",
"data": {
...
}
@@ -24,8 +26,10 @@ The webhook POST request is structured as so (assuming `application/json` as the
```no-highlight
{
"event": "deleted",
"signal_received_timestamp": 1508781858.544069,
"model": "Site",
"timestamp": "2019-10-12 12:55:44.030750",
"username": "johnsmith",
"model": "site",
"request_id": "e9bb83b2-ebe4-4346-b13f-07144b1a00b4",
"data": {
"asn": None,
"comments": "",

View File

@@ -4,7 +4,7 @@ NetBox includes a Python shell within which objects can be directly queried, cre
./manage.py nbshell
```
This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/dev/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
This will launch a customized version of [the built-in Django shell](https://docs.djangoproject.com/en/stable/ref/django-admin/#shell) with all relevant NetBox models pre-loaded. (If desired, the stock Django shell is also available by executing `./manage.py shell`.)
```
$ ./manage.py nbshell
@@ -28,7 +28,7 @@ DCIM:
## Querying Objects
Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/dev/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `<model>.objects.all()`, which will return a (truncated) list of all objects of that type.
Objects are retrieved by forming a [Django queryset](https://docs.djangoproject.com/en/stable/topics/db/queries/#retrieving-objects). The base queryset for an object takes the form `<model>.objects.all()`, which will return a (truncated) list of all objects of that type.
```
>>> Device.objects.all()
@@ -99,7 +99,7 @@ This approach can span multiple levels of relations. For example, the following
```
!!! note
While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/dev/ref/models/querysets/) documentation.
While the above query is functional, it is very inefficient. There are ways to optimize such requests, however they are out of the scope of this document. For more information, see the [Django queryset method reference](https://docs.djangoproject.com/en/stable/ref/models/querysets/) documentation.
Reverse relationships can be traversed as well. For example, the following will find all devices with an interface named "em0":
@@ -137,7 +137,7 @@ To return the inverse of a filtered queryset, use `exclude()` instead of `filter
```
!!! info
The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/dev/ref/models/querysets/).
The examples above are intended only to provide a cursory introduction to queryset filtering. For an exhaustive list of the available filters, please consult the [Django queryset API docs](https://docs.djangoproject.com/en/stable/ref/models/querysets/).
## Creating and Updating Objects

View File

@@ -39,6 +39,11 @@ If you want to export only the database schema, and not the data itself (e.g. fo
```no-highlight
pg_dump -s netbox > netbox_schema.sql
```
If you are migrating your instance of NetBox to a different machine, please make sure you invalidate the cache by performing this command:
```no-highlight
python3 manage.py invalidate all
```
---

View File

@@ -139,7 +139,7 @@ Enforcement of unique IP space can be toggled on a per-VRF basis. To enforce uni
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if `DEBUG` is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in `ADMINS`.
The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/1.11/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
The Django framework on which NetBox runs allows for the customization of logging, e.g. to write logs to file. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a file:
```
LOGGING = {
@@ -311,7 +311,7 @@ Enable this option to run the webhook backend. See the docs section on the webho
## Date and Time Formatting
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date).
You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date).
Defaults:

View File

@@ -2,7 +2,7 @@
## ALLOWED_HOSTS
This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/1.9/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/1.9/ref/settings/#allowed-hosts)).
This is a list of valid fully-qualified domain names (FQDNs) that is used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different (e.g. when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server). NetBox will not permit access to the server via any other hostnames (or IPs). The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts `HTTP POST` to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, has `USE_X_FORWARDED_HOST = True` (in `netbox/netbox/settings.py`) which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)).
Example:
@@ -21,6 +21,7 @@ NetBox requires access to a PostgreSQL database service to store data. This serv
* `PASSWORD` - PostgreSQL password
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (5432)
* `CONN_MAX_AGE` - Number in seconds for Netbox to keep database connections open. 150-300 seconds is typically a good starting point ([more info](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections)).
Example:
@@ -31,6 +32,7 @@ DATABASE = {
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
}
```
@@ -69,7 +71,7 @@ REDIS = {
!!! note:
If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but
an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting.
!!! warning:
It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook

View File

@@ -69,6 +69,14 @@ If the new field will be included in the object list view, add a column to the m
Edit the object's view template to display the new field. There may also be a custom add/edit form template that needs to be updated.
### 11. Adjust API and model tests
### 11. Create/extend test cases
Extend the model and/or API tests to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields.
Create or extend the relevant test cases to verify that the new field and any accompanying validation logic perform as expected. This is especially important for relational fields. NetBox incorporates various test suites, including:
* API serializer/view tests
* Filter tests
* Form tests
* Model tests
* View tests
Be diligent to ensure all of the relevant test suites are adapted or extended as necessary to test any new functionality.

View File

@@ -1,6 +1,6 @@
# Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/dev/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
## PEP 8 Exceptions

View File

@@ -1,4 +1,4 @@
![NetBox](netbox_logo.png "NetBox logo")
![NetBox](netbox_logo.svg "NetBox logo")
# What is NetBox?

View File

@@ -14,7 +14,7 @@ This section of the documentation discusses installing and configuring the NetBo
# yum install -y epel-release
# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis
# easy_install-3.6 pip
# ln -s /usr/bin/python36 /usr/bin/python3
# ln -s /usr/bin/python3.6 /usr/bin/python3
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
@@ -129,6 +129,7 @@ DATABASE = {
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
}
```

View File

@@ -32,7 +32,6 @@ server {
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
}
}
```
@@ -108,7 +107,7 @@ Install gunicorn:
# pip3 install gunicorn
```
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`.
Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. More info on `max_requests` can be found in the [gunicorn docs](https://docs.gunicorn.org/en/stable/settings.html#max-requests).
```no-highlight
command = '/usr/bin/gunicorn'
@@ -116,6 +115,8 @@ pythonpath = '/opt/netbox/netbox'
bind = '127.0.0.1:8001'
workers = 3
user = 'www-data'
max_requests = 5000
max_requests_jitter = 500
```
# supervisord Installation

View File

@@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = {
```
# User Groups for Permissions
!!! info
When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` .
@@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
* `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions.
* `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions.
!!! warning
Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory.
# Troubleshooting LDAP
`supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`.

21
docs/netbox_logo.svg Normal file
View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 320">
<g fill="#9cc8f8" stroke="#9cc8f8">
<circle cx="37" cy="284" r="23"/>
<circle cx="101" cy="37" r="23"/>
<circle cx="101" cy="220" r="23"/>
<circle cx="284" cy="220" r="23"/>
<rect x="93" y="37" width="16" height="180"/>
<rect x="101" y="212" width="180" height="16"/>
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
</g>
<g fill="#1685fc" stroke="#1685fc">
<circle cx="284" cy="37" r="23"/>
<circle cx="37" cy="101" r="23"/>
<circle cx="220" cy="101" r="23"/>
<circle cx="220" cy="284" r="23"/>
<rect x="37" y="93" width="180" height="16"/>
<rect x="212" y="101" width="16" height="180"/>
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
<path transform="translate(380, 8)" d="M13.60 200L13.60 104L36.40 104L36.40 119.40L36.80 119.40Q40.20 112.20 47.20 106.90Q54.20 101.60 66.20 101.60L66.20 101.60Q75.80 101.60 82.50 104.80Q89.20 108 93.40 113.20Q97.60 118.40 99.40 125.20Q101.20 132 101.20 139.40L101.20 139.40L101.20 200L77.20 200L77.20 151.40Q77.20 147.40 76.80 142.50Q76.40 137.60 74.70 133.30Q73 129 69.40 126.10Q65.80 123.20 59.60 123.20L59.60 123.20Q53.60 123.20 49.50 125.20Q45.40 127.20 42.70 130.60Q40 134 38.80 138.40Q37.60 142.80 37.60 147.60L37.60 147.60L37.60 200L13.60 200ZM224.80 160.40L151.60 160.40Q152.80 171.20 160 177.20Q167.20 183.20 177.40 183.20L177.40 183.20Q186.40 183.20 192.50 179.50Q198.60 175.80 203.20 170.20L203.20 170.20L220.40 183.20Q212 193.60 201.60 198Q191.20 202.40 179.80 202.40L179.80 202.40Q169 202.40 159.40 198.80Q149.80 195.20 142.80 188.60Q135.80 182 131.70 172.70Q127.60 163.40 127.60 152L127.60 152Q127.60 140.60 131.70 131.30Q135.80 122 142.80 115.40Q149.80 108.80 159.40 105.20Q169 101.60 179.80 101.60L179.80 101.60Q189.80 101.60 198.10 105.10Q206.40 108.60 212.30 115.20Q218.20 121.80 221.50 131.50Q224.80 141.20 224.80 153.80L224.80 153.80L224.80 160.40ZM151.60 142.40L200.80 142.40Q200.60 131.80 194.20 125.70Q187.80 119.60 176.40 119.60L176.40 119.60Q165.60 119.60 159.30 125.80Q153 132 151.60 142.40L151.60 142.40ZM259.80 124.40L240.00 124.40L240.00 104L259.80 104L259.80 76.20L283.80 76.20L283.80 104L310.20 104L310.20 124.40L283.80 124.40L283.80 166.40Q283.80 173.60 286.50 177.80Q289.20 182 297.20 182L297.20 182Q300.40 182 304.20 181.30Q308 180.60 310.20 179L310.20 179L310.20 199.20Q306.40 201 300.90 201.70Q295.40 202.40 291.20 202.40L291.20 202.40Q281.60 202.40 275.50 200.30Q269.40 198.20 265.90 193.90Q262.40 189.60 261.10 183.20Q259.80 176.80 259.80 168.40L259.80 168.40L259.80 124.40ZM333.20 200L333.20 48.80L357.20 48.80L357.20 116.20L357.80 116.20Q359.60 113.80 362.40 111.30Q365.20 108.80 369.20 106.60Q373.20 104.40 378.40 103Q383.60 101.60 390.40 101.60L390.40 101.60Q400.60 101.60 409.20 105.50Q417.80 109.40 423.90 116.20Q430 123 433.40 132.20Q436.80 141.40 436.80 152L436.80 152Q436.80 162.60 433.60 171.80Q430.40 181 424.20 187.80Q418 194.60 409.20 198.50Q400.40 202.40 389.40 202.40L389.40 202.40Q379.20 202.40 370.40 198.40Q361.60 194.40 356.40 185.60L356.40 185.60L356 185.60L356 200L333.20 200ZM412.80 152L412.80 152Q412.80 146.40 410.90 141.20Q409 136 405.30 132Q401.60 128 396.40 125.60Q391.20 123.20 384.60 123.20L384.60 123.20Q378 123.20 372.80 125.60Q367.60 128 363.90 132Q360.20 136 358.30 141.20Q356.40 146.40 356.40 152L356.40 152Q356.40 157.60 358.30 162.80Q360.20 168 363.90 172Q367.60 176 372.80 178.40Q378 180.80 384.60 180.80L384.60 180.80Q391.20 180.80 396.40 178.40Q401.60 176 405.30 172Q409 168 410.90 162.80Q412.80 157.60 412.80 152ZM458.40 152L458.40 152Q458.40 140.60 462.50 131.30Q466.60 122 473.60 115.40Q480.60 108.80 490.20 105.20Q499.80 101.60 510.60 101.60L510.60 101.60Q521.40 101.60 531 105.20Q540.60 108.80 547.60 115.40Q554.60 122 558.70 131.30Q562.80 140.60 562.80 152L562.80 152Q562.80 163.40 558.70 172.70Q554.60 182 547.60 188.60Q540.60 195.20 531 198.80Q521.40 202.40 510.60 202.40L510.60 202.40Q499.80 202.40 490.20 198.80Q480.60 195.20 473.60 188.60Q466.60 182 462.50 172.70Q458.40 163.40 458.40 152ZM482.40 152L482.40 152Q482.40 157.60 484.30 162.80Q486.20 168 489.90 172Q493.60 176 498.80 178.40Q504 180.80 510.60 180.80L510.60 180.80Q517.20 180.80 522.40 178.40Q527.60 176 531.30 172Q535 168 536.90 162.80Q538.80 157.60 538.80 152L538.80 152Q538.80 146.40 536.90 141.20Q535 136 531.30 132Q527.60 128 522.40 125.60Q517.20 123.20 510.60 123.20L510.60 123.20Q504 123.20 498.80 125.60Q493.60 128 489.90 132Q486.20 136 484.30 141.20Q482.40 146.40 482.40 152ZM575.40 200L614 148.40L580.80 104L610 104L629.20 132.80L650 104L677.40 104L644.60 148.40L683.20 200L654 200L629 165.60L603.80 200L575.40 200Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -1,3 +1,140 @@
# v2.6.12 (2020-01-13)
## Enhancements
* [#1982](https://github.com/netbox-community/netbox/issues/1982) - Improved NAPALM method documentation in Swagger (OpenAPI)
* [#2050](https://github.com/netbox-community/netbox/issues/2050) - Preview image attachments when hovering over the link
* [#2113](https://github.com/netbox-community/netbox/issues/2113) - Allow NAPALM driver settings to be changed with request headers
* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Toggle the display of child prefixes/IP addresses
* [#3009](https://github.com/netbox-community/netbox/issues/3009) - Search by description when assigning IP address to interfaces
* [#3021](https://github.com/netbox-community/netbox/issues/3021) - Add `tenant` filter field for cables
* [#3090](https://github.com/netbox-community/netbox/issues/3090) - Enable filtering of interfaces by name on the device view
* [#3187](https://github.com/netbox-community/netbox/issues/3187) - Add rack selection field to rack elevations view
* [#3393](https://github.com/netbox-community/netbox/issues/3393) - Paginate assigned circuits at the provider details view
* [#3440](https://github.com/netbox-community/netbox/issues/3440) - Add total path length to cable trace
* [#3491](https://github.com/netbox-community/netbox/issues/3491) - Include content of response on webhook error
* [#3623](https://github.com/netbox-community/netbox/issues/3623) - Enable word expansion during interface creation
* [#3668](https://github.com/netbox-community/netbox/issues/3668) - Enable searching by DNS name when assigning IP address
* [#3851](https://github.com/netbox-community/netbox/issues/3851) - Allow passing initial data to custom script forms
* [#3891](https://github.com/netbox-community/netbox/issues/3891) - Add `local_context_data` filter for virtual machines
## Bug Fixes
* [#3589](https://github.com/netbox-community/netbox/issues/3589) - Fix validation on tagged VLANs of an interface
* [#3849](https://github.com/netbox-community/netbox/issues/3849) - Fix ordering of models when dumping data to JSON
* [#3853](https://github.com/netbox-community/netbox/issues/3853) - Fix device role link on config context view
* [#3856](https://github.com/netbox-community/netbox/issues/3856) - Allow filtering VM interfaces by multiple MAC addresses
* [#3857](https://github.com/netbox-community/netbox/issues/3857) - Fix rendering of grouped custom links
* [#3862](https://github.com/netbox-community/netbox/issues/3862) - Allow filtering device components by multiple device names
* [#3864](https://github.com/netbox-community/netbox/issues/3864) - Disallow /0 masks for prefixes and IP addresses
* [#3872](https://github.com/netbox-community/netbox/issues/3872) - Paginate related IPs on the IP address view
* [#3876](https://github.com/netbox-community/netbox/issues/3876) - Fix minimum/maximum value rendering for site ASN field
* [#3882](https://github.com/netbox-community/netbox/issues/3882) - Fix filtering of devices by rack group
* [#3898](https://github.com/netbox-community/netbox/issues/3898) - Fix references to deleted cables without a label
* [#3905](https://github.com/netbox-community/netbox/issues/3905) - Fix divide-by-zero on power feeds with low power values
---
# v2.6.11 (2020-01-03)
## Bug Fixes
* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression)
* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects
---
# v2.6.10 (2020-01-02)
## Enhancements
* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices
* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results
* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses
* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception
* [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts
* [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets
* [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items
* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field
* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID
## Bug Fixes
* [#3106](https://github.com/netbox-community/netbox/issues/3106) - Restrict queryset of chained fields when form validation fails
* [#3695](https://github.com/netbox-community/netbox/issues/3695) - Include A/Z termination sites for circuits in global search
* [#3712](https://github.com/netbox-community/netbox/issues/3712) - Scrolling to target (hash) did not account for the header size
* [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs
* [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices
* [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list
* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596)
---
# v2.6.9 (2019-12-16)
## Enhancements
* [#3152](https://github.com/netbox-community/netbox/issues/3152) - Include direct link to rack elevations on site view
* [#3441](https://github.com/netbox-community/netbox/issues/3441) - Move virtual machine results near devices in global search
* [#3761](https://github.com/netbox-community/netbox/issues/3761) - Added copy button for API tokens
## Bug Fixes
* [#2170](https://github.com/netbox-community/netbox/issues/2170) - Prevent the deletion of a virtual chassis when a cross-member LAG is present
* [#2358](https://github.com/netbox-community/netbox/issues/2358) - Respect custom field default values when creating objects via the REST API
* [#3749](https://github.com/netbox-community/netbox/issues/3749) - Fix exception on password change page for local users
* [#3757](https://github.com/netbox-community/netbox/issues/3757) - Fix unable to assign IP to interface
---
# v2.6.8 (2019-12-10)
## Enhancements
* [#3139](https://github.com/netbox-community/netbox/issues/3139) - Disable password change form for LDAP-authenticated users
* [#3457](https://github.com/netbox-community/netbox/issues/3457) - Display cable colors on device view
* [#3329](https://github.com/netbox-community/netbox/issues/3329) - Remove obsolete P3P policy header
* [#3663](https://github.com/netbox-community/netbox/issues/3663) - Add query filters for `created` and `last_updated` fields
* [#3722](https://github.com/netbox-community/netbox/issues/3722) - Allow the underscore character in IPAddress DNS names
## Bug Fixes
* [#3312](https://github.com/netbox-community/netbox/issues/3312) - Fix validation error when editing power cables in bulk
* [#3644](https://github.com/netbox-community/netbox/issues/3644) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
* [#3669](https://github.com/netbox-community/netbox/issues/3669) - Include `weight` field in prefix/VLAN role form
* [#3674](https://github.com/netbox-community/netbox/issues/3674) - Include comments on PowerFeed view
* [#3679](https://github.com/netbox-community/netbox/issues/3679) - Fix link for assigned ipaddress in interface page
* [#3709](https://github.com/netbox-community/netbox/issues/3709) - Prevent exception when importing an invalid cable definition
* [#3720](https://github.com/netbox-community/netbox/issues/3720) - Correctly indicate power feed terminations on cable list
* [#3724](https://github.com/netbox-community/netbox/issues/3724) - Fix API filtering of interfaces by more than one device name
* [#3725](https://github.com/netbox-community/netbox/issues/3725) - Enforce client validation for minimum service port number
---
# v2.6.7 (2019-11-01)
## Enhancements
* [#3445](https://github.com/netbox-community/netbox/issues/3445) - Add support for additional user defined headers to be added to webhook requests
* [#3499](https://github.com/netbox-community/netbox/issues/3499) - Add `ca_file_path` to Webhook model to support user supplied CA certificate verification of webhook requests
* [#3594](https://github.com/netbox-community/netbox/issues/3594) - Add ChoiceVar for custom scripts
* [#3619](https://github.com/netbox-community/netbox/issues/3619) - Add 400GE OSFP interface type
* [#3659](https://github.com/netbox-community/netbox/issues/3659) - Add filtering for objects in admin UI
## Bug Fixes
* [#3309](https://github.com/netbox-community/netbox/issues/3309) - Rewrite change logging middleware to resolve sporadic testing failures
* [#3340](https://github.com/netbox-community/netbox/issues/3340) - Add missing options to connect front ports to console ports
* [#3357](https://github.com/netbox-community/netbox/issues/3357) - Enable filter sites/devices/VMs by null region
* [#3460](https://github.com/netbox-community/netbox/issues/3460) - Extend upgrade script to validate Python dependencies
* [#3596](https://github.com/netbox-community/netbox/issues/3596) - Prevent server error when reassigning a device to a new device bay
* [#3629](https://github.com/netbox-community/netbox/issues/3629) - Use `get_lldp_neighors_detail` to validation LLDP neighbors
* [#3635](https://github.com/netbox-community/netbox/issues/3635) - Add missing cache support for the circuits app
* [#3636](https://github.com/netbox-community/netbox/issues/3636) - Add missing `rack_group` field to PowerFeed CSV export
* [#3652](https://github.com/netbox-community/netbox/issues/3652) - Limit next/previous rack by assigned rack group
---
# v2.6.6 (2019-10-10)
## Notes

View File

@@ -31,9 +31,11 @@ pages:
- Change Logging: 'additional-features/change-logging.md'
- Context Data: 'additional-features/context-data.md'
- Custom Fields: 'additional-features/custom-fields.md'
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md'
- Graphs: 'additional-features/graphs.md'
- NAPALM: 'additional-features/napalm.md'
- Prometheus Metrics: 'additional-features/prometheus-metrics.md'
- Reports: 'additional-features/reports.md'
- Tags: 'additional-features/tags.md'

View File

@@ -2,14 +2,21 @@ import django_filters
from django.db.models import Q
from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from .constants import *
from .models import Circuit, CircuitTermination, CircuitType, Provider
__all__ = (
'CircuitFilter',
'CircuitTerminationFilter',
'CircuitTypeFilter',
'ProviderFilter',
)
class ProviderFilter(CustomFieldFilterSet):
class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -18,6 +25,17 @@ class ProviderFilter(CustomFieldFilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='circuits__terminations__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='circuits__terminations__site',
queryset=Site.objects.all(),
@@ -54,7 +72,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet):
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'

View File

@@ -7,7 +7,7 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField,
FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
DatePicker, FilterChoiceField, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple
)
from .constants import *
from .models import Circuit, CircuitTermination, CircuitType, Provider
@@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -161,7 +173,6 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
]
help_texts = {
'cid': "Unique circuit ID",
'install_date': "Format: YYYY-MM-DD",
'commit_rate': "Committed rate",
}
widgets = {
@@ -172,7 +183,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldForm):
api_url="/api/circuits/circuit-types/"
),
'status': StaticSelect2(),
'install_date': DatePicker(),
}
@@ -303,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(

View File

@@ -0,0 +1,287 @@
from django.test import TestCase
from circuits.constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_OFFLINE, CIRCUIT_STATUS_PLANNED
from circuits.filters import *
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider
from dcim.models import Region, Site
class ProviderTestCase(TestCase):
queryset = Provider.objects.all()
filterset = ProviderFilter
@classmethod
def setUpTestData(cls):
providers = (
Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'),
Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'),
Provider(name='Provider 3', slug='provider-3', asn=65003, account='3456'),
Provider(name='Provider 4', slug='provider-4', asn=65004, account='4567'),
Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'),
)
Provider.objects.bulk_create(providers)
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
)
Site.objects.bulk_create(sites)
circuit_types = (
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
CircuitType(name='Test Circuit Type 2', slug='test-circuit-type-2'),
)
CircuitType.objects.bulk_create(circuit_types)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 1'),
)
Circuit.objects.bulk_create(circuits)
CircuitTermination.objects.bulk_create((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
CircuitTermination(circuit=circuits[1], site=sites[0], term_side='A', port_speed=1000),
))
def test_name(self):
params = {'name': ['Provider 1', 'Provider 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['provider-1', 'provider-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_asn(self):
params = {'asn': ['65001', '65002']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_account(self):
params = {'account': ['1234', '2345']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTypeTestCase(TestCase):
queryset = CircuitType.objects.all()
filterset = CircuitTypeFilter
@classmethod
def setUpTestData(cls):
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_id(self):
params = {'id': [self.queryset.first().pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_name(self):
params = {'name': ['Circuit Type 1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_slug(self):
params = {'slug': ['circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class CircuitTestCase(TestCase):
queryset = Circuit.objects.all()
filterset = CircuitFilter
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
)
Site.objects.bulk_create(sites)
circuit_types = (
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
CircuitType(name='Test Circuit Type 2', slug='test-circuit-type-2'),
)
CircuitType.objects.bulk_create(circuit_types)
providers = (
Provider(name='Provider 1', slug='provider-1'),
Provider(name='Provider 2', slug='provider-2'),
)
Provider.objects.bulk_create(providers)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CIRCUIT_STATUS_ACTIVE),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CIRCUIT_STATUS_ACTIVE),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CIRCUIT_STATUS_PLANNED),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CIRCUIT_STATUS_PLANNED),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CIRCUIT_STATUS_OFFLINE),
Circuit(provider=providers[1], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CIRCUIT_STATUS_OFFLINE),
)
Circuit.objects.bulk_create(circuits)
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=1000),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=1000),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
def test_cid(self):
params = {'cid': ['Test Circuit 1', 'Test Circuit 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_install_date(self):
params = {'install_date': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_commit_rate(self):
params = {'commit_rate': ['1000', '2000']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_provider(self):
provider = Provider.objects.first()
params = {'provider_id': [provider.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'provider': [provider.slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_type(self):
circuit_type = CircuitType.objects.first()
params = {'type_id': [circuit_type.pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'type': [circuit_type.slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_status(self):
params = {'status': [CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTerminationTestCase(TestCase):
queryset = CircuitTermination.objects.all()
filterset = CircuitTerminationFilter
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Test Site 1', slug='test-site-1'),
Site(name='Test Site 2', slug='test-site-2'),
Site(name='Test Site 3', slug='test-site-3'),
)
Site.objects.bulk_create(sites)
circuit_types = (
CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'),
)
CircuitType.objects.bulk_create(circuit_types)
providers = (
Provider(name='Provider 1', slug='provider-1'),
)
Provider.objects.bulk_create(providers)
circuits = (
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'),
Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'),
)
Circuit.objects.bulk_create(circuits)
circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'),
))
CircuitTermination.objects.bulk_create(circuit_terminations)
def test_term_side(self):
params = {'term_side': 'A'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_port_speed(self):
params = {'port_speed': ['1000', '2000']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_upstream_speed(self):
params = {'upstream_speed': ['1000', '2000']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_xconnect_id(self):
params = {'xconnect_id': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self):
circuits = Circuit.objects.all()[:2]
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
@@ -5,9 +6,11 @@ from django.db import transaction
from django.db.models import Count, OuterRef, Subquery
from django.shortcuts import get_object_or_404, redirect, render
from django.views.generic import View
from django_tables2 import RequestConfig
from extras.models import Graph, GRAPH_TYPE_PROVIDER
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
@@ -38,9 +41,18 @@ class ProviderView(PermissionRequiredMixin, View):
circuits = Circuit.objects.filter(provider=provider).prefetch_related('type', 'tenant', 'terminations__site')
show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists()
circuits_table = tables.CircuitTable(circuits, orderable=False)
circuits_table.columns.hide('provider')
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(circuits_table)
return render(request, 'circuits/provider.html', {
'provider': provider,
'circuits': circuits,
'circuits_table': circuits_table,
'show_graphs': show_graphs,
})

View File

@@ -370,6 +370,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
return obj.get_config_context()
class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
device = NestedDeviceSerializer()
cable = NestedCableSerializer(read_only=True)

View File

@@ -358,6 +358,17 @@ class DeviceViewSet(CustomFieldModelViewSet):
return Response(serializer.data)
@swagger_auto_schema(
manual_parameters=[
Parameter(
name='method',
in_='query',
required=True,
type=openapi.TYPE_STRING
)
],
responses={'200': serializers.DeviceNAPALMSerializer}
)
@action(detail=True, url_path='napalm')
def napalm(self, request, pk):
"""
@@ -396,13 +407,29 @@ class DeviceViewSet(CustomFieldModelViewSet):
napalm_methods = request.GET.getlist('method')
response = OrderedDict([(m, None) for m in napalm_methods])
ip_address = str(device.primary_ip.address.ip)
username = settings.NAPALM_USERNAME
password = settings.NAPALM_PASSWORD
optional_args = settings.NAPALM_ARGS.copy()
if device.platform.napalm_args is not None:
optional_args.update(device.platform.napalm_args)
# Update NAPALM parameters according to the request headers
for header in request.headers:
if header[:9].lower() != 'x-napalm-':
continue
key = header[9:]
if key.lower() == 'username':
username = request.headers[header]
elif key.lower() == 'password':
password = request.headers[header]
elif key:
optional_args[key.lower()] = request.headers[header]
d = driver(
hostname=ip_address,
username=settings.NAPALM_USERNAME,
password=settings.NAPALM_PASSWORD,
username=username,
password=password,
timeout=settings.NAPALM_TIMEOUT,
optional_args=optional_args
)

View File

@@ -1,4 +1,8 @@
# BGP ASN bounds
BGP_ASN_MIN = 1
BGP_ASN_MAX = 2**32 - 1
# Rack types
RACK_TYPE_2POST = 100
RACK_TYPE_4POST = 200
@@ -86,6 +90,7 @@ IFACE_TYPE_100GE_QSFP28 = 1600
IFACE_TYPE_200GE_CFP2 = 1650
IFACE_TYPE_200GE_QSFP56 = 1700
IFACE_TYPE_400GE_QSFP_DD = 1750
IFACE_TYPE_400GE_OSFP = 1800
# Wireless
IFACE_TYPE_80211A = 2600
IFACE_TYPE_80211G = 2610
@@ -180,6 +185,7 @@ IFACE_TYPE_CHOICES = [
[IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'],
[IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'],
[IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
[IFACE_TYPE_400GE_OSFP, 'OSFP (400GE)'],
]
],
[
@@ -382,7 +388,8 @@ CONNECTION_STATUS_CHOICES = [
# Cable endpoint types
CABLE_TERMINATION_TYPES = [
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination',
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
'circuittermination', 'powerfeed',
]
# Cable types

View File

@@ -3,14 +3,21 @@ from django.core.validators import MinValueValidator, MaxValueValidator
from django.db import models
from netaddr import AddrFormatError, EUI, mac_unix_expanded
from .constants import *
class ASNField(models.BigIntegerField):
description = "32-bit ASN field"
default_validators = [
MinValueValidator(1),
MaxValueValidator(4294967295),
MinValueValidator(BGP_ASN_MIN),
MaxValueValidator(BGP_ASN_MAX),
]
def formfield(self, **kwargs):
defaults = {'min_value': BGP_ASN_MIN, 'max_value': BGP_ASN_MAX}
defaults.update(**kwargs)
return super().formfield(**defaults)
class mac_unix_expanded_uppercase(mac_unix_expanded):
word_fmt = '%.2X'

View File

@@ -2,13 +2,13 @@ import django_filters
from django.contrib.auth.models import User
from django.db.models import Q
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES
from utilities.filters import (
MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter,
TreeNodeMultipleChoiceFilter,
MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter,
TagFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from .constants import *
@@ -21,6 +21,45 @@ from .models import (
)
__all__ = (
'CableFilter',
'ConsoleConnectionFilter',
'ConsolePortFilter',
'ConsolePortTemplateFilter',
'ConsoleServerPortFilter',
'ConsoleServerPortTemplateFilter',
'DeviceBayFilter',
'DeviceBayTemplateFilter',
'DeviceFilter',
'DeviceRoleFilter',
'DeviceTypeFilter',
'FrontPortFilter',
'FrontPortTemplateFilter',
'InterfaceConnectionFilter',
'InterfaceFilter',
'InterfaceTemplateFilter',
'InventoryItemFilter',
'ManufacturerFilter',
'PlatformFilter',
'PowerConnectionFilter',
'PowerFeedFilter',
'PowerOutletFilter',
'PowerOutletTemplateFilter',
'PowerPanelFilter',
'PowerPortFilter',
'PowerPortTemplateFilter',
'RackFilter',
'RackGroupFilter',
'RackReservationFilter',
'RackRoleFilter',
'RearPortFilter',
'RearPortTemplateFilter',
'RegionFilter',
'SiteFilter',
'VirtualChassisFilter',
)
class RegionFilter(NameSlugSearchFilterSet):
parent_id = django_filters.ModelMultipleChoiceFilter(
queryset=Region.objects.all(),
@@ -38,7 +77,7 @@ class RegionFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -93,6 +132,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet):
class RackGroupFilter(NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -116,7 +166,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'color']
class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -125,6 +175,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -251,7 +312,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class DeviceTypeFilter(CustomFieldFilterSet):
class DeviceTypeFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -423,7 +484,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet):
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -624,7 +685,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
queryset=Device.objects.all(),
label='Device (ID)',
)
device = django_filters.ModelChoiceFilter(
device = django_filters.ModelMultipleChoiceFilter(
field_name='device__name',
queryset=Device.objects.all(),
to_field_name='name',
label='Device (name)',
@@ -696,7 +758,7 @@ class InterfaceFilter(django_filters.FilterSet):
method='search',
label='Search',
)
device = django_filters.CharFilter(
device = MultiValueCharFilter(
method='filter_device',
field_name='name',
label='Device',
@@ -749,8 +811,10 @@ class InterfaceFilter(django_filters.FilterSet):
def filter_device(self, queryset, name, value):
try:
device = Device.objects.get(**{name: value})
vc_interface_ids = device.vc_interfaces.values_list('id', flat=True)
devices = Device.objects.filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist:
return queryset.none()
@@ -829,6 +893,28 @@ class InventoryItemFilter(DeviceComponentFilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='device__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='device__site',
queryset=Site.objects.all(),
label='Site (ID)',
)
site = django_filters.ModelMultipleChoiceFilter(
field_name='device__site__slug',
queryset=Site.objects.all(),
to_field_name='slug',
label='Site name (slug)',
)
device_id = django_filters.ModelChoiceFilter(
queryset=Device.objects.all(),
label='Device (ID)',
@@ -866,8 +952,8 @@ class InventoryItemFilter(DeviceComponentFilterSet):
qs_filter = (
Q(name__icontains=value) |
Q(part_id__icontains=value) |
Q(serial__iexact=value) |
Q(asset_tag__iexact=value) |
Q(serial__icontains=value) |
Q(asset_tag__icontains=value) |
Q(description__icontains=value)
)
return queryset.filter(qs_filter)
@@ -878,6 +964,17 @@ class VirtualChassisFilter(django_filters.FilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='master__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='master__site',
queryset=Site.objects.all(),
@@ -933,7 +1030,7 @@ class CableFilter(django_filters.FilterSet):
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueNumberFilter(
device = MultiValueCharFilter(
method='filter_device',
field_name='device__name'
)
@@ -953,6 +1050,14 @@ class CableFilter(django_filters.FilterSet):
method='filter_device',
field_name='device__site__slug'
)
tenant_id = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant_id'
)
tenant = MultiValueNumberFilter(
method='filter_device',
field_name='device__tenant__slug'
)
class Meta:
model = Cable
@@ -976,9 +1081,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@@ -991,11 +1099,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet):
return queryset.filter(connected_endpoint__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(connected_endpoint__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'connected_endpoint__{}__in'.format(name): value})
)
@@ -1004,9 +1112,12 @@ class PowerConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@@ -1019,11 +1130,11 @@ class PowerConnectionFilter(django_filters.FilterSet):
return queryset.filter(_connected_poweroutlet__device__site__slug=value)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(_connected_poweroutlet__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_poweroutlet__{}__in'.format(name): value})
)
@@ -1032,9 +1143,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
method='filter_site',
label='Site (slug)',
)
device = django_filters.CharFilter(
device_id = MultiValueNumberFilter(
method='filter_device'
)
device = MultiValueCharFilter(
method='filter_device',
label='Device',
field_name='device__name'
)
class Meta:
@@ -1050,11 +1164,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet):
)
def filter_device(self, queryset, name, value):
if not value.strip():
if not value:
return queryset
return queryset.filter(
Q(device__name__icontains=value) |
Q(_connected_interface__device__name__icontains=value)
Q(**{'{}__in'.format(name): value}) |
Q(**{'_connected_interface__{}__in'.format(name): value})
)
@@ -1067,6 +1181,17 @@ class PowerPanelFilter(django_filters.FilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -1096,7 +1221,7 @@ class PowerPanelFilter(django_filters.FilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilter(CustomFieldFilterSet):
class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -1105,6 +1230,17 @@ class PowerFeedFilter(CustomFieldFilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
field_name='power_panel__site',
queryset=Site.objects.all(),

View File

@@ -74,6 +74,17 @@ class InterfaceCommonForm:
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED_ALL:
self.cleaned_data['tagged_vlans'] = []
# Validate tagged VLANs; must be a global VLAN or in the same site
elif self.cleaned_data['mode'] == IFACE_MODE_TAGGED:
valid_sites = [None, self.cleaned_data['device'].site]
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
if invalid_vlans:
raise forms.ValidationError({
'tagged_vlans': "The tagged VLANs ({}) must belong to the same site as the interface's parent "
"device/VM, or they must be global".format(', '.join(invalid_vlans))
})
class BulkRenameForm(forms.Form):
"""
@@ -281,8 +292,8 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
)
)
asn = forms.IntegerField(
min_value=1,
max_value=4294967295,
min_value=BGP_ASN_MIN,
max_value=BGP_ASN_MAX,
required=False,
label='ASN'
)
@@ -364,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm):
class RackGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -635,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Rack
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -651,16 +686,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
}
)
)
group_id = ChainedModelChoiceField(
label='Rack group',
queryset=RackGroup.objects.prefetch_related('site'),
chains=(
('site', 'site'),
group_id = FilterChoiceField(
queryset=RackGroup.objects.prefetch_related(
'site'
),
required=False,
label='Rack group',
null_label='-- None --',
widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/",
null_option=True,
null_option=True
)
)
status = forms.MultipleChoiceField(
@@ -680,6 +714,34 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
)
#
# Rack elevations
#
class RackElevationFilterForm(RackFilterForm):
field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant']
id = ChainedModelChoiceField(
queryset=Rack.objects.all(),
label='Rack',
chains=(
('site', 'site'),
('group_id', 'group_id'),
),
required=False,
widget=APISelectMultiple(
api_url='/api/dcim/racks/',
display_field='display_name',
)
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Filter the rack field based on the site and group
self.fields['site'].widget.add_filter_for('id', 'site')
self.fields['group_id'].widget.add_filter_for('id', 'group_id')
#
# Rack reservations
#
@@ -1339,7 +1401,8 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
widget=APISelect(
api_url="/api/dcim/manufacturers/",
filter_for={
'device_type': 'manufacturer_id'
'device_type': 'manufacturer_id',
'platform': 'manufacturer_id'
}
)
)
@@ -1408,7 +1471,10 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
),
'status': StaticSelect2(),
'platform': APISelect(
api_url="/api/dcim/platforms/"
api_url="/api/dcim/platforms/",
additional_query_params={
"manufacturer_id": "null"
}
),
'primary_ip4': StaticSelect2(),
'primary_ip6': StaticSelect2(),
@@ -1764,7 +1830,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
widget=APISelectMultiple(
api_url="/api/dcim/rack-groups/",
filter_for={
'rack_id': 'rack_group_id',
'rack_id': 'group_id',
}
)
)
@@ -2223,36 +2289,6 @@ class InterfaceForm(InterfaceCommonForm, BootstrapMixin, forms.ModelForm):
device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG
)
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
site = getattr(self.instance.parent, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
name_pattern = ExpandableNameField(
@@ -2333,36 +2369,6 @@ class InterfaceCreateForm(InterfaceCommonForm, ComponentForm, forms.Form):
else:
self.fields['lag'].queryset = Interface.objects.none()
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
site = getattr(self.parent, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
pk = forms.ModelMultipleChoiceField(
@@ -2445,36 +2451,6 @@ class InterfaceBulkEditForm(InterfaceCommonForm, BootstrapMixin, AddRemoveTagsFo
else:
self.fields['lag'].choices = []
# Limit VLan choices to those in: global vlans, global groups, the current site's group, the current site
vlan_choices = []
global_vlans = VLAN.objects.filter(site=None, group=None)
vlan_choices.append(
('Global', [(vlan.pk, vlan) for vlan in global_vlans])
)
for group in VLANGroup.objects.filter(site=None):
global_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append(
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
)
if self.parent_obj is not None:
site = getattr(self.parent_obj, 'site', None)
if site is not None:
# Add non-grouped site VLANs
site_vlans = VLAN.objects.filter(site=site, group=None)
vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs
for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group)
vlan_choices.append((
'{} / {}'.format(group.site.name, group.name),
[(vlan.pk, vlan) for vlan in site_group_vlans]
))
self.fields['untagged_vlan'].choices = [(None, '---------')] + vlan_choices
self.fields['tagged_vlans'].choices = vlan_choices
class InterfaceBulkRenameForm(BulkRenameForm):
pk = forms.ModelMultipleChoiceField(
@@ -2828,6 +2804,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, f
termination_b_provider = forms.ModelChoiceField(
queryset=Provider.objects.all(),
label='Provider',
required=False,
widget=APISelect(
api_url='/api/circuits/providers/',
filter_for={
@@ -2881,6 +2858,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
termination_b_site = forms.ModelChoiceField(
queryset=Site.objects.all(),
label='Site',
required=False,
widget=APISelect(
api_url='/api/dcim/sites/',
display_field='cid',
@@ -2912,6 +2890,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.Mode
('rack_group', 'termination_b_rackgroup'),
),
label='Power Panel',
required=False,
widget=APISelect(
api_url='/api/dcim/power-panels/',
filter_for={
@@ -3143,6 +3122,17 @@ class CableFilterForm(BootstrapMixin, forms.Form):
}
)
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/tenancy/tenants/",
value_field='slug',
filter_for={
'device_id': 'tenant',
}
)
)
rack_id = FilterChoiceField(
queryset=Rack.objects.all(),
label='Rack',
@@ -3167,9 +3157,13 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False,
widget=ColorSelect()
)
device = forms.CharField(
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device name'
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
)
@@ -3234,38 +3228,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm):
#
class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
site = FilterChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
device = forms.CharField(
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device name'
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
)
class PowerConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
site = FilterChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
device = forms.CharField(
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device name'
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
)
class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form):
site = forms.ModelChoiceField(
site = FilterChoiceField(
queryset=Site.objects.all(),
required=False,
to_field_name='slug'
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
)
)
device = forms.CharField(
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device name'
label='Device',
widget=APISelectMultiple(
api_url='/api/dcim/devices/',
)
)
@@ -3281,9 +3296,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = InventoryItem
fields = [
'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags',
]
widgets = {
'device': APISelect(
api_url="/api/dcim/devices/"
),
'manufacturer': APISelect(
api_url="/api/dcim/manufacturers/"
)
@@ -3319,9 +3337,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm):
queryset=InventoryItem.objects.all(),
widget=forms.MultipleHiddenInput()
)
device = forms.ModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
api_url="/api/dcim/devices/"
)
)
manufacturer = forms.ModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False
required=False,
widget=APISelect(
api_url="/api/dcim/manufacturers/"
)
)
part_id = forms.CharField(
max_length=50,
@@ -3345,18 +3373,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
required=False,
label='Search'
)
device = forms.CharField(
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
label='Device name'
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
widget=APISelectMultiple(
api_url="/api/dcim/sites/",
value_field="slug",
filter_for={
'device_id': 'site'
}
)
)
device_id = FilterChoiceField(
queryset=Device.objects.all(),
required=False,
label='Device',
widget=APISelect(
api_url='/api/dcim/devices/',
)
)
manufacturer = FilterChoiceField(
queryset=Manufacturer.objects.all(),
to_field_name='slug',
null_label='-- None --'
widget=APISelect(
api_url="/api/dcim/manufacturers/",
value_field="slug",
)
)
discovered = forms.NullBooleanField(
required=False,
widget=forms.Select(
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
@@ -3503,6 +3561,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -3608,6 +3678,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -3828,6 +3910,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm):
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',

View File

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

View File

@@ -1,3 +1,5 @@
import sys
from django.db import migrations, models
import django.db.models.deletion
@@ -5,14 +7,15 @@ import django.db.models.deletion
def cache_cable_devices(apps, schema_editor):
Cable = apps.get_model('dcim', 'Cable')
print("\nUpdatng cable device terminations...")
if 'test' not in sys.argv:
print("\nUpdating cable device terminations...")
cable_count = Cable.objects.count()
# Cache A/B termination devices on all existing Cables. Note that the custom save() method on Cable is not
# available during a migration, so we replicate its logic here.
for i, cable in enumerate(Cable.objects.all(), start=1):
if not i % 1000:
if not i % 1000 and 'test' not in sys.argv:
print("[{}/{}]".format(i, cable_count))
termination_a_model = apps.get_model(cable.termination_a_type.app_label, cable.termination_a_type.model)

View File

@@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import Count, Q, Sum
from django.db.models import Count, F, ProtectedError, Q, Sum
from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager
@@ -98,6 +98,8 @@ class CableTermination(models.Model):
object_id_field='termination_b_id'
)
is_path_endpoint = True
class Meta:
abstract = True
@@ -2444,6 +2446,8 @@ class FrontPort(CableTermination, ComponentModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
@@ -2506,6 +2510,8 @@ class RearPort(CableTermination, ComponentModel):
validators=[MinValueValidator(1), MaxValueValidator(64)]
)
is_path_endpoint = False
objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem)
@@ -2588,6 +2594,16 @@ class DeviceBay(ComponentModel):
if self.device == self.installed_device:
raise ValidationError("Cannot install a device into itself.")
# Check that the installed device is not already installed elsewhere
if self.installed_device:
current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first()
if current_bay and current_bay != self:
raise ValidationError({
'installed_device': "Cannot install the specified device; device is already installed in {}".format(
current_bay
)
})
#
# Inventory items
@@ -2714,6 +2730,24 @@ class VirtualChassis(ChangeLoggedModel):
'master': "The selected master is not assigned to this virtual chassis."
})
def delete(self, *args, **kwargs):
# Check for LAG interfaces split across member chassis
interfaces = Interface.objects.filter(
device__in=self.members.all(),
lag__isnull=False
).exclude(
lag__device=F('device')
)
if interfaces:
raise ProtectedError(
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
"LAG".format(self),
interfaces
)
return super().delete(*args, **kwargs)
def to_csv(self):
return (
self.master,
@@ -2721,255 +2755,6 @@ class VirtualChassis(ChangeLoggedModel):
)
#
# Cables
#
class Cable(ChangeLoggedModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.PositiveSmallIntegerField(
choices=CABLE_TYPE_CHOICES,
blank=True,
null=True
)
status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
)
label = models.CharField(
max_length=100,
blank=True
)
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
blank=True,
null=True
)
length_unit = models.PositiveSmallIntegerField(
choices=CABLE_LENGTH_UNIT_CHOICES,
blank=True,
null=True
)
# Stores the normalized length (in meters) for database ordering
_abs_length = models.DecimalField(
max_digits=10,
decimal_places=4,
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
]
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __str__(self):
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])
def clean(self):
# Validate that termination A exists
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
# A component with multiple positions must be connected to a component with an equal number of positions
term_a_positions = getattr(self.termination_a, 'positions', 1)
term_b_positions = getattr(self.termination_b, 'positions', 1)
if term_a_positions != term_b_positions:
raise ValidationError(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
self.termination_a, term_a_positions, self.termination_b, term_b_positions
)
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and self.length_unit is None:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = None
def save(self, *args, **kwargs):
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
def to_csv(self):
return (
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
self.termination_a_id,
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
self.termination_b_id,
self.get_type_display(),
self.get_status_display(),
self.label,
self.color,
self.length,
self.length_unit,
)
def get_status_class(self):
return 'success' if self.status else 'info'
def get_compatible_types(self):
"""
Return all termination types compatible with termination A.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
def get_path_endpoints(self):
"""
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
None.
"""
a_path = self.termination_b.trace()
b_path = self.termination_a.trace()
# Determine overall path status (connected or planned)
if self.status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
else:
path_status = CONNECTION_STATUS_CONNECTED
for segment in a_path[1:] + b_path[1:]:
if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
break
a_endpoint = a_path[-1][2]
b_endpoint = b_path[-1][2]
return a_endpoint, b_endpoint, path_status
#
# Power
#
@@ -3112,6 +2897,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
return (
self.power_panel.site.name,
self.power_panel.name,
self.rack.group.name if self.rack and self.rack.group else None,
self.rack.name if self.rack else None,
self.name,
self.get_status_display(),
@@ -3148,3 +2934,260 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
def get_status_class(self):
return STATUS_CLASSES[self.status]
#
# Cables
#
class Cable(ChangeLoggedModel):
"""
A physical connection between two endpoints.
"""
termination_a_type = models.ForeignKey(
to=ContentType,
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
termination_a_id = models.PositiveIntegerField()
termination_a = GenericForeignKey(
ct_field='termination_a_type',
fk_field='termination_a_id'
)
termination_b_type = models.ForeignKey(
to=ContentType,
limit_choices_to={'model__in': CABLE_TERMINATION_TYPES},
on_delete=models.PROTECT,
related_name='+'
)
termination_b_id = models.PositiveIntegerField()
termination_b = GenericForeignKey(
ct_field='termination_b_type',
fk_field='termination_b_id'
)
type = models.PositiveSmallIntegerField(
choices=CABLE_TYPE_CHOICES,
blank=True,
null=True
)
status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
default=CONNECTION_STATUS_CONNECTED
)
label = models.CharField(
max_length=100,
blank=True
)
color = ColorField(
blank=True
)
length = models.PositiveSmallIntegerField(
blank=True,
null=True
)
length_unit = models.PositiveSmallIntegerField(
choices=CABLE_LENGTH_UNIT_CHOICES,
blank=True,
null=True
)
# Stores the normalized length (in meters) for database ordering
_abs_length = models.DecimalField(
max_digits=10,
decimal_places=4,
blank=True,
null=True
)
# Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by
# their associated Devices.
_termination_a_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
_termination_b_device = models.ForeignKey(
to=Device,
on_delete=models.CASCADE,
related_name='+',
blank=True,
null=True
)
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',
'color', 'length', 'length_unit',
]
class Meta:
ordering = ['pk']
unique_together = (
('termination_a_type', 'termination_a_id'),
('termination_b_type', 'termination_b_id'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# A copy of the PK to be used by __str__ in case the object is deleted
self._pk = self.pk
def __str__(self):
return self.label or '#{}'.format(self._pk)
def get_absolute_url(self):
return reverse('dcim:cable', args=[self.pk])
def clean(self):
# Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type)
})
# Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist:
raise ValidationError({
'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type)
})
type_a = self.termination_a_type.model
type_b = self.termination_b_type.model
# Validate interface types
if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_a.get_type_display()
)
})
if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES:
raise ValidationError({
'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format(
self.termination_b.get_type_display()
)
})
# Check that termination types are compatible
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
raise ValidationError("Incompatible termination types: {} and {}".format(
self.termination_a_type, self.termination_b_type
))
# A component with multiple positions must be connected to a component with an equal number of positions
term_a_positions = getattr(self.termination_a, 'positions', 1)
term_b_positions = getattr(self.termination_b, 'positions', 1)
if term_a_positions != term_b_positions:
raise ValidationError(
"{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format(
self.termination_a, term_a_positions, self.termination_b, term_b_positions
)
)
# A termination point cannot be connected to itself
if self.termination_a == self.termination_b:
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# A front port cannot be connected to its corresponding rear port
if (
type_a in ['frontport', 'rearport'] and
type_b in ['frontport', 'rearport'] and
(
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
getattr(self.termination_b, 'rear_port', None) == self.termination_a
)
):
raise ValidationError("A front port cannot be connected to it corresponding rear port")
# Check for an existing Cable connected to either termination object
if self.termination_a.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_a, self.termination_a.cable_id
))
if self.termination_b.cable not in (None, self):
raise ValidationError("{} already has a cable attached (#{})".format(
self.termination_b, self.termination_b.cable_id
))
# Validate length and length_unit
if self.length is not None and self.length_unit is None:
raise ValidationError("Must specify a unit when setting a cable length")
elif self.length is None:
self.length_unit = None
def save(self, *args, **kwargs):
# Store the given length (if any) in meters for use in database ordering
if self.length and self.length_unit:
self._abs_length = to_meters(self.length, self.length_unit)
else:
self._abs_length = None
# Store the parent Device for the A and B terminations (if applicable) to enable filtering
if hasattr(self.termination_a, 'device'):
self._termination_a_device = self.termination_a.device
if hasattr(self.termination_b, 'device'):
self._termination_b_device = self.termination_b.device
super().save(*args, **kwargs)
# Update the private pk used in __str__ in case this is a new object (i.e. just got its pk)
self._pk = self.pk
def to_csv(self):
return (
'{}.{}'.format(self.termination_a_type.app_label, self.termination_a_type.model),
self.termination_a_id,
'{}.{}'.format(self.termination_b_type.app_label, self.termination_b_type.model),
self.termination_b_id,
self.get_type_display(),
self.get_status_display(),
self.label,
self.color,
self.length,
self.length_unit,
)
def get_status_class(self):
return 'success' if self.status else 'info'
def get_compatible_types(self):
"""
Return all termination types compatible with termination A.
"""
if self.termination_a is None:
return
return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name]
def get_path_endpoints(self):
"""
Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be
None.
"""
a_path = self.termination_b.trace()
b_path = self.termination_a.trace()
# Determine overall path status (connected or planned)
if self.status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
else:
path_status = CONNECTION_STATUS_CONNECTED
for segment in a_path[1:] + b_path[1:]:
if segment[1] is None or segment[1].status == CONNECTION_STATUS_PLANNED:
path_status = CONNECTION_STATUS_PLANNED
break
a_endpoint = a_path[-1][2]
b_endpoint = b_path[-1][2]
return a_endpoint, b_endpoint, path_status

View File

@@ -45,7 +45,7 @@ def update_connected_endpoints(instance, **kwargs):
# Check if this Cable has formed a complete path. If so, update both endpoints.
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
if endpoint_a is not None and endpoint_b is not None:
if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status
endpoint_a.save()

View File

@@ -181,8 +181,10 @@ VIRTUALCHASSIS_ACTIONS = """
CABLE_TERMINATION_PARENT = """
{% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% else %}
{% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %}
"""
@@ -718,7 +720,7 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Termination A'
)
termination_a = tables.Column(
termination_a = tables.LinkColumn(
accessor=Accessor('termination_a'),
orderable=False,
verbose_name=''
@@ -729,7 +731,7 @@ class CableTable(BaseTable):
orderable=False,
verbose_name='Termination B'
)
termination_b = tables.Column(
termination_b = tables.LinkColumn(
accessor=Accessor('termination_b'),
orderable=False,
verbose_name=''

File diff suppressed because it is too large Load Diff

View File

@@ -325,9 +325,12 @@ class CableTestCase(TestCase):
def test_cable_deletion(self):
"""
When a Cable is deleted, the `cable` field on its termination points must be nullified.
When a Cable is deleted, the `cable` field on its termination points must be nullified. The str() method
should still return the PK of the string even after being nullified.
"""
self.cable.delete()
self.assertIsNone(self.cable.pk)
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
interface2 = Interface.objects.get(pk=self.interface2.pk)

View File

@@ -388,7 +388,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
'page': page,
'total_count': total_count,
'face_id': face_id,
'filter_form': forms.RackFilterForm(request.GET),
'filter_form': forms.RackElevationFilterForm(request.GET),
})
@@ -404,8 +404,12 @@ class RackView(PermissionRequiredMixin, View):
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer')
next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name').first()
prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first()
if rack.group:
peer_racks = Rack.objects.filter(site=rack.site, group=rack.group)
else:
peer_racks = Rack.objects.filter(site=rack.site, group__isnull=True)
next_rack = peer_racks.filter(name__gt=rack.name).order_by('name').first()
prev_rack = peer_racks.filter(name__lt=rack.name).order_by('-name').first()
reservations = RackReservation.objects.filter(rack=rack)
power_feeds = PowerFeed.objects.filter(rack=rack).prefetch_related('power_panel')
@@ -1750,10 +1754,13 @@ class CableTraceView(PermissionRequiredMixin, View):
def get(self, request, model, pk):
obj = get_object_or_404(model, pk=pk)
trace = obj.trace(follow_circuits=True)
total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length])
return render(request, 'dcim/cable_trace.html', {
'obj': obj,
'trace': obj.trace(follow_circuits=True),
'trace': trace,
'total_length': total_length,
})

View File

@@ -3,7 +3,10 @@ from django.contrib import admin
from netbox.admin import admin_site
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook
from .models import (
CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook,
)
from .reports import get_report
def order_content_types(field):
@@ -40,6 +43,9 @@ class WebhookAdmin(admin.ModelAdmin):
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
'type_delete', 'ssl_verification',
]
list_filter = [
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
]
form = WebhookForm
def models(self, obj):
@@ -70,7 +76,12 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
@admin.register(CustomField, site=admin_site)
class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
list_display = [
'name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description',
]
list_filter = [
'type', 'required', 'obj_type',
]
form = CustomFieldForm
def models(self, obj):
@@ -106,7 +117,12 @@ class CustomLinkForm(forms.ModelForm):
@admin.register(CustomLink, site=admin_site)
class CustomLinkAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'group_name', 'weight']
list_display = [
'name', 'content_type', 'group_name', 'weight',
]
list_filter = [
'content_type',
]
form = CustomLinkForm
@@ -116,7 +132,12 @@ class CustomLinkAdmin(admin.ModelAdmin):
@admin.register(Graph, site=admin_site)
class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source']
list_display = [
'name', 'type', 'weight', 'source',
]
list_filter = [
'type',
]
#
@@ -139,10 +160,45 @@ class ExportTemplateForm(forms.ModelForm):
@admin.register(ExportTemplate, site=admin_site)
class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
list_display = [
'name', 'content_type', 'description', 'mime_type', 'file_extension',
]
list_filter = [
'content_type',
]
form = ExportTemplateForm
#
# Reports
#
@admin.register(ReportResult, site=admin_site)
class ReportResultAdmin(admin.ModelAdmin):
list_display = [
'report', 'active', 'created', 'user', 'passing',
]
fields = [
'report', 'user', 'passing', 'data',
]
list_filter = [
'failed',
]
readonly_fields = fields
def has_add_permission(self, request):
return False
def active(self, obj):
module, report_name = obj.report.split('.')
return True if get_report(module, report_name) else False
active.boolean = True
def passing(self, obj):
return not obj.failed
passing.boolean = True
#
# Topology maps
#

View File

@@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
def to_internal_value(self, data):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)}
custom_fields = {
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
}
for field_name, value in data.items():
@@ -97,21 +99,21 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
def __init__(self, *args, **kwargs):
def _populate_custom_fields(instance, fields):
custom_fields = {f.name: None for f in fields}
for cfv in instance.custom_field_values.all():
if cfv.field.type == CF_TYPE_SELECT:
custom_fields[cfv.field.name] = CustomFieldChoiceSerializer(cfv.value).data
instance.custom_fields = {}
for field in fields:
value = instance.cf.get(field.name)
if field.type == CF_TYPE_SELECT and value is not None:
instance.custom_fields[field.name] = CustomFieldChoiceSerializer(value).data
else:
custom_fields[cfv.field.name] = cfv.value
instance.custom_fields = custom_fields
instance.custom_fields[field.name] = value
super().__init__(*args, **kwargs)
if self.instance is not None:
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
if self.instance is not None:
# Populate CustomFieldValues for each instance from database
try:
@@ -120,6 +122,26 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
except TypeError:
_populate_custom_fields(self.instance, fields)
else:
if not hasattr(self, 'initial_data'):
self.initial_data = {}
# Populate default values
if fields and 'custom_fields' not in self.initial_data:
self.initial_data['custom_fields'] = {}
# Populate initial data using custom field default values
for field in fields:
if field.name not in self.initial_data['custom_fields'] and field.default:
if field.type == CF_TYPE_SELECT:
field_value = field.choices.get(value=field.default).pk
elif field.type == CF_TYPE_BOOLEAN:
field_value = bool(field.default)
else:
field_value = field.default
self.initial_data['custom_fields'][field.name] = field_value
def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items():

View File

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

View File

@@ -8,6 +8,20 @@ from .constants import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap
__all__ = (
'ConfigContextFilter',
'CreatedUpdatedFilterSet',
'CustomFieldFilter',
'CustomFieldFilterSet',
'ExportTemplateFilter',
'GraphFilter',
'LocalConfigContextFilter',
'ObjectChangeFilter',
'TagFilter',
'TopologyMapFilter',
)
class CustomFieldFilter(django_filters.Filter):
"""
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
@@ -241,3 +255,24 @@ class ObjectChangeFilter(django_filters.FilterSet):
Q(user_name__icontains=value) |
Q(object_repr__icontains=value)
)
class CreatedUpdatedFilterSet(django_filters.FilterSet):
created = django_filters.DateFilter()
created__gte = django_filters.DateFilter(
field_name='created',
lookup_expr='gte'
)
created__lte = django_filters.DateFilter(
field_name='created',
lookup_expr='lte'
)
last_updated = django_filters.DateTimeFilter()
last_updated__gte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='gte'
)
last_updated__lte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='lte'
)

View File

@@ -10,8 +10,8 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
CommentField, ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, StaticSelect2,
BOOLEAN_WITH_BLANK_CHOICES,
CommentField, ContentTypeSelect, DatePicker, DateTimePicker, FilterChoiceField, LaxURLField, JSONField,
SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from .constants import *
from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag
@@ -52,12 +52,12 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
else:
initial = None
field = forms.NullBooleanField(
required=cf.required, initial=initial, widget=forms.Select(choices=choices)
required=cf.required, initial=initial, widget=StaticSelect2(choices=choices)
)
# Date
elif cf.type == CF_TYPE_DATE:
field = forms.DateField(required=cf.required, initial=initial, help_text="Date format: YYYY-MM-DD")
field = forms.DateField(required=cf.required, initial=initial, widget=DatePicker())
# Select
elif cf.type == CF_TYPE_SELECT:
@@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F
default_choice = cf.choices.get(value=initial).pk
except ObjectDoesNotExist:
pass
field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice)
field = forms.TypedChoiceField(
choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2()
)
# URL
elif cf.type == CF_TYPE_URL:
@@ -388,16 +390,12 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
time_after = forms.DateTimeField(
label='After',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
widget=DateTimePicker()
)
time_before = forms.DateTimeField(
label='Before',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
widget=DateTimePicker()
)
action = forms.ChoiceField(
choices=add_blank_choice(OBJECTCHANGE_ACTION_CHOICES),

View File

@@ -1,14 +1,15 @@
import random
import threading
import uuid
from copy import deepcopy
from datetime import timedelta
from django.conf import settings
from django.db.models.signals import post_delete, post_save
from django.db.models.signals import pre_delete, post_save
from django.utils import timezone
from django.utils.functional import curry
from django_prometheus.models import model_deletes, model_inserts, model_updates
from utilities.querysets import DummyQuerySet
from .constants import *
from .models import ObjectChange
from .signals import purge_changelog
@@ -19,33 +20,34 @@ _thread_locals = threading.local()
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated
Fires when an object is created or updated.
"""
# Queue the object and a new ObjectChange for processing once the request completes
if hasattr(instance, 'to_objectchange'):
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
objectchange = instance.to_objectchange(action)
_thread_locals.changed_objects.append(
(instance, objectchange)
)
# Queue the object for processing once the request completes
action = OBJECTCHANGE_ACTION_CREATE if kwargs['created'] else OBJECTCHANGE_ACTION_UPDATE
_thread_locals.changed_objects.append(
(instance, action)
)
def _handle_deleted_object(request, sender, instance, **kwargs):
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted
Fires when an object is deleted.
"""
# Record an Object Change
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(OBJECTCHANGE_ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Cache custom fields prior to copying the instance
if hasattr(instance, 'cache_custom_fields'):
instance.cache_custom_fields()
# Enqueue webhooks
enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
# Create a copy of the object being deleted
copy = deepcopy(instance)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
# Preserve tags
if hasattr(instance, 'tags'):
copy.tags = DummyQuerySet(instance.tags.all())
# Queue the copy of the object for processing once the request completes
_thread_locals.changed_objects.append(
(copy, OBJECTCHANGE_ACTION_DELETE)
)
def purge_objectchange_cache(sender, **kwargs):
@@ -81,12 +83,9 @@ class ObjectChangeMiddleware(object):
# the same request.
request.id = uuid.uuid4()
# Signals don't include the request context, so we're currying it into the post_delete function ahead of time.
handle_deleted_object = curry(_handle_deleted_object, request)
# Connect our receivers to the post_save and post_delete signals.
post_save.connect(handle_changed_object, dispatch_uid='cache_changed_object')
post_delete.connect(handle_deleted_object, dispatch_uid='cache_deleted_object')
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
# Provide a hook for purging the change cache
purge_changelog.connect(purge_objectchange_cache)
@@ -98,22 +97,31 @@ class ObjectChangeMiddleware(object):
if not _thread_locals.changed_objects:
return response
# Create records for any cached objects that were created/updated.
for obj, objectchange in _thread_locals.changed_objects:
# Create records for any cached objects that were changed.
for instance, action in _thread_locals.changed_objects:
# Record the change
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Refresh cached custom field values
if action in [OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE]:
if hasattr(instance, 'cache_custom_fields'):
instance.cache_custom_fields()
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
objectchange = instance.to_objectchange(action)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Enqueue webhooks
enqueue_webhooks(obj, request.user, request.id, objectchange.action)
enqueue_webhooks(instance, request.user, request.id, action)
# Increment metric counters
if objectchange.action == OBJECTCHANGE_ACTION_CREATE:
model_inserts.labels(obj._meta.model_name).inc()
elif objectchange.action == OBJECTCHANGE_ACTION_UPDATE:
model_updates.labels(obj._meta.model_name).inc()
if action == OBJECTCHANGE_ACTION_CREATE:
model_inserts.labels(instance._meta.model_name).inc()
elif action == OBJECTCHANGE_ACTION_UPDATE:
model_updates.labels(instance._meta.model_name).inc()
elif action == OBJECTCHANGE_ACTION_DELETE:
model_deletes.labels(instance._meta.model_name).inc()
# Housekeeping: 1% chance of clearing out expired ObjectChanges. This applies only to requests which result in
# one or more changes being logged.

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2 on 2019-10-13 05:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0025_objectchange_time_index'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='ca_file_path',
field=models.CharField(blank=True, max_length=4096, null=True),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2 on 2019-10-13 07:06
import django.contrib.postgres.fields.jsonb
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0026_webhook_ca_file_path'),
]
operations = [
migrations.AddField(
model_name='webhook',
name='additional_headers',
field=django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True),
),
]

View File

@@ -12,12 +12,11 @@ from django.db.models import F, Q
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from jinja2 import Environment
from taggit.models import TagBase, GenericTaggedItemBase
from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.fields import ColorField
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict
from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict, render_jinja2
from .constants import *
from .querysets import ConfigContextQuerySet
@@ -70,6 +69,12 @@ class Webhook(models.Model):
default=WEBHOOK_CT_JSON,
verbose_name='HTTP content type'
)
additional_headers = JSONField(
null=True,
blank=True,
help_text="User supplied headers which should be added to the request in addition to the HTTP content type. "
"Headers are supplied as key/value pairs in a JSON object."
)
secret = models.CharField(
max_length=255,
blank=True,
@@ -86,6 +91,14 @@ class Webhook(models.Model):
verbose_name='SSL verification',
help_text="Enable SSL certificate verification. Disable with caution!"
)
ca_file_path = models.CharField(
max_length=4096,
null=True,
blank=True,
verbose_name='CA File Path',
help_text='The specific CA certificate file to use for SSL verification. '
'Leave blank to use the system defaults.'
)
class Meta:
unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',)
@@ -102,6 +115,17 @@ class Webhook(models.Model):
"You must select at least one type: create, update, and/or delete."
)
if not self.ssl_verification and self.ca_file_path:
raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.'
})
# Verify that JSON data is provided as an object
if self.additional_headers and type(self.additional_headers) is not dict:
raise ValidationError({
'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}'
})
#
# Custom fields
@@ -113,16 +137,21 @@ class CustomFieldModel(models.Model):
class Meta:
abstract = True
def cache_custom_fields(self):
"""
Cache all custom field values for this instance
"""
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
@property
def cf(self):
"""
Name-based CustomFieldValue accessor for use in templates
"""
if self._cf is None:
# Cache all custom field values for this instance
self._cf = {
field.name: value for field, value in self.get_custom_fields().items()
}
self.cache_custom_fields()
return self._cf
def get_custom_fields(self):
@@ -472,8 +501,7 @@ class ExportTemplate(models.Model):
output = template.render(Context(context))
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
template = Environment().from_string(source=self.template_code)
output = template.render(**context)
output = render_jinja2(self.template_code, context)
else:
return None
@@ -887,6 +915,13 @@ class ReportResult(models.Model):
class Meta:
ordering = ['report']
def __str__(self):
return "{} {} at {}".format(
self.report,
"passed" if not self.failed else "failed",
self.created
)
#
# Change logging

View File

@@ -24,6 +24,7 @@ from .signals import purge_changelog
__all__ = [
'BaseScript',
'BooleanVar',
'ChoiceVar',
'FileVar',
'IntegerVar',
'IPNetworkVar',
@@ -133,6 +134,27 @@ class BooleanVar(ScriptVariable):
self.field_attrs['required'] = False
class ChoiceVar(ScriptVariable):
"""
Select one of several predefined static choices, passed as a list of two-tuples. Example:
color = ChoiceVar(
choices=(
('#ff0000', 'Red'),
('#00ff00', 'Green'),
('#0000ff', 'Blue')
)
)
"""
form_field = forms.ChoiceField
def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set field choices
self.field_attrs['choices'] = choices
class ObjectVar(ScriptVariable):
"""
NetBox object representation. The provided QuerySet will determine the choices available.
@@ -213,6 +235,9 @@ class BaseScript:
# Initiate the log
self.log = []
# Declare the placeholder for the current request
self.request = None
# Grab some info about the script
self.filename = inspect.getfile(self.__class__)
self.source = inspect.getsource(self.__class__)
@@ -238,12 +263,12 @@ class BaseScript:
def run(self, data):
raise NotImplementedError("The script must define a run() method.")
def as_form(self, data=None, files=None):
def as_form(self, data=None, files=None, initial=None):
"""
Return a Django form suitable for populating the context data required to run this Script.
"""
vars = self._get_vars()
form = ScriptForm(vars, data, files, commit_default=getattr(self.Meta, 'commit_default', True))
form = ScriptForm(vars, data, files, initial=initial, commit_default=getattr(self.Meta, 'commit_default', True))
return form
@@ -315,7 +340,7 @@ def is_variable(obj):
return isinstance(obj, ScriptVariable)
def run_script(script, data, files, commit=True):
def run_script(script, data, request, commit=True):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
@@ -325,9 +350,13 @@ def run_script(script, data, files, commit=True):
end_time = None
# Add files to form data
files = request.FILES
for field_name, fileobj in files.items():
data[field_name] = fileobj
# Add the current request as a property of the script
script.request = request
try:
with transaction.atomic():
start_time = time.time()

View File

@@ -3,9 +3,9 @@ from collections import OrderedDict
from django import template
from django.contrib.contenttypes.models import ContentType
from django.utils.safestring import mark_safe
from jinja2 import Environment
from extras.models import CustomLink
from utilities.utils import render_jinja2
register = template.Library()
@@ -46,12 +46,17 @@ def custom_links(obj):
# Add non-grouped links
else:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
cl.url, link_target, cl.button_class, text_rendered
)
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_rendered = render_jinja2(cl.url, context)
link_target = ' target="_blank"' if cl.new_window else ''
template_code += LINK_BUTTON.format(
link_rendered, link_target, cl.button_class, text_rendered
)
except Exception as e:
template_code += '<a class="btn btn-sm btn-default" disabled="disabled" title="{}">' \
'<i class="fa fa-warning"></i> {}</a>\n'.format(e, cl.name)
# Add grouped links to template
for group, links in group_names.items():
@@ -59,11 +64,18 @@ def custom_links(obj):
links_rendered = []
for cl in links:
text_rendered = Environment().from_string(source=cl.text).render(**context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
try:
text_rendered = render_jinja2(cl.text, context)
if text_rendered:
link_target = ' target="_blank"' if cl.new_window else ''
link_rendered = render_jinja2(cl.url, context)
links_rendered.append(
GROUP_LINK.format(link_rendered, link_target, text_rendered)
)
except Exception as e:
links_rendered.append(
GROUP_LINK.format(cl.url, link_target, cl.text)
'<li><a disabled="disabled" title="{}"><span class="text-muted">'
'<i class="fa fa-warning"></i> {}</span></a></li>'.format(e, cl.name)
)
if links_rendered:
@@ -71,7 +83,4 @@ def custom_links(obj):
links[0].button_class, group, ''.join(links_rendered)
)
# Render template
rendered = Environment().from_string(source=template_code).render(**context)
return mark_safe(rendered)
return mark_safe(template_code)

View File

@@ -1,8 +1,11 @@
import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from rest_framework import status
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Rack, RackGroup, RackRole, Region, Site
from extras.constants import GRAPH_TYPE_SITE
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from tenancy.models import Tenant, TenantGroup
@@ -520,3 +523,68 @@ class ConfigContextTest(APITestCase):
configcontext6.sites.add(site2)
rendered_context = device.get_config_context()
self.assertEqual(rendered_context['bar'], 456)
class CreatedUpdatedFilterTest(APITestCase):
def setUp(self):
super().setUp()
self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1')
self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1')
self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000')
self.rack1 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42,
)
self.rack2 = Rack.objects.create(
site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42,
)
# change the created and last_updated of one
Rack.objects.filter(pk=self.rack2.pk).update(
last_updated=datetime.datetime(2001, 2, 3, 1, 2, 3, 4, tzinfo=timezone.utc),
created=datetime.datetime(2001, 2, 3)
)
def test_get_rack_created(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_created_gte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_created_lte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated_gte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_last_updated_lte(self):
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
self.assertEqual(response.data['count'], 1)
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)

View File

@@ -1,33 +1,57 @@
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from rest_framework import status
from dcim.models import Site
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_UPDATE, OBJECTCHANGE_ACTION_DELETE
from extras.models import ObjectChange
from extras.constants import *
from extras.models import CustomField, CustomFieldValue, ObjectChange
from utilities.testing import APITestCase
class ChangeLogTest(APITestCase):
def setUp(self):
super().setUp()
# Create a custom field on the Site model
ct = ContentType.objects.get_for_model(Site)
cf = CustomField(
type=CF_TYPE_TEXT,
name='my_field',
required=False
)
cf.save()
cf.obj_type.set([ct])
def test_create_object(self):
data = {
'name': 'Test Site 1',
'slug': 'test-site-1',
'custom_fields': {
'my_field': 'ABC'
},
'tags': [
'bar', 'foo'
],
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(ObjectChange.objects.count(), 1)
oc = ObjectChange.objects.first()
site = Site.objects.get(pk=response.data['id'])
oc = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_CREATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_update_object(self):
@@ -37,26 +61,43 @@ class ChangeLogTest(APITestCase):
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
'custom_fields': {
'my_field': 'DEF'
},
'tags': [
'abc', 'xyz'
],
}
self.assertEqual(ObjectChange.objects.count(), 0)
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.put(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(ObjectChange.objects.count(), 1)
site = Site.objects.get(pk=response.data['id'])
self.assertEqual(site.name, data['name'])
oc = ObjectChange.objects.first()
site = Site.objects.get(pk=response.data['id'])
oc = ObjectChange.objects.get(
changed_object_type=ContentType.objects.get_for_model(Site),
changed_object_id=site.pk
)
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_UPDATE)
self.assertEqual(oc.object_data['custom_fields'], data['custom_fields'])
self.assertListEqual(sorted(oc.object_data['tags']), data['tags'])
def test_delete_object(self):
site = Site(name='Test Site 1', slug='test-site-1')
site = Site(
name='Test Site 1',
slug='test-site-1'
)
site.save()
site.tags.add('foo', 'bar')
CustomFieldValue.objects.create(
field=CustomField.objects.get(name='my_field'),
obj=site,
value='ABC'
)
self.assertEqual(ObjectChange.objects.count(), 0)
@@ -70,3 +111,5 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, None)
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, OBJECTCHANGE_ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

View File

@@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
cfv = self.site.custom_field_values.get(field=self.cf_select)
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
def test_set_custom_field_defaults(self):
"""
Create a new object with no custom field data. Custom field values should be created using the custom fields'
default values.
"""
CUSTOM_FIELD_DEFAULTS = {
'magic_word': 'foobar',
'magic_number': '123',
'is_magic': 'true',
'magic_date': '2019-12-13',
'magic_url': 'http://example.com/',
'magic_choice': self.cf_select_choice1.value,
}
# Update CustomFields to set default values
for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
CustomField.objects.filter(name=field_name).update(default=default_value)
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
class CustomFieldChoiceAPITest(APITestCase):
def setUp(self):

View File

@@ -0,0 +1,181 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import DeviceRole, Platform, Region, Site
from extras.constants import *
from extras.filters import *
from extras.models import ConfigContext, ExportTemplate, Graph
from tenancy.models import Tenant, TenantGroup
class GraphTestCase(TestCase):
queryset = Graph.objects.all()
filterset = GraphFilter
@classmethod
def setUpTestData(cls):
graphs = (
Graph(name='Graph 1', type=GRAPH_TYPE_DEVICE, source='http://example.com/1'),
Graph(name='Graph 2', type=GRAPH_TYPE_INTERFACE, source='http://example.com/2'),
Graph(name='Graph 3', type=GRAPH_TYPE_SITE, source='http://example.com/3'),
)
Graph.objects.bulk_create(graphs)
def test_name(self):
params = {'name': 'Graph 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_type(self):
params = {'type': GRAPH_TYPE_DEVICE}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
class ExportTemplateTestCase(TestCase):
queryset = ExportTemplate.objects.all()
filterset = ExportTemplateFilter
@classmethod
def setUpTestData(cls):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = (
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_language=TEMPLATE_LANGUAGE_DJANGO, template_code='TESTING'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_language=TEMPLATE_LANGUAGE_JINJA2, template_code='TESTING'),
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_language=TEMPLATE_LANGUAGE_JINJA2, template_code='TESTING'),
)
ExportTemplate.objects.bulk_create(export_templates)
def test_name(self):
params = {'name': 'Export Template 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_content_type(self):
params = {'content_type': ContentType.objects.get(model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_template_language(self):
params = {'template_language': TEMPLATE_LANGUAGE_JINJA2}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ConfigContextTestCase(TestCase):
queryset = ConfigContext.objects.all()
filterset = ConfigContextFilter
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1'),
Site(name='Test Site 2', slug='test-site-2'),
Site(name='Test Site 3', slug='test-site-3'),
)
Site.objects.bulk_create(sites)
device_roles = (
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'),
)
DeviceRole.objects.bulk_create(device_roles)
platforms = (
Platform(name='Platform 1', slug='platform-1'),
Platform(name='Platform 2', slug='platform-2'),
Platform(name='Platform 3', slug='platform-3'),
)
Platform.objects.bulk_create(platforms)
tenant_groups = (
TenantGroup(name='Tenant Group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant Group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant Group 3', slug='tenant-group-3'),
)
TenantGroup.objects.bulk_create(tenant_groups)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
for i in range(0, 3):
is_active = bool(i % 2)
c = ConfigContext.objects.create(
name='Config Context {}'.format(i + 1),
is_active=is_active,
data='{"foo": 123}'
)
c.regions.set([regions[i]])
c.sites.set([sites[i]])
c.roles.set([device_roles[i]])
c.platforms.set([platforms[i]])
c.tenant_groups.set([tenant_groups[i]])
c.tenants.set([tenants[i]])
def test_name(self):
params = {'name': 'Config Context 1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_is_active(self):
params = {'is_active': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'is_active': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
device_roles = DeviceRole.objects.all()[:2]
params = {'role_id': [device_roles[0].pk, device_roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'role': [device_roles[0].slug, device_roles[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_platform(self):
platforms = Platform.objects.all()[:2]
params = {'platform_id': [platforms[0].pk, platforms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'platform': [platforms[0].slug, platforms[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: ObjectChangeFilter test

View File

@@ -99,6 +99,31 @@ class ScriptVariablesTest(TestCase):
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], False)
def test_choicevar(self):
CHOICES = (
('ff0000', 'Red'),
('00ff00', 'Green'),
('0000ff', 'Blue')
)
class TestScript(Script):
var1 = ChoiceVar(
choices=CHOICES
)
# Validate valid choice
data = {'var1': CHOICES[0][0]}
form = TestScript().as_form(data)
self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], CHOICES[0][0])
# Validate invalid choices
data = {'var1': 'taupe'}
form = TestScript().as_form(data)
self.assertFalse(form.is_valid())
def test_objectvar(self):
class TestScript(Script):

View File

@@ -392,7 +392,7 @@ class ScriptView(PermissionRequiredMixin, View):
def get(self, request, module, name):
script = self._get_script(module, name)
form = script.as_form()
form = script.as_form(initial=request.GET)
return render(request, 'extras/script.html', {
'module': module,
@@ -413,7 +413,7 @@ class ScriptView(PermissionRequiredMixin, View):
if form.is_valid():
commit = form.cleaned_data.pop('_commit')
output, execution_time = run_script(script, form.cleaned_data, request.FILES, commit)
output, execution_time = run_script(script, form.cleaned_data, request, commit)
return render(request, 'extras/script.html', {
'module': module,

View File

@@ -25,6 +25,9 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
headers = {
'Content-Type': webhook.get_http_content_type_display(),
}
if webhook.additional_headers:
headers.update(webhook.additional_headers)
params = {
'method': 'POST',
'url': webhook.payload_url,
@@ -49,11 +52,13 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
with requests.Session() as session:
session.verify = webhook.ssl_verification
if webhook.ca_file_path:
session.verify = webhook.ca_file_path
response = session.send(prepared_request)
if response.status_code >= 200 and response.status_code <= 299:
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
else:
raise requests.exceptions.RequestException(
"Status {} returned, webhook FAILED to process.".format(response.status_code)
"Status {} returned with content '{}', webhook FAILED to process.".format(response.status_code, response.content)
)

View File

@@ -4,16 +4,29 @@ from django.core.exceptions import ValidationError
from django.db.models import Q
from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet
from dcim.models import Device, Interface, Region, Site
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from virtualization.models import VirtualMachine
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet):
__all__ = (
'AggregateFilter',
'IPAddressFilter',
'PrefixFilter',
'RIRFilter',
'RoleFilter',
'ServiceFilter',
'VLANFilter',
'VLANGroupFilter',
'VRFFilter',
)
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -49,7 +62,7 @@ class RIRFilter(NameSlugSearchFilterSet):
fields = ['name', 'slug', 'is_private']
class AggregateFilter(CustomFieldFilterSet):
class AggregateFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -110,7 +123,7 @@ class RoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -149,6 +162,17 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
to_field_name='rd',
label='VRF (RD)',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -247,7 +271,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -309,6 +333,10 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
queryset=Interface.objects.all(),
label='Interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface',
label='Is assigned to an interface',
)
status = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_STATUS_CHOICES,
null_value=None
@@ -366,8 +394,22 @@ class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet):
except Device.DoesNotExist:
return queryset.none()
def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value)
class VLANGroupFilter(NameSlugSearchFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -384,7 +426,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'
@@ -393,6 +435,17 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
method='search',
label='Search',
)
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
label='Region (ID)',
)
region = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='site__region__in',
to_field_name='slug',
label='Region (slug)',
)
site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(),
label='Site (ID)',
@@ -444,7 +497,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
return queryset.filter(qs_filter)
class ServiceFilter(django_filters.FilterSet):
class ServiceFilter(CreatedUpdatedFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',

View File

@@ -3,14 +3,14 @@ from django.core.exceptions import MultipleObjectsReturned
from django.core.validators import MaxValueValidator, MinValueValidator
from taggit.forms import TagField
from dcim.models import Site, Rack, Device, Interface
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField,
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
CSVChoiceField, DatePicker, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm,
SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES
)
from virtualization.models import VirtualMachine
from .constants import *
@@ -156,12 +156,12 @@ class AggregateForm(BootstrapMixin, CustomFieldForm):
help_texts = {
'prefix': "IPv4 or IPv6 network",
'rir': "Regional Internet Registry responsible for this prefix",
'date_added': "Format: YYYY-MM-DD",
}
widgets = {
'rir': APISelect(
api_url="/api/ipam/rirs/"
)
),
'date_added': DatePicker(),
}
@@ -205,6 +205,9 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
nullable_fields = [
'date_added', 'description',
]
widgets = {
'date_added': DatePicker(),
}
class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm):
@@ -240,7 +243,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
class Meta:
model = Role
fields = [
'name', 'slug',
'name', 'slug', 'weight',
]
@@ -489,8 +492,8 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = Prefix
field_order = [
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant',
'is_pool', 'expand',
'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'region', 'site', 'role', 'tenant_group',
'tenant', 'is_pool', 'expand',
]
q = forms.CharField(
required=False,
@@ -531,6 +534,18 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
required=False,
widget=StaticSelect2Multiple()
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -918,7 +933,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf = forms.ModelChoiceField(
vrf_id = forms.ModelChoiceField(
queryset=VRF.objects.all(),
required=False,
label='VRF',
@@ -927,15 +942,17 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form):
api_url="/api/ipam/vrfs/"
)
)
address = forms.CharField(
label='IP Address'
q = forms.CharField(
required=False,
label='Search',
)
class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = IPAddress
field_order = [
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant',
'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'assigned_to_interface', 'tenant_group',
'tenant',
]
q = forms.CharField(
required=False,
@@ -981,6 +998,13 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo
required=False,
widget=StaticSelect2Multiple()
)
assigned_to_interface = forms.NullBooleanField(
required=False,
label='Assigned to an interface',
widget=StaticSelect2(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#
@@ -1023,6 +1047,18 @@ class VLANGroupCSVForm(forms.ModelForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region',
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -1204,11 +1240,24 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor
class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
model = VLAN
field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant']
q = forms.CharField(
required=False,
label='Search'
)
region = FilterChoiceField(
queryset=Region.objects.all(),
to_field_name='slug',
required=False,
widget=APISelectMultiple(
api_url="/api/dcim/regions/",
value_field="slug",
filter_for={
'site': 'region',
'group_id': 'region'
}
)
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug',
@@ -1250,6 +1299,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
#
class ServiceForm(BootstrapMixin, CustomFieldForm):
port = forms.IntegerField(
min_value=1,
max_value=65535
)
tags = TagField(
required=False
)

View File

@@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='ipaddress',
name='dns_name',
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]),
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')]),
),
]

View File

@@ -177,6 +177,12 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel):
# Clear host bits from prefix
self.prefix = self.prefix.cidr
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
raise ValidationError({
'prefix': "Cannot create aggregate with /0 mask."
})
# Ensure that the aggregate being added is not covered by an existing aggregate
covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix))
if self.pk:
@@ -347,6 +353,12 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
if self.prefix:
# /0 masks are not acceptable
if self.prefix.prefixlen == 0:
raise ValidationError({
'prefix': "Cannot create prefix with /0 mask."
})
# Disallow host masks
if self.prefix.version == 4 and self.prefix.prefixlen == 32:
raise ValidationError({
@@ -622,6 +634,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
if self.address:
# /0 masks are not acceptable
if self.address.prefixlen == 0:
raise ValidationError({
'address': "Cannot create IP address with /0 mask."
})
# Enforce unique IP space (if applicable)
if self.role not in IPADDRESS_ROLES_NONUNIQUE and ((
self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE

View File

@@ -85,7 +85,11 @@ IPADDRESS_LINK = """
"""
IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
{% if request.GET %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
{% else %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
{% endif %}
"""
IPADDRESS_PARENT = """
@@ -292,7 +296,7 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = Role
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions')
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'weight', 'actions')
#
@@ -369,7 +373,7 @@ class IPAddressAssignTable(BaseTable):
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description')
orderable = False

View File

@@ -0,0 +1,645 @@
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site
from ipam.constants import *
from ipam.filters import *
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from virtualization.models import Cluster, ClusterType, VirtualMachine
class VRFTestCase(TestCase):
queryset = VRF.objects.all()
filterset = VRFFilter
@classmethod
def setUpTestData(cls):
vrfs = (
VRF(name='VRF 1', rd='65000:100', enforce_unique=False),
VRF(name='VRF 2', rd='65000:200', enforce_unique=False),
VRF(name='VRF 3', rd='65000:300', enforce_unique=False),
VRF(name='VRF 4', rd='65000:400', enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', enforce_unique=True),
VRF(name='VRF 6', rd='65000:600', enforce_unique=True),
)
VRF.objects.bulk_create(vrfs)
def test_name(self):
params = {'name': ['VRF 1', 'VRF 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rd(self):
params = {'rd': ['65000:100', '65000:200']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_enforce_unique(self):
params = {'enforce_unique': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'enforce_unique': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class RIRTestCase(TestCase):
queryset = RIR.objects.all()
filterset = RIRFilter
@classmethod
def setUpTestData(cls):
rirs = (
RIR(name='RIR 1', slug='rir-1', is_private=False),
RIR(name='RIR 2', slug='rir-2', is_private=False),
RIR(name='RIR 3', slug='rir-3', is_private=False),
RIR(name='RIR 4', slug='rir-4', is_private=True),
RIR(name='RIR 5', slug='rir-5', is_private=True),
RIR(name='RIR 6', slug='rir-6', is_private=True),
)
RIR.objects.bulk_create(rirs)
def test_name(self):
params = {'name': ['RIR 1', 'RIR 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['rir-1', 'rir-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_is_private(self):
params = {'is_private': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'is_private': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
class AggregateTestCase(TestCase):
queryset = Aggregate.objects.all()
filterset = AggregateFilter
@classmethod
def setUpTestData(cls):
rirs = (
RIR(name='RIR 1', slug='rir-1'),
RIR(name='RIR 2', slug='rir-2'),
RIR(name='RIR 3', slug='rir-3'),
)
RIR.objects.bulk_create(rirs)
aggregates = (
Aggregate(family=4, prefix='10.1.0.0/16', rir=rirs[0], date_added='2020-01-01'),
Aggregate(family=4, prefix='10.2.0.0/16', rir=rirs[0], date_added='2020-01-02'),
Aggregate(family=4, prefix='10.3.0.0/16', rir=rirs[1], date_added='2020-01-03'),
Aggregate(family=6, prefix='2001:db8:1::/48', rir=rirs[1], date_added='2020-01-04'),
Aggregate(family=6, prefix='2001:db8:2::/48', rir=rirs[2], date_added='2020-01-05'),
Aggregate(family=6, prefix='2001:db8:3::/48', rir=rirs[2], date_added='2020-01-06'),
)
Aggregate.objects.bulk_create(aggregates)
def test_family(self):
params = {'family': '4'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_date_added(self):
params = {'date_added': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Test for multiple values
def test_prefix(self):
params = {'prefix': '10.1.0.0/16'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_rir(self):
rirs = RIR.objects.all()[:2]
params = {'rir_id': [rirs[0].pk, rirs[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'rir': [rirs[0].slug, rirs[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class RoleTestCase(TestCase):
queryset = Role.objects.all()
filterset = RoleFilter
@classmethod
def setUpTestData(cls):
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'),
)
Role.objects.bulk_create(roles)
def test_id(self):
id_list = self.queryset.values_list('id', flat=True)[:2]
params = {'id': [str(id) for id in id_list]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Role 1', 'Role 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['role-1', 'role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class PrefixTestCase(TestCase):
queryset = Prefix.objects.all()
filterset = PrefixFilter
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
)
Site.objects.bulk_create(sites)
vrfs = (
VRF(name='VRF 1', rd='65000:100'),
VRF(name='VRF 2', rd='65000:200'),
VRF(name='VRF 3', rd='65000:300'),
)
VRF.objects.bulk_create(vrfs)
vlans = (
VLAN(vid=1, name='VLAN 1'),
VLAN(vid=2, name='VLAN 2'),
VLAN(vid=3, name='VLAN 3'),
)
VLAN.objects.bulk_create(vlans)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'),
)
Role.objects.bulk_create(roles)
prefixes = (
Prefix(family=4, prefix='10.0.0.0/24', site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=4, prefix='10.0.1.0/24', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=4, prefix='10.0.2.0/24', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PREFIX_STATUS_DEPRECATED),
Prefix(family=4, prefix='10.0.3.0/24', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PREFIX_STATUS_RESERVED),
Prefix(family=6, prefix='2001:db8::/64', site=None, vrf=None, vlan=None, role=None, is_pool=True),
Prefix(family=6, prefix='2001:db8:0:1::/64', site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]),
Prefix(family=6, prefix='2001:db8:0:2::/64', site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PREFIX_STATUS_DEPRECATED),
Prefix(family=6, prefix='2001:db8:0:3::/64', site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PREFIX_STATUS_RESERVED),
Prefix(family=4, prefix='10.0.0.0/16'),
Prefix(family=6, prefix='2001:db8::/32'),
)
Prefix.objects.bulk_create(prefixes)
def test_family(self):
params = {'family': '6'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_is_pool(self):
params = {'is_pool': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'is_pool': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_within(self):
params = {'within': '10.0.0.0/16'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_within_include(self):
params = {'within_include': '10.0.0.0/16'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
def test_contains(self):
params = {'contains': '10.0.1.0/24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains': '2001:db8:0:1::/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self):
params = {'mask_length': '24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vrf(self):
vrfs = VRF.objects.all()[:2]
params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vlan(self):
vlans = VLAN.objects.all()[:2]
params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# TODO: Test for multiple values
params = {'vlan_vid': vlans[0].vid}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'role': [roles[0].slug, roles[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_status(self):
params = {'status': [PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class IPAddressTestCase(TestCase):
queryset = IPAddress.objects.all()
filterset = IPAddressFilter
@classmethod
def setUpTestData(cls):
vrfs = (
VRF(name='VRF 1', rd='65000:100'),
VRF(name='VRF 2', rd='65000:200'),
VRF(name='VRF 3', rd='65000:300'),
)
VRF.objects.bulk_create(vrfs)
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
)
Device.objects.bulk_create(devices)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
)
VirtualMachine.objects.bulk_create(virtual_machines)
interfaces = (
Interface(device=devices[0], name='Interface 1'),
Interface(device=devices[1], name='Interface 2'),
Interface(device=devices[2], name='Interface 3'),
Interface(virtual_machine=virtual_machines[0], name='Interface 1'),
Interface(virtual_machine=virtual_machines[1], name='Interface 2'),
Interface(virtual_machine=virtual_machines[2], name='Interface 3'),
)
Interface.objects.bulk_create(interfaces)
ipaddresses = (
IPAddress(family=4, address='10.0.0.1/24', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-a'),
IPAddress(family=4, address='10.0.0.2/24', vrf=vrfs[0], interface=interfaces[0], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
IPAddress(family=4, address='10.0.0.3/24', vrf=vrfs[1], interface=interfaces[1], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=4, address='10.0.0.4/24', vrf=vrfs[2], interface=interfaces[2], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(family=6, address='2001:db8::1/64', vrf=None, interface=None, status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-a'),
IPAddress(family=6, address='2001:db8::2/64', vrf=vrfs[0], interface=interfaces[3], status=IPADDRESS_STATUS_ACTIVE, role=None, dns_name='ipaddress-b'),
IPAddress(family=6, address='2001:db8::3/64', vrf=vrfs[1], interface=interfaces[4], status=IPADDRESS_STATUS_RESERVED, role=IPADDRESS_ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(family=6, address='2001:db8::4/64', vrf=vrfs[2], interface=interfaces[5], status=IPADDRESS_STATUS_DEPRECATED, role=IPADDRESS_ROLE_SECONDARY, dns_name='ipaddress-d'),
)
IPAddress.objects.bulk_create(ipaddresses)
def test_family(self):
params = {'family': '6'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_dns_name(self):
params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_parent(self):
params = {'parent': '10.0.0.0/24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'parent': '2001:db8::/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def filter_address(self):
# Check IPv4 and IPv6, with and without a mask
params = {'address': '10.0.0.1/24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'address': '10.0.0.1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'address': '2001:db8::1/64'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'address': '2001:db8::1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_mask_length(self):
params = {'mask_length': '24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_vrf(self):
vrfs = VRF.objects.all()[:2]
params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# TODO: Test for multiple values
def test_device(self):
device = Device.objects.first()
params = {'device_id': device.pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'device': device.name}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_virtual_machine(self):
vms = VirtualMachine.objects.all()[:2]
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_interface(self):
interfaces = Interface.objects.all()[:2]
params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'interface': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'assigned_to_interface': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self):
params = {'status': [PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_role(self):
params = {'role': [IPADDRESS_ROLE_SECONDARY, IPADDRESS_ROLE_VIP]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class VLANGroupTestCase(TestCase):
queryset = VLANGroup.objects.all()
filterset = VLANGroupFilter
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
)
Site.objects.bulk_create(sites)
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2]),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None),
)
VLANGroup.objects.bulk_create(vlan_groups)
def test_id(self):
id_list = self.queryset.values_list('id', flat=True)[:2]
params = {'id': [str(id) for id in id_list]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['VLAN Group 1', 'VLAN Group 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['vlan-group-1', 'vlan-group-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VLANTestCase(TestCase):
queryset = VLAN.objects.all()
filterset = VLANFilter
@classmethod
def setUpTestData(cls):
regions = (
Region(name='Test Region 1', slug='test-region-1'),
Region(name='Test Region 2', slug='test-region-2'),
Region(name='Test Region 3', slug='test-region-3'),
)
# Can't use bulk_create for models with MPTT fields
for r in regions:
r.save()
sites = (
Site(name='Test Site 1', slug='test-site-1', region=regions[0]),
Site(name='Test Site 2', slug='test-site-2', region=regions[1]),
Site(name='Test Site 3', slug='test-site-3', region=regions[2]),
)
Site.objects.bulk_create(sites)
roles = (
Role(name='Role 1', slug='role-1'),
Role(name='Role 2', slug='role-2'),
Role(name='Role 3', slug='role-3'),
)
Role.objects.bulk_create(roles)
groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None),
)
VLANGroup.objects.bulk_create(groups)
vlans = (
VLAN(vid=101, name='VLAN 101', site=sites[0], group=groups[0], role=roles[0], status=VLAN_STATUS_ACTIVE),
VLAN(vid=102, name='VLAN 102', site=sites[0], group=groups[0], role=roles[0], status=VLAN_STATUS_ACTIVE),
VLAN(vid=201, name='VLAN 201', site=sites[1], group=groups[1], role=roles[1], status=VLAN_STATUS_DEPRECATED),
VLAN(vid=202, name='VLAN 202', site=sites[1], group=groups[1], role=roles[1], status=VLAN_STATUS_DEPRECATED),
VLAN(vid=301, name='VLAN 301', site=sites[2], group=groups[2], role=roles[2], status=VLAN_STATUS_RESERVED),
VLAN(vid=302, name='VLAN 302', site=sites[2], group=groups[2], role=roles[2], status=VLAN_STATUS_RESERVED),
)
VLAN.objects.bulk_create(vlans)
def test_name(self):
params = {'name': ['VLAN 101', 'VLAN 102']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_rd(self):
params = {'vid': ['101', '201', '301']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_region(self):
regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'region': [regions[0].slug, regions[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_site(self):
sites = Site.objects.all()[:2]
params = {'site_id': [sites[0].pk, sites[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_group(self):
groups = VLANGroup.objects.all()[:2]
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_role(self):
roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'role': [roles[0].slug, roles[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_status(self):
params = {'status': [VLAN_STATUS_ACTIVE, VLAN_STATUS_DEPRECATED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
class ServiceTestCase(TestCase):
queryset = Service.objects.all()
filterset = ServiceFilter
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
)
Device.objects.bulk_create(devices)
clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster = Cluster.objects.create(type=clustertype, name='Cluster 1')
virtual_machines = (
VirtualMachine(name='Virtual Machine 1', cluster=cluster),
VirtualMachine(name='Virtual Machine 2', cluster=cluster),
VirtualMachine(name='Virtual Machine 3', cluster=cluster),
)
VirtualMachine.objects.bulk_create(virtual_machines)
services = (
Service(device=devices[0], name='Service 1', protocol=IP_PROTOCOL_TCP, port=1001),
Service(device=devices[1], name='Service 2', protocol=IP_PROTOCOL_TCP, port=1002),
Service(device=devices[2], name='Service 3', protocol=IP_PROTOCOL_UDP, port=1003),
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=IP_PROTOCOL_TCP, port=2001),
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=IP_PROTOCOL_TCP, port=2002),
Service(virtual_machine=virtual_machines[2], name='Service 6', protocol=IP_PROTOCOL_UDP, port=2003),
)
Service.objects.bulk_create(services)
def test_id(self):
id_list = self.queryset.values_list('id', flat=True)[:3]
params = {'id': [str(id) for id in id_list]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_name(self):
params = {'name': ['Service 1', 'Service 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_protocol(self):
params = {'protocol': IP_PROTOCOL_TCP}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_port(self):
params = {'port': ['1001', '1002', '1003']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_virtual_machine(self):
vms = VirtualMachine.objects.all()[:2]
params = {'virtual_machine_id': [vms[0].pk, vms[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'virtual_machine': [vms[0].name, vms[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
DNSValidator = RegexValidator(
regex='^[0-9A-Za-z.-]+$',
message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names',
regex='^[0-9A-Za-z._-]+$',
message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names',
code='invalid'
)

View File

@@ -333,7 +333,10 @@ class AggregateView(PermissionRequiredMixin, View):
).annotate_depth(
limit=0
)
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
# Add available prefixes to the table if requested
if request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes)
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
@@ -356,6 +359,7 @@ class AggregateView(PermissionRequiredMixin, View):
'aggregate': aggregate,
'prefix_table': prefix_table,
'permissions': permissions,
'show_available': request.GET.get('show_available', 'true') == 'true',
})
@@ -511,8 +515,8 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
'site', 'vlan', 'role',
).annotate_depth(limit=0)
# Annotate available prefixes
if child_prefixes:
# Add available prefixes to the table if requested
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes)
prefix_table = tables.PrefixDetailTable(child_prefixes)
@@ -539,6 +543,7 @@ class PrefixPrefixesView(PermissionRequiredMixin, View):
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&within={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'prefixes',
'show_available': request.GET.get('show_available', 'true') == 'true',
})
@@ -553,7 +558,10 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
ipaddresses = prefix.get_child_ips().prefetch_related(
'vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for'
)
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
# Add available IP addresses to the table if requested
if request.GET.get('show_available', 'true') == 'true':
ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool)
ip_table = tables.IPAddressTable(ipaddresses)
if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
@@ -579,6 +587,7 @@ class PrefixIPAddressesView(PermissionRequiredMixin, View):
'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
'active_tab': 'ip-addresses',
'show_available': request.GET.get('show_available', 'true') == 'true',
})
@@ -677,7 +686,14 @@ class IPAddressView(PermissionRequiredMixin, View):
).filter(
vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)
)
related_ips_table = tables.IPAddressTable(list(related_ips), orderable=False)
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(related_ips_table)
return render(request, 'ipam/ipaddress.html', {
'ipaddress': ipaddress,
@@ -740,13 +756,12 @@ class IPAddressAssignView(PermissionRequiredMixin, View):
if form.is_valid():
queryset = IPAddress.objects.prefetch_related(
addresses = IPAddress.objects.prefetch_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter(
vrf=form.cleaned_data['vrf'],
address__istartswith=form.cleaned_data['address'],
)[:100] # Limit to 100 results
table = tables.IPAddressAssignTable(queryset)
)
# Limit to 100 results
addresses = filters.IPAddressFilter(request.POST, addresses).qs[:100]
table = tables.IPAddressAssignTable(addresses)
return render(request, 'ipam/ipaddress_assign.html', {
'form': form,

View File

@@ -17,12 +17,13 @@ DATABASE = {
'PASSWORD': '', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
}
# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file.
# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and
# symbols. NetBox will not run without this defined. For more information, see
# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY
# https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-SECRET_KEY
SECRET_KEY = ''
# Redis database settings. The Redis database is used for caching and background processing such as webhooks
@@ -106,7 +107,7 @@ EXEMPT_VIEW_PERMISSIONS = [
]
# Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs:
# https://docs.djangoproject.com/en/1.11/topics/logging/
# https://docs.djangoproject.com/en/stable/topics/logging/
LOGGING = {}
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
@@ -154,6 +155,10 @@ PREFER_IPV4 = False
# this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
# this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
# By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use
# local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
@@ -167,7 +172,7 @@ TIME_ZONE = 'UTC'
WEBHOOKS_ENABLED = False
# Date/time formatting. See the following link for supported formats:
# https://docs.djangoproject.com/en/dev/ref/templates/builtins/#date
# https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date
DATE_FORMAT = 'N j, Y'
SHORT_DATE_FORMAT = 'Y-m-d'
TIME_FORMAT = 'g:i a'

View File

@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup
#
VERSION = '2.6.6'
VERSION = '2.6.12'
# Hostname
HOSTNAME = platform.node()
@@ -364,6 +364,7 @@ CACHEOPS = {
'auth.user': {'ops': 'get', 'timeout': 60 * 15},
'auth.*': {'ops': ('fetch', 'get')},
'auth.permission': {'ops': 'all'},
'circuits.*': {'ops': 'all'},
'dcim.*': {'ops': 'all'},
'ipam.*': {'ops': 'all'},
'extras.*': {'ops': 'all'},

View File

@@ -1,6 +1,6 @@
from collections import OrderedDict
from django.db.models import Count, F
from django.db.models import Count, F, OuterRef, Subquery
from django.shortcuts import render
from django.views.generic import View
from rest_framework.response import Response
@@ -8,7 +8,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView
from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider
from circuits.models import Circuit, CircuitTermination, Provider
from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import (
CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter,
@@ -49,9 +49,15 @@ SEARCH_TYPES = OrderedDict((
('circuit', {
'permission': 'circuits.view_circuit',
'queryset': Circuit.objects.prefetch_related(
'type', 'provider', 'tenant'
).prefetch_related(
'terminations__site'
'type', 'provider', 'tenant', 'terminations__site'
).annotate(
# Annotate A/Z terminations
a_side=Subquery(
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='A').values('site__name')[:1]
),
z_side=Subquery(
CircuitTermination.objects.filter(circuit=OuterRef('pk')).filter(term_side='Z').values('site__name')[:1]
),
),
'filter': CircuitFilter,
'table': CircuitTable,
@@ -116,6 +122,23 @@ SEARCH_TYPES = OrderedDict((
'table': PowerFeedTable,
'url': 'dcim:powerfeed_list',
}),
# Virtualization
('cluster', {
'permission': 'virtualization.view_cluster',
'queryset': Cluster.objects.prefetch_related('type', 'group'),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'permission': 'virtualization.view_virtualmachine',
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
# IPAM
('vrf', {
'permission': 'ipam.view_vrf',
@@ -168,23 +191,6 @@ SEARCH_TYPES = OrderedDict((
'table': TenantTable,
'url': 'tenancy:tenant_list',
}),
# Virtualization
('cluster', {
'permission': 'virtualization.view_cluster',
'queryset': Cluster.objects.prefetch_related('type', 'group'),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'permission': 'virtualization.view_virtualmachine',
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
))

View File

@@ -457,6 +457,14 @@ table.report th a {
width: 80px;
border: 1px solid grey;
}
.inline-color-block {
display: inline-block;
width: 1.5em;
height: 1.5em;
border: 1px solid grey;
border-radius: .25em;
vertical-align: middle;
}
.text-nowrap {
white-space: nowrap;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,784 @@
.flatpickr-calendar {
background: transparent;
opacity: 0;
display: none;
text-align: center;
visibility: hidden;
padding: 0;
-webkit-animation: none;
animation: none;
direction: ltr;
border: 0;
font-size: 14px;
line-height: 24px;
border-radius: 5px;
position: absolute;
width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-ms-touch-action: manipulation;
touch-action: manipulation;
background: #3f4458;
-webkit-box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
box-shadow: 1px 0 0 #20222c, -1px 0 0 #20222c, 0 1px 0 #20222c, 0 -1px 0 #20222c, 0 3px 13px rgba(0,0,0,0.08);
}
.flatpickr-calendar.open,
.flatpickr-calendar.inline {
opacity: 1;
max-height: 640px;
visibility: visible;
}
.flatpickr-calendar.open {
display: inline-block;
z-index: 99999;
}
.flatpickr-calendar.animate.open {
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.flatpickr-calendar.inline {
display: block;
position: relative;
top: 2px;
}
.flatpickr-calendar.static {
position: absolute;
top: calc(100% + 2px);
}
.flatpickr-calendar.static.open {
z-index: 999;
display: block;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
}
.flatpickr-calendar .hasWeeks .dayContainer,
.flatpickr-calendar .hasTime .dayContainer {
border-bottom: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.flatpickr-calendar .hasWeeks .dayContainer {
border-left: 0;
}
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
height: 40px;
border-top: 1px solid #20222c;
}
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
height: auto;
}
.flatpickr-calendar:before,
.flatpickr-calendar:after {
position: absolute;
display: block;
pointer-events: none;
border: solid transparent;
content: '';
height: 0;
width: 0;
left: 22px;
}
.flatpickr-calendar.rightMost:before,
.flatpickr-calendar.rightMost:after {
left: auto;
right: 22px;
}
.flatpickr-calendar:before {
border-width: 5px;
margin: 0 -5px;
}
.flatpickr-calendar:after {
border-width: 4px;
margin: 0 -4px;
}
.flatpickr-calendar.arrowTop:before,
.flatpickr-calendar.arrowTop:after {
bottom: 100%;
}
.flatpickr-calendar.arrowTop:before {
border-bottom-color: #20222c;
}
.flatpickr-calendar.arrowTop:after {
border-bottom-color: #3f4458;
}
.flatpickr-calendar.arrowBottom:before,
.flatpickr-calendar.arrowBottom:after {
top: 100%;
}
.flatpickr-calendar.arrowBottom:before {
border-top-color: #20222c;
}
.flatpickr-calendar.arrowBottom:after {
border-top-color: #3f4458;
}
.flatpickr-calendar:focus {
outline: 0;
}
.flatpickr-wrapper {
position: relative;
display: inline-block;
}
.flatpickr-months {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-months .flatpickr-month {
background: #3f4458;
color: #fff;
fill: #fff;
height: 34px;
line-height: 1;
text-align: center;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
overflow: hidden;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
text-decoration: none;
cursor: pointer;
position: absolute;
top: 0;
height: 34px;
padding: 10px;
z-index: 3;
color: #fff;
fill: #fff;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
display: none;
}
.flatpickr-months .flatpickr-prev-month i,
.flatpickr-months .flatpickr-next-month i {
position: relative;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
/*
/*rtl:begin:ignore*/
/*
*/
left: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
/*
/*rtl:begin:ignore*/
/*
*/
right: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: #eee;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
fill: #f64747;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
width: 14px;
height: 14px;
}
.flatpickr-months .flatpickr-prev-month svg path,
.flatpickr-months .flatpickr-next-month svg path {
-webkit-transition: fill 0.1s;
transition: fill 0.1s;
fill: inherit;
}
.numInputWrapper {
position: relative;
height: auto;
}
.numInputWrapper input,
.numInputWrapper span {
display: inline-block;
}
.numInputWrapper input {
width: 100%;
}
.numInputWrapper input::-ms-clear {
display: none;
}
.numInputWrapper input::-webkit-outer-spin-button,
.numInputWrapper input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.numInputWrapper span {
position: absolute;
right: 0;
width: 14px;
padding: 0 4px 0 2px;
height: 50%;
line-height: 50%;
opacity: 0;
cursor: pointer;
border: 1px solid rgba(255,255,255,0.15);
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.numInputWrapper span:hover {
background: rgba(192,187,167,0.1);
}
.numInputWrapper span:active {
background: rgba(192,187,167,0.2);
}
.numInputWrapper span:after {
display: block;
content: "";
position: absolute;
}
.numInputWrapper span.arrowUp {
top: 0;
border-bottom: 0;
}
.numInputWrapper span.arrowUp:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid rgba(255,255,255,0.6);
top: 26%;
}
.numInputWrapper span.arrowDown {
top: 50%;
}
.numInputWrapper span.arrowDown:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(255,255,255,0.6);
top: 40%;
}
.numInputWrapper span svg {
width: inherit;
height: auto;
}
.numInputWrapper span svg path {
fill: rgba(255,255,255,0.5);
}
.numInputWrapper:hover {
background: rgba(192,187,167,0.05);
}
.numInputWrapper:hover span {
opacity: 1;
}
.flatpickr-current-month {
font-size: 135%;
line-height: inherit;
font-weight: 300;
color: inherit;
position: absolute;
width: 75%;
left: 12.5%;
padding: 7.48px 0 0 0;
line-height: 1;
height: 34px;
display: inline-block;
text-align: center;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
}
.flatpickr-current-month span.cur-month {
font-family: inherit;
font-weight: 700;
color: inherit;
display: inline-block;
margin-left: 0.5ch;
padding: 0;
}
.flatpickr-current-month span.cur-month:hover {
background: rgba(192,187,167,0.05);
}
.flatpickr-current-month .numInputWrapper {
width: 6ch;
width: 7ch\0;
display: inline-block;
}
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
border-bottom-color: #fff;
}
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
border-top-color: #fff;
}
.flatpickr-current-month input.cur-year {
background: transparent;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: inherit;
cursor: text;
padding: 0 0 0 0.5ch;
margin: 0;
display: inline-block;
font-size: inherit;
font-family: inherit;
font-weight: 300;
line-height: inherit;
height: auto;
border: 0;
border-radius: 0;
vertical-align: initial;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-current-month input.cur-year:focus {
outline: 0;
}
.flatpickr-current-month input.cur-year[disabled],
.flatpickr-current-month input.cur-year[disabled]:hover {
font-size: 100%;
color: rgba(255,255,255,0.5);
background: transparent;
pointer-events: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
appearance: menulist;
background: #3f4458;
border: none;
border-radius: 0;
box-sizing: border-box;
color: inherit;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 300;
height: auto;
line-height: inherit;
margin: -1px 0 0 0;
outline: none;
padding: 0 0 0 0.5ch;
position: relative;
vertical-align: initial;
-webkit-box-sizing: border-box;
-webkit-appearance: menulist;
-moz-appearance: menulist;
width: auto;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
outline: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: rgba(192,187,167,0.05);
}
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
background-color: #3f4458;
outline: none;
padding: 0;
}
.flatpickr-weekdays {
background: transparent;
text-align: center;
overflow: hidden;
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
height: 28px;
}
.flatpickr-weekdays .flatpickr-weekdaycontainer {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
span.flatpickr-weekday {
cursor: default;
font-size: 90%;
background: #3f4458;
color: #fff;
line-height: 1;
margin: 0;
text-align: center;
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
font-weight: bolder;
}
.dayContainer,
.flatpickr-weeks {
padding: 1px 0 0 0;
}
.flatpickr-days {
position: relative;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
width: 307.875px;
}
.flatpickr-days:focus {
outline: 0;
}
.dayContainer {
padding: 0;
outline: 0;
text-align: left;
width: 307.875px;
min-width: 307.875px;
max-width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: inline-block;
display: -ms-flexbox;
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
-ms-flex-wrap: wrap;
-ms-flex-pack: justify;
-webkit-justify-content: space-around;
justify-content: space-around;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
opacity: 1;
}
.dayContainer + .dayContainer {
-webkit-box-shadow: -1px 0 0 #20222c;
box-shadow: -1px 0 0 #20222c;
}
.flatpickr-day {
background: none;
border: 1px solid transparent;
border-radius: 150px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: rgba(255,255,255,0.95);
cursor: pointer;
font-weight: 400;
width: 14.2857143%;
-webkit-flex-basis: 14.2857143%;
-ms-flex-preferred-size: 14.2857143%;
flex-basis: 14.2857143%;
max-width: 39px;
height: 39px;
line-height: 39px;
margin: 0;
display: inline-block;
position: relative;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
}
.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
cursor: pointer;
outline: 0;
background: #646c8c;
border-color: #646c8c;
}
.flatpickr-day.today {
border-color: #eee;
}
.flatpickr-day.today:hover,
.flatpickr-day.today:focus {
border-color: #eee;
background: #eee;
color: #3f4458;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
background: #80cbc4;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #80cbc4;
}
.flatpickr-day.selected.startRange,
.flatpickr-day.startRange.startRange,
.flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px;
}
.flatpickr-day.selected.endRange,
.flatpickr-day.startRange.endRange,
.flatpickr-day.endRange.endRange {
border-radius: 0 50px 50px 0;
}
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
-webkit-box-shadow: -10px 0 0 #80cbc4;
box-shadow: -10px 0 0 #80cbc4;
}
.flatpickr-day.selected.startRange.endRange,
.flatpickr-day.startRange.startRange.endRange,
.flatpickr-day.endRange.startRange.endRange {
border-radius: 50px;
}
.flatpickr-day.inRange {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
box-shadow: -5px 0 0 #646c8c, 5px 0 0 #646c8c;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay,
.flatpickr-day.notAllowed,
.flatpickr-day.notAllowed.prevMonthDay,
.flatpickr-day.notAllowed.nextMonthDay {
color: rgba(255,255,255,0.3);
background: transparent;
border-color: transparent;
cursor: default;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
cursor: not-allowed;
color: rgba(255,255,255,0.1);
}
.flatpickr-day.week.selected {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
box-shadow: -5px 0 0 #80cbc4, 5px 0 0 #80cbc4;
}
.flatpickr-day.hidden {
visibility: hidden;
}
.rangeMode .flatpickr-day {
margin-top: 1px;
}
.flatpickr-weekwrapper {
float: left;
}
.flatpickr-weekwrapper .flatpickr-weeks {
padding: 0 12px;
-webkit-box-shadow: 1px 0 0 #20222c;
box-shadow: 1px 0 0 #20222c;
}
.flatpickr-weekwrapper .flatpickr-weekday {
float: none;
width: 100%;
line-height: 28px;
}
.flatpickr-weekwrapper span.flatpickr-day,
.flatpickr-weekwrapper span.flatpickr-day:hover {
display: block;
width: 100%;
max-width: none;
color: rgba(255,255,255,0.3);
background: transparent;
cursor: default;
border: none;
}
.flatpickr-innerContainer {
display: block;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
}
.flatpickr-rContainer {
display: inline-block;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.flatpickr-time {
text-align: center;
outline: 0;
display: block;
height: 0;
line-height: 40px;
max-height: 40px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-time:after {
content: "";
display: table;
clear: both;
}
.flatpickr-time .numInputWrapper {
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
width: 40%;
height: 40px;
float: left;
}
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: rgba(255,255,255,0.95);
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: rgba(255,255,255,0.95);
}
.flatpickr-time.hasSeconds .numInputWrapper {
width: 26%;
}
.flatpickr-time.time24hr .numInputWrapper {
width: 49%;
}
.flatpickr-time input {
background: transparent;
-webkit-box-shadow: none;
box-shadow: none;
border: 0;
border-radius: 0;
text-align: center;
margin: 0;
padding: 0;
height: inherit;
line-height: inherit;
color: rgba(255,255,255,0.95);
font-size: 14px;
position: relative;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-time input.flatpickr-hour {
font-weight: bold;
}
.flatpickr-time input.flatpickr-minute,
.flatpickr-time input.flatpickr-second {
font-weight: 400;
}
.flatpickr-time input:focus {
outline: 0;
border: 0;
}
.flatpickr-time .flatpickr-time-separator,
.flatpickr-time .flatpickr-am-pm {
height: inherit;
float: left;
line-height: inherit;
color: rgba(255,255,255,0.95);
font-weight: bold;
width: 2%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.flatpickr-time .flatpickr-am-pm {
outline: 0;
width: 18%;
cursor: pointer;
text-align: center;
font-weight: 400;
}
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: #6a7395;
}
.flatpickr-input[readonly] {
cursor: pointer;
}
@-webkit-keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}

View File

@@ -0,0 +1,798 @@
.flatpickr-calendar {
background: transparent;
opacity: 0;
display: none;
text-align: center;
visibility: hidden;
padding: 0;
-webkit-animation: none;
animation: none;
direction: ltr;
border: 0;
font-size: 14px;
line-height: 24px;
border-radius: 5px;
position: absolute;
width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-ms-touch-action: manipulation;
touch-action: manipulation;
-webkit-box-shadow: 0 3px 13px rgba(0,0,0,0.08);
box-shadow: 0 3px 13px rgba(0,0,0,0.08);
}
.flatpickr-calendar.open,
.flatpickr-calendar.inline {
opacity: 1;
max-height: 640px;
visibility: visible;
}
.flatpickr-calendar.open {
display: inline-block;
z-index: 99999;
}
.flatpickr-calendar.animate.open {
-webkit-animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
animation: fpFadeInDown 300ms cubic-bezier(0.23, 1, 0.32, 1);
}
.flatpickr-calendar.inline {
display: block;
position: relative;
top: 2px;
}
.flatpickr-calendar.static {
position: absolute;
top: calc(100% + 2px);
}
.flatpickr-calendar.static.open {
z-index: 999;
display: block;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+1) .flatpickr-day.inRange:nth-child(7n+7) {
-webkit-box-shadow: none !important;
box-shadow: none !important;
}
.flatpickr-calendar.multiMonth .flatpickr-days .dayContainer:nth-child(n+2) .flatpickr-day.inRange:nth-child(7n+1) {
-webkit-box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
box-shadow: -2px 0 0 #e6e6e6, 5px 0 0 #e6e6e6;
}
.flatpickr-calendar .hasWeeks .dayContainer,
.flatpickr-calendar .hasTime .dayContainer {
border-bottom: 0;
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
.flatpickr-calendar .hasWeeks .dayContainer {
border-left: 0;
}
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
height: 40px;
border-top: 1px solid #eceef1;
}
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-innerContainer {
border-bottom: 0;
}
.flatpickr-calendar.showTimeInput.hasTime .flatpickr-time {
border: 1px solid #eceef1;
}
.flatpickr-calendar.noCalendar.hasTime .flatpickr-time {
height: auto;
}
.flatpickr-calendar:before,
.flatpickr-calendar:after {
position: absolute;
display: block;
pointer-events: none;
border: solid transparent;
content: '';
height: 0;
width: 0;
left: 22px;
}
.flatpickr-calendar.rightMost:before,
.flatpickr-calendar.rightMost:after {
left: auto;
right: 22px;
}
.flatpickr-calendar:before {
border-width: 5px;
margin: 0 -5px;
}
.flatpickr-calendar:after {
border-width: 4px;
margin: 0 -4px;
}
.flatpickr-calendar.arrowTop:before,
.flatpickr-calendar.arrowTop:after {
bottom: 100%;
}
.flatpickr-calendar.arrowTop:before {
border-bottom-color: #eceef1;
}
.flatpickr-calendar.arrowTop:after {
border-bottom-color: #eceef1;
}
.flatpickr-calendar.arrowBottom:before,
.flatpickr-calendar.arrowBottom:after {
top: 100%;
}
.flatpickr-calendar.arrowBottom:before {
border-top-color: #eceef1;
}
.flatpickr-calendar.arrowBottom:after {
border-top-color: #eceef1;
}
.flatpickr-calendar:focus {
outline: 0;
}
.flatpickr-wrapper {
position: relative;
display: inline-block;
}
.flatpickr-months {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
}
.flatpickr-months .flatpickr-month {
border-radius: 5px 5px 0 0;
background: #eceef1;
color: #5a6171;
fill: #5a6171;
height: 34px;
line-height: 1;
text-align: center;
position: relative;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
overflow: hidden;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
.flatpickr-months .flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month {
text-decoration: none;
cursor: pointer;
position: absolute;
top: 0;
height: 34px;
padding: 10px;
z-index: 3;
color: #5a6171;
fill: #5a6171;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-disabled,
.flatpickr-months .flatpickr-next-month.flatpickr-disabled {
display: none;
}
.flatpickr-months .flatpickr-prev-month i,
.flatpickr-months .flatpickr-next-month i {
position: relative;
}
.flatpickr-months .flatpickr-prev-month.flatpickr-prev-month,
.flatpickr-months .flatpickr-next-month.flatpickr-prev-month {
/*
/*rtl:begin:ignore*/
/*
*/
left: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month.flatpickr-next-month,
.flatpickr-months .flatpickr-next-month.flatpickr-next-month {
/*
/*rtl:begin:ignore*/
/*
*/
right: 0;
/*
/*rtl:end:ignore*/
/*
*/
}
/*
/*rtl:begin:ignore*/
/*
/*rtl:end:ignore*/
.flatpickr-months .flatpickr-prev-month:hover,
.flatpickr-months .flatpickr-next-month:hover {
color: #bbb;
}
.flatpickr-months .flatpickr-prev-month:hover svg,
.flatpickr-months .flatpickr-next-month:hover svg {
fill: #f64747;
}
.flatpickr-months .flatpickr-prev-month svg,
.flatpickr-months .flatpickr-next-month svg {
width: 14px;
height: 14px;
}
.flatpickr-months .flatpickr-prev-month svg path,
.flatpickr-months .flatpickr-next-month svg path {
-webkit-transition: fill 0.1s;
transition: fill 0.1s;
fill: inherit;
}
.numInputWrapper {
position: relative;
height: auto;
}
.numInputWrapper input,
.numInputWrapper span {
display: inline-block;
}
.numInputWrapper input {
width: 100%;
}
.numInputWrapper input::-ms-clear {
display: none;
}
.numInputWrapper input::-webkit-outer-spin-button,
.numInputWrapper input::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
}
.numInputWrapper span {
position: absolute;
right: 0;
width: 14px;
padding: 0 4px 0 2px;
height: 50%;
line-height: 50%;
opacity: 0;
cursor: pointer;
border: 1px solid rgba(72,72,72,0.15);
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.numInputWrapper span:hover {
background: rgba(0,0,0,0.1);
}
.numInputWrapper span:active {
background: rgba(0,0,0,0.2);
}
.numInputWrapper span:after {
display: block;
content: "";
position: absolute;
}
.numInputWrapper span.arrowUp {
top: 0;
border-bottom: 0;
}
.numInputWrapper span.arrowUp:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-bottom: 4px solid rgba(72,72,72,0.6);
top: 26%;
}
.numInputWrapper span.arrowDown {
top: 50%;
}
.numInputWrapper span.arrowDown:after {
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid rgba(72,72,72,0.6);
top: 40%;
}
.numInputWrapper span svg {
width: inherit;
height: auto;
}
.numInputWrapper span svg path {
fill: rgba(90,97,113,0.5);
}
.numInputWrapper:hover {
background: rgba(0,0,0,0.05);
}
.numInputWrapper:hover span {
opacity: 1;
}
.flatpickr-current-month {
font-size: 135%;
line-height: inherit;
font-weight: 300;
color: inherit;
position: absolute;
width: 75%;
left: 12.5%;
padding: 7.48px 0 0 0;
line-height: 1;
height: 34px;
display: inline-block;
text-align: center;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
}
.flatpickr-current-month span.cur-month {
font-family: inherit;
font-weight: 700;
color: inherit;
display: inline-block;
margin-left: 0.5ch;
padding: 0;
}
.flatpickr-current-month span.cur-month:hover {
background: rgba(0,0,0,0.05);
}
.flatpickr-current-month .numInputWrapper {
width: 6ch;
width: 7ch\0;
display: inline-block;
}
.flatpickr-current-month .numInputWrapper span.arrowUp:after {
border-bottom-color: #5a6171;
}
.flatpickr-current-month .numInputWrapper span.arrowDown:after {
border-top-color: #5a6171;
}
.flatpickr-current-month input.cur-year {
background: transparent;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: inherit;
cursor: text;
padding: 0 0 0 0.5ch;
margin: 0;
display: inline-block;
font-size: inherit;
font-family: inherit;
font-weight: 300;
line-height: inherit;
height: auto;
border: 0;
border-radius: 0;
vertical-align: initial;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-current-month input.cur-year:focus {
outline: 0;
}
.flatpickr-current-month input.cur-year[disabled],
.flatpickr-current-month input.cur-year[disabled]:hover {
font-size: 100%;
color: rgba(90,97,113,0.5);
background: transparent;
pointer-events: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months {
appearance: menulist;
background: #eceef1;
border: none;
border-radius: 0;
box-sizing: border-box;
color: inherit;
cursor: pointer;
font-size: inherit;
font-family: inherit;
font-weight: 300;
height: auto;
line-height: inherit;
margin: -1px 0 0 0;
outline: none;
padding: 0 0 0 0.5ch;
position: relative;
vertical-align: initial;
-webkit-box-sizing: border-box;
-webkit-appearance: menulist;
-moz-appearance: menulist;
width: auto;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:focus,
.flatpickr-current-month .flatpickr-monthDropdown-months:active {
outline: none;
}
.flatpickr-current-month .flatpickr-monthDropdown-months:hover {
background: rgba(0,0,0,0.05);
}
.flatpickr-current-month .flatpickr-monthDropdown-months .flatpickr-monthDropdown-month {
background-color: #eceef1;
outline: none;
padding: 0;
}
.flatpickr-weekdays {
background: #eceef1;
text-align: center;
overflow: hidden;
width: 100%;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
height: 28px;
}
.flatpickr-weekdays .flatpickr-weekdaycontainer {
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
}
span.flatpickr-weekday {
cursor: default;
font-size: 90%;
background: #eceef1;
color: #5a6171;
line-height: 1;
margin: 0;
text-align: center;
display: block;
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
font-weight: bolder;
}
.dayContainer,
.flatpickr-weeks {
padding: 1px 0 0 0;
}
.flatpickr-days {
position: relative;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-align: start;
-webkit-align-items: flex-start;
-ms-flex-align: start;
align-items: flex-start;
width: 307.875px;
border-left: 1px solid #eceef1;
border-right: 1px solid #eceef1;
}
.flatpickr-days:focus {
outline: 0;
}
.dayContainer {
padding: 0;
outline: 0;
text-align: left;
width: 307.875px;
min-width: 307.875px;
max-width: 307.875px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
display: inline-block;
display: -ms-flexbox;
display: -webkit-box;
display: -webkit-flex;
display: flex;
-webkit-flex-wrap: wrap;
flex-wrap: wrap;
-ms-flex-wrap: wrap;
-ms-flex-pack: justify;
-webkit-justify-content: space-around;
justify-content: space-around;
-webkit-transform: translate3d(0px, 0px, 0px);
transform: translate3d(0px, 0px, 0px);
opacity: 1;
}
.dayContainer + .dayContainer {
-webkit-box-shadow: -1px 0 0 #eceef1;
box-shadow: -1px 0 0 #eceef1;
}
.flatpickr-day {
background: none;
border: 1px solid transparent;
border-radius: 150px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #484848;
cursor: pointer;
font-weight: 400;
width: 14.2857143%;
-webkit-flex-basis: 14.2857143%;
-ms-flex-preferred-size: 14.2857143%;
flex-basis: 14.2857143%;
max-width: 39px;
height: 39px;
line-height: 39px;
margin: 0;
display: inline-block;
position: relative;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
text-align: center;
}
.flatpickr-day.inRange,
.flatpickr-day.prevMonthDay.inRange,
.flatpickr-day.nextMonthDay.inRange,
.flatpickr-day.today.inRange,
.flatpickr-day.prevMonthDay.today.inRange,
.flatpickr-day.nextMonthDay.today.inRange,
.flatpickr-day:hover,
.flatpickr-day.prevMonthDay:hover,
.flatpickr-day.nextMonthDay:hover,
.flatpickr-day:focus,
.flatpickr-day.prevMonthDay:focus,
.flatpickr-day.nextMonthDay:focus {
cursor: pointer;
outline: 0;
background: #e2e2e2;
border-color: #e2e2e2;
}
.flatpickr-day.today {
border-color: #bbb;
}
.flatpickr-day.today:hover,
.flatpickr-day.today:focus {
border-color: #bbb;
background: #bbb;
color: #fff;
}
.flatpickr-day.selected,
.flatpickr-day.startRange,
.flatpickr-day.endRange,
.flatpickr-day.selected.inRange,
.flatpickr-day.startRange.inRange,
.flatpickr-day.endRange.inRange,
.flatpickr-day.selected:focus,
.flatpickr-day.startRange:focus,
.flatpickr-day.endRange:focus,
.flatpickr-day.selected:hover,
.flatpickr-day.startRange:hover,
.flatpickr-day.endRange:hover,
.flatpickr-day.selected.prevMonthDay,
.flatpickr-day.startRange.prevMonthDay,
.flatpickr-day.endRange.prevMonthDay,
.flatpickr-day.selected.nextMonthDay,
.flatpickr-day.startRange.nextMonthDay,
.flatpickr-day.endRange.nextMonthDay {
background: #ff5a5f;
-webkit-box-shadow: none;
box-shadow: none;
color: #fff;
border-color: #ff5a5f;
}
.flatpickr-day.selected.startRange,
.flatpickr-day.startRange.startRange,
.flatpickr-day.endRange.startRange {
border-radius: 50px 0 0 50px;
}
.flatpickr-day.selected.endRange,
.flatpickr-day.startRange.endRange,
.flatpickr-day.endRange.endRange {
border-radius: 0 50px 50px 0;
}
.flatpickr-day.selected.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.startRange.startRange + .endRange:not(:nth-child(7n+1)),
.flatpickr-day.endRange.startRange + .endRange:not(:nth-child(7n+1)) {
-webkit-box-shadow: -10px 0 0 #ff5a5f;
box-shadow: -10px 0 0 #ff5a5f;
}
.flatpickr-day.selected.startRange.endRange,
.flatpickr-day.startRange.startRange.endRange,
.flatpickr-day.endRange.startRange.endRange {
border-radius: 50px;
}
.flatpickr-day.inRange {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
box-shadow: -5px 0 0 #e2e2e2, 5px 0 0 #e2e2e2;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover,
.flatpickr-day.prevMonthDay,
.flatpickr-day.nextMonthDay,
.flatpickr-day.notAllowed,
.flatpickr-day.notAllowed.prevMonthDay,
.flatpickr-day.notAllowed.nextMonthDay {
color: rgba(72,72,72,0.3);
background: transparent;
border-color: transparent;
cursor: default;
}
.flatpickr-day.flatpickr-disabled,
.flatpickr-day.flatpickr-disabled:hover {
cursor: not-allowed;
color: rgba(72,72,72,0.1);
}
.flatpickr-day.week.selected {
border-radius: 0;
-webkit-box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
box-shadow: -5px 0 0 #ff5a5f, 5px 0 0 #ff5a5f;
}
.flatpickr-day.hidden {
visibility: hidden;
}
.rangeMode .flatpickr-day {
margin-top: 1px;
}
.flatpickr-weekwrapper {
float: left;
}
.flatpickr-weekwrapper .flatpickr-weeks {
padding: 0 12px;
border-left: 1px solid #eceef1;
}
.flatpickr-weekwrapper .flatpickr-weekday {
float: none;
width: 100%;
line-height: 28px;
}
.flatpickr-weekwrapper span.flatpickr-day,
.flatpickr-weekwrapper span.flatpickr-day:hover {
display: block;
width: 100%;
max-width: none;
color: rgba(72,72,72,0.3);
background: transparent;
cursor: default;
border: none;
}
.flatpickr-innerContainer {
display: block;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
background: #fff;
border-bottom: 1px solid #eceef1;
}
.flatpickr-rContainer {
display: inline-block;
padding: 0;
-webkit-box-sizing: border-box;
box-sizing: border-box;
}
.flatpickr-time {
text-align: center;
outline: 0;
display: block;
height: 0;
line-height: 40px;
max-height: 40px;
-webkit-box-sizing: border-box;
box-sizing: border-box;
overflow: hidden;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
background: #fff;
border-radius: 0 0 5px 5px;
}
.flatpickr-time:after {
content: "";
display: table;
clear: both;
}
.flatpickr-time .numInputWrapper {
-webkit-box-flex: 1;
-webkit-flex: 1;
-ms-flex: 1;
flex: 1;
width: 40%;
height: 40px;
float: left;
}
.flatpickr-time .numInputWrapper span.arrowUp:after {
border-bottom-color: #484848;
}
.flatpickr-time .numInputWrapper span.arrowDown:after {
border-top-color: #484848;
}
.flatpickr-time.hasSeconds .numInputWrapper {
width: 26%;
}
.flatpickr-time.time24hr .numInputWrapper {
width: 49%;
}
.flatpickr-time input {
background: transparent;
-webkit-box-shadow: none;
box-shadow: none;
border: 0;
border-radius: 0;
text-align: center;
margin: 0;
padding: 0;
height: inherit;
line-height: inherit;
color: #484848;
font-size: 14px;
position: relative;
-webkit-box-sizing: border-box;
box-sizing: border-box;
-webkit-appearance: textfield;
-moz-appearance: textfield;
appearance: textfield;
}
.flatpickr-time input.flatpickr-hour {
font-weight: bold;
}
.flatpickr-time input.flatpickr-minute,
.flatpickr-time input.flatpickr-second {
font-weight: 400;
}
.flatpickr-time input:focus {
outline: 0;
border: 0;
}
.flatpickr-time .flatpickr-time-separator,
.flatpickr-time .flatpickr-am-pm {
height: inherit;
float: left;
line-height: inherit;
color: #484848;
font-weight: bold;
width: 2%;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
-webkit-align-self: center;
-ms-flex-item-align: center;
align-self: center;
}
.flatpickr-time .flatpickr-am-pm {
outline: 0;
width: 18%;
cursor: pointer;
text-align: center;
font-weight: 400;
}
.flatpickr-time input:hover,
.flatpickr-time .flatpickr-am-pm:hover,
.flatpickr-time input:focus,
.flatpickr-time .flatpickr-am-pm:focus {
background: #eaeaea;
}
.flatpickr-input[readonly] {
cursor: pointer;
}
@-webkit-keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
@keyframes fpFadeInDown {
from {
opacity: 0;
-webkit-transform: translate3d(0, -20px, 0);
transform: translate3d(0, -20px, 0);
}
to {
opacity: 1;
-webkit-transform: translate3d(0, 0, 0);
transform: translate3d(0, 0, 0);
}
}
span.flatpickr-day.selected {
font-weight: bold;
}

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 320">
<g fill="#9cc8f8" stroke="#9cc8f8">
<circle cx="37" cy="284" r="23"/>
<circle cx="101" cy="37" r="23"/>
<circle cx="101" cy="220" r="23"/>
<circle cx="284" cy="220" r="23"/>
<rect x="93" y="37" width="16" height="180"/>
<rect x="101" y="212" width="180" height="16"/>
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
</g>
<g fill="#1685fc" stroke="#1685fc">
<circle cx="284" cy="37" r="23"/>
<circle cx="37" cy="101" r="23"/>
<circle cx="220" cy="101" r="23"/>
<circle cx="220" cy="284" r="23"/>
<rect x="37" y="93" width="180" height="16"/>
<rect x="212" y="101" width="16" height="180"/>
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 835 B

View File

@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1100 320">
<g fill="#9cc8f8" stroke="#9cc8f8">
<circle cx="37" cy="284" r="23"/>
<circle cx="101" cy="37" r="23"/>
<circle cx="101" cy="220" r="23"/>
<circle cx="284" cy="220" r="23"/>
<rect x="93" y="37" width="16" height="180"/>
<rect x="101" y="212" width="180" height="16"/>
<rect x="93" y="212" width="16" height="90" transform="rotate(45 101 220)"/>
</g>
<g fill="#1685fc" stroke="#1685fc">
<circle cx="284" cy="37" r="23"/>
<circle cx="37" cy="101" r="23"/>
<circle cx="220" cy="101" r="23"/>
<circle cx="220" cy="284" r="23"/>
<rect x="37" y="93" width="180" height="16"/>
<rect x="212" y="101" width="16" height="180"/>
<rect x="212" y="93" width="16" height="90" transform="rotate(225 220 101)"/>
<path transform="translate(380, 8)" d="M13.60 200L13.60 104L36.40 104L36.40 119.40L36.80 119.40Q40.20 112.20 47.20 106.90Q54.20 101.60 66.20 101.60L66.20 101.60Q75.80 101.60 82.50 104.80Q89.20 108 93.40 113.20Q97.60 118.40 99.40 125.20Q101.20 132 101.20 139.40L101.20 139.40L101.20 200L77.20 200L77.20 151.40Q77.20 147.40 76.80 142.50Q76.40 137.60 74.70 133.30Q73 129 69.40 126.10Q65.80 123.20 59.60 123.20L59.60 123.20Q53.60 123.20 49.50 125.20Q45.40 127.20 42.70 130.60Q40 134 38.80 138.40Q37.60 142.80 37.60 147.60L37.60 147.60L37.60 200L13.60 200ZM224.80 160.40L151.60 160.40Q152.80 171.20 160 177.20Q167.20 183.20 177.40 183.20L177.40 183.20Q186.40 183.20 192.50 179.50Q198.60 175.80 203.20 170.20L203.20 170.20L220.40 183.20Q212 193.60 201.60 198Q191.20 202.40 179.80 202.40L179.80 202.40Q169 202.40 159.40 198.80Q149.80 195.20 142.80 188.60Q135.80 182 131.70 172.70Q127.60 163.40 127.60 152L127.60 152Q127.60 140.60 131.70 131.30Q135.80 122 142.80 115.40Q149.80 108.80 159.40 105.20Q169 101.60 179.80 101.60L179.80 101.60Q189.80 101.60 198.10 105.10Q206.40 108.60 212.30 115.20Q218.20 121.80 221.50 131.50Q224.80 141.20 224.80 153.80L224.80 153.80L224.80 160.40ZM151.60 142.40L200.80 142.40Q200.60 131.80 194.20 125.70Q187.80 119.60 176.40 119.60L176.40 119.60Q165.60 119.60 159.30 125.80Q153 132 151.60 142.40L151.60 142.40ZM259.80 124.40L240.00 124.40L240.00 104L259.80 104L259.80 76.20L283.80 76.20L283.80 104L310.20 104L310.20 124.40L283.80 124.40L283.80 166.40Q283.80 173.60 286.50 177.80Q289.20 182 297.20 182L297.20 182Q300.40 182 304.20 181.30Q308 180.60 310.20 179L310.20 179L310.20 199.20Q306.40 201 300.90 201.70Q295.40 202.40 291.20 202.40L291.20 202.40Q281.60 202.40 275.50 200.30Q269.40 198.20 265.90 193.90Q262.40 189.60 261.10 183.20Q259.80 176.80 259.80 168.40L259.80 168.40L259.80 124.40ZM333.20 200L333.20 48.80L357.20 48.80L357.20 116.20L357.80 116.20Q359.60 113.80 362.40 111.30Q365.20 108.80 369.20 106.60Q373.20 104.40 378.40 103Q383.60 101.60 390.40 101.60L390.40 101.60Q400.60 101.60 409.20 105.50Q417.80 109.40 423.90 116.20Q430 123 433.40 132.20Q436.80 141.40 436.80 152L436.80 152Q436.80 162.60 433.60 171.80Q430.40 181 424.20 187.80Q418 194.60 409.20 198.50Q400.40 202.40 389.40 202.40L389.40 202.40Q379.20 202.40 370.40 198.40Q361.60 194.40 356.40 185.60L356.40 185.60L356 185.60L356 200L333.20 200ZM412.80 152L412.80 152Q412.80 146.40 410.90 141.20Q409 136 405.30 132Q401.60 128 396.40 125.60Q391.20 123.20 384.60 123.20L384.60 123.20Q378 123.20 372.80 125.60Q367.60 128 363.90 132Q360.20 136 358.30 141.20Q356.40 146.40 356.40 152L356.40 152Q356.40 157.60 358.30 162.80Q360.20 168 363.90 172Q367.60 176 372.80 178.40Q378 180.80 384.60 180.80L384.60 180.80Q391.20 180.80 396.40 178.40Q401.60 176 405.30 172Q409 168 410.90 162.80Q412.80 157.60 412.80 152ZM458.40 152L458.40 152Q458.40 140.60 462.50 131.30Q466.60 122 473.60 115.40Q480.60 108.80 490.20 105.20Q499.80 101.60 510.60 101.60L510.60 101.60Q521.40 101.60 531 105.20Q540.60 108.80 547.60 115.40Q554.60 122 558.70 131.30Q562.80 140.60 562.80 152L562.80 152Q562.80 163.40 558.70 172.70Q554.60 182 547.60 188.60Q540.60 195.20 531 198.80Q521.40 202.40 510.60 202.40L510.60 202.40Q499.80 202.40 490.20 198.80Q480.60 195.20 473.60 188.60Q466.60 182 462.50 172.70Q458.40 163.40 458.40 152ZM482.40 152L482.40 152Q482.40 157.60 484.30 162.80Q486.20 168 489.90 172Q493.60 176 498.80 178.40Q504 180.80 510.60 180.80L510.60 180.80Q517.20 180.80 522.40 178.40Q527.60 176 531.30 172Q535 168 536.90 162.80Q538.80 157.60 538.80 152L538.80 152Q538.80 146.40 536.90 141.20Q535 136 531.30 132Q527.60 128 522.40 125.60Q517.20 123.20 510.60 123.20L510.60 123.20Q504 123.20 498.80 125.60Q493.60 128 489.90 132Q486.20 136 484.30 141.20Q482.40 146.40 482.40 152ZM575.40 200L614 148.40L580.80 104L610 104L629.20 132.80L650 104L677.40 104L644.60 148.40L683.20 200L654 200L629 165.60L603.80 200L575.40 200Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -7,7 +7,7 @@ $(document).ready(function() {
// "Toggle" checkbox for object lists (PK column)
$('input:checkbox.toggle').click(function() {
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
$(this).closest('table').find('input:checkbox[name=pk]:visible').prop('checked', $(this).prop('checked'));
// Show the "select all" box if present
if ($(this).is(':checked')) {
@@ -103,14 +103,16 @@ $(document).ready(function() {
placeholder: "---------",
theme: "bootstrap",
templateResult: colorPickerClassCopy,
templateSelection: colorPickerClassCopy
templateSelection: colorPickerClassCopy,
width: "off"
});
// Static choice selection
$('.netbox-select2-static').select2({
allowClear: true,
placeholder: "---------",
theme: "bootstrap"
theme: "bootstrap",
width: "off"
});
// API backed selection
@@ -120,6 +122,7 @@ $(document).ready(function() {
allowClear: true,
placeholder: "---------",
theme: "bootstrap",
width: "off",
ajax: {
delay: 500,
@@ -184,7 +187,15 @@ $(document).ready(function() {
$.each(element.attributes, function(index, attr){
if (attr.name.includes("data-additional-query-param-")){
var param_name = attr.name.split("data-additional-query-param-")[1];
parameters[param_name] = attr.value;
if (param_name in parameters) {
if (Array.isArray(parameters[param_name])) {
parameters[param_name].push(attr.value)
} else {
parameters[param_name] = [parameters[param_name], attr.value]
}
} else {
parameters[param_name] = attr.value;
}
}
});
@@ -251,6 +262,24 @@ $(document).ready(function() {
}
});
// Flatpickr selectors
$('.date-picker').flatpickr({
allowInput: true
});
$('.datetime-picker').flatpickr({
allowInput: true,
enableSeconds: true,
enableTime: true,
time_24hr: true
});
$('.time-picker').flatpickr({
allowInput: true,
enableSeconds: true,
enableTime: true,
noCalendar: true,
time_24hr: true
});
// API backed tags
var tags = $('#id_tags');
if (tags.length > 0 && tags.val().length > 0){
@@ -273,7 +302,8 @@ $(document).ready(function() {
multiple: true,
allowClear: true,
placeholder: "Tags",
theme: "bootstrap",
width: "off",
ajax: {
delay: 250,
url: netbox_api_path + "extras/tags/",
@@ -353,4 +383,58 @@ $(document).ready(function() {
});
$('select#id_mode').trigger('change');
}
// Scroll up an offset equal to the first nav element if a hash is present
// Cannot use '#navbar' because it is not always visible, like in small windows
function headerOffsetScroll() {
if (window.location.hash) {
// Short wait needed to allow the page to scroll to the element
setTimeout(function() {
window.scrollBy(0, -$('nav').height())
}, 10);
}
}
// Account for the header height when hash-scrolling
window.addEventListener('load', headerOffsetScroll);
window.addEventListener('hashchange', headerOffsetScroll);
// Offset between the preview window and the window edges
const IMAGE_PREVIEW_OFFSET_X = 20;
const IMAGE_PREVIEW_OFFSET_Y = 10;
// Preview an image attachment when the link is hovered over
$('a.image-preview').on('mouseover', function(e) {
// Twice the offset to account for all sides of the picture
var maxWidth = window.innerWidth - (e.clientX + (IMAGE_PREVIEW_OFFSET_X * 2));
var maxHeight = window.innerHeight - (e.clientY + (IMAGE_PREVIEW_OFFSET_Y * 2));
var img = $('<img>').attr('id', 'image-preview-window').css({
display: 'none',
position: 'absolute',
maxWidth: maxWidth + 'px',
maxHeight: maxHeight + 'px',
left: e.pageX + IMAGE_PREVIEW_OFFSET_X + 'px',
top: e.pageY + IMAGE_PREVIEW_OFFSET_Y + 'px',
boxShadow: '0 0px 12px 3px rgba(0, 0, 0, 0.4)',
});
// Remove any existing preview windows and add the current one
$('#image-preview-window').remove();
$('body').append(img);
// Once loaded, show the preview if the image is indeed an image
img.on('load', function(e) {
if (e.target.complete && e.target.naturalWidth) {
$('#image-preview-window').fadeIn('fast');
}
});
// Begin loading
img.attr('src', e.target.href);
});
// Fade the image out; it will be deleted when another one is previewed
$('a.image-preview').on('mouseout', function() {
$('#image-preview-window').fadeOut('fast');
});
});

View File

@@ -0,0 +1,30 @@
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
// Inteface filtering
$('input.interface-filter').on('input', function() {
var filter = new RegExp(this.value);
for (interface of $(this).closest('form').find('tbody > tr')) {
// Slice off 'interface_' at the start of the ID
if (filter && filter.test(interface.id.slice(10))) {
// Match the toggle in case the filter now matches the interface
$(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked'));
$(interface).show();
} else {
// Uncheck to prevent actions from including it when it doesn't match
$(interface).find('input:checkbox[name=pk]').prop('checked', false);
$(interface).hide();
}
}
});

View File

@@ -2,11 +2,17 @@ import django_filters
from django.db.models import Q
from dcim.models import Device
from extras.filters import CustomFieldFilterSet
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Secret, SecretRole
__all__ = (
'SecretFilter',
'SecretRoleFilter',
)
class SecretRoleFilter(NameSlugSearchFilterSet):
class Meta:
@@ -14,7 +20,7 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class SecretFilter(CustomFieldFilterSet):
class SecretFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'

View File

@@ -0,0 +1,92 @@
from django.test import TestCase
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from secrets.filters import *
from secrets.models import Secret, SecretRole
class SecretRoleTestCase(TestCase):
queryset = SecretRole.objects.all()
filterset = SecretRoleFilter
@classmethod
def setUpTestData(cls):
roles = (
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'),
)
SecretRole.objects.bulk_create(roles)
def test_id(self):
id_list = self.queryset.values_list('id', flat=True)[:2]
params = {'id': [str(id) for id in id_list]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self):
params = {'name': ['Secret Role 1', 'Secret Role 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['secret-role-1', 'secret-role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class SecretTestCase(TestCase):
queryset = Secret.objects.all()
filterset = SecretFilter
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
devices = (
Device(device_type=device_type, name='Device 1', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 2', site=site, device_role=device_role),
Device(device_type=device_type, name='Device 3', site=site, device_role=device_role),
)
Device.objects.bulk_create(devices)
roles = (
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'),
)
SecretRole.objects.bulk_create(roles)
secrets = (
Secret(device=devices[0], role=roles[0], name='Secret 1', plaintext='SECRET DATA'),
Secret(device=devices[1], role=roles[1], name='Secret 2', plaintext='SECRET DATA'),
Secret(device=devices[2], role=roles[2], name='Secret 3', plaintext='SECRET DATA'),
)
# Must call save() to encrypt Secrets
for s in secrets:
s.save()
def test_name(self):
params = {'name': ['Secret 1', 'Secret 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_id__in(self):
id_list = self.queryset.values_list('id', flat=True)[:2]
params = {'id__in': ','.join([str(id) for id in id_list])}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self):
roles = SecretRole.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'role': [roles[0].slug, roles[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device(self):
devices = Device.objects.all()[:2]
params = {'device_id': [devices[0].pk, devices[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'device': [devices[0].name, devices[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@@ -9,6 +9,7 @@
<link rel="stylesheet" href="{% static 'jquery-ui-1.12.1/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'select2-4.0.5/css/select2.min.css' %}">
<link rel="stylesheet" href="{% static 'select2-bootstrap-0.1.0-beta.10/select2-bootstrap.min.css' %}">
<link rel="stylesheet" href="{% static 'flatpickr-4.6.3/themes/light.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}?v{{ settings.VERSION }}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
<meta charset="UTF-8">
@@ -69,6 +70,7 @@
<script src="{% static 'jquery-ui-1.12.1/jquery-ui.min.js' %}"></script>
<script src="{% static 'bootstrap-3.4.1-dist/js/bootstrap.min.js' %}"></script>
<script src="{% static 'select2-4.0.5/js/select2.min.js' %}"></script>
<script src="{% static 'flatpickr-4.6.3/flatpickr.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">

View File

@@ -125,58 +125,7 @@
<div class="panel-heading">
<strong>Circuits</strong>
</div>
<table class="table table-hover panel-body">
<tr>
<th>Circuit ID</th>
<th>Type</th>
<th>Tenant</th>
<th>A Side</th>
<th>Z Side</th>
<th>Description</th>
</tr>
{% for c in circuits %}
<tr>
<td>
<a href="{% url 'circuits:circuit' pk=c.pk %}">{{ c.cid }}</a>
</td>
<td>
<a href="{% url 'circuits:circuit_list' %}?type={{ c.type.slug }}">{{ c.type }}</a>
</td>
<td>
{% if c.tenant %}
<a href="{% url 'tenancy:tenant' slug=c.tenant.slug %}">{{ c.tenant }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.termination_a %}
<a href="{% url 'dcim:site' slug=c.termination_a.site.slug %}">{{ c.termination_a.site }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.termination_z %}
<a href="{% url 'dcim:site' slug=c.termination_z.site.slug %}">{{ c.termination_z.site }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>
{% if c.description %}
{{ c.description }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" class="text-muted">None</td>
</tr>
{% endfor %}
</table>
{% include 'inc/table.html' with table=circuits_table %}
{% if perms.circuits.add_circuit %}
<div class="panel-footer text-right noprint">
<a href="{% url 'circuits:circuit_add' %}?provider={{ provider.pk }}" class="btn btn-xs btn-primary">
@@ -185,6 +134,7 @@
</div>
{% endif %}
</div>
{% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
</div>
</div>
{% include 'inc/modal.html' with modal_name='graphs' %}

View File

@@ -10,7 +10,10 @@
<div class="col-md-4 col-md-offset-1 text-center">
<h4>Near End</h4>
</div>
<div class="col-md-4 col-md-offset-3 text-center">
<div class="col-md-3 text-center">
{% if total_length %}<h5>Total length: {{ total_length|floatformat:"-2" }} Meters<h5>{% endif %}
</div>
<div class="col-md-4 text-center">
<h4>Far End</h4>
</div>
</div>

View File

@@ -556,6 +556,9 @@
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
</div>
<div class="col-md-2 pull-right noprint">
<input class="form-control interface-filter" type="text" placeholder="Filter" title="RegEx-enabled" style="height: 23px" />
</div>
</div>
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
@@ -900,19 +903,8 @@ function toggleConnection(elem) {
$(".cable-toggle").click(function() {
return toggleConnection($(this));
});
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
</script>
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/graphs.js' %}?v{{ settings.VERSION }}"></script>
<script src="{% static 'js/secrets.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@@ -52,10 +52,10 @@
<script type="text/javascript">
$(document).ready(function() {
$.ajax({
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors",
url: "{% url 'dcim-api:device-napalm' pk=device.pk %}?method=get_lldp_neighbors_detail",
dataType: 'json',
success: function(json) {
$.each(json['get_lldp_neighbors'], function(iface, neighbors) {
$.each(json['get_lldp_neighbors_detail'], function(iface, neighbors) {
var neighbor = neighbors[0];
var row = $('#' + iface.split(".")[0].replace(/([\/:])/g, "\\$1"));
@@ -69,8 +69,8 @@ $(document).ready(function() {
}
// Clean up hostnames/interfaces learned via LLDP
var neighbor_host = neighbor['hostname'] || ""; // sanitize hostname if it's null to avoid breaking the split func
var neighbor_port = neighbor['port'] || ""; // sanitize port if it's null to avoid breaking the split func
var neighbor_host = neighbor['remote_system_name'] || ""; // sanitize hostname if it's null to avoid breaking the split func
var neighbor_port = neighbor['remote_port'] || ""; // sanitize port if it's null to avoid breaking the split func
var lldp_device = neighbor_host.split(".")[0]; // Strip off any trailing domain name
var lldp_interface = neighbor_port.split(".")[0]; // Strip off any trailing subinterface ID

View File

@@ -64,6 +64,8 @@
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='interface' %}?return_url={{ device.get_absolute_url }}">Interface</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-server-port' %}?return_url={{ device.get_absolute_url }}">Console Server Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='console-port' %}?return_url={{ device.get_absolute_url }}">Console Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='front-port' %}?return_url={{ device.get_absolute_url }}">Front Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='rear-port' %}?return_url={{ device.get_absolute_url }}">Rear Port</a></li>
<li><a href="{% url 'dcim:frontport_connect' termination_a_id=frontport.pk termination_b_type='circuit-termination' %}?return_url={{ device.get_absolute_url }}">Circuit Termination</a></li>

View File

@@ -48,6 +48,9 @@
<td class="text-nowrap">
{% if iface.cable %}
<a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
{% if iface.cable.color %}
<span class="inline-color-block" style="background-color: #{{ iface.cable.color }}">&nbsp;</span>
{% endif %}
<a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i>
</a>

View File

@@ -112,7 +112,9 @@
{% if utilization %}
<td>
{{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
{% if powerfeed.available_power > 0 %}
{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
{% endif %}
</td>
{% else %}
<td class="text-muted">N/A</td>
@@ -121,6 +123,18 @@
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if powerfeed.comments %}
{{ powerfeed.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div>
<div class="col-md-6">
<div class="panel panel-default">

View File

@@ -251,25 +251,28 @@
<div class="panel-heading">
<strong>Rack Groups</strong>
</div>
{% if rack_groups %}
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<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 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>
</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="panel-body text-muted">
None
</div>
{% endif %}
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<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 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>
</td>
</tr>
{% endfor %}
<tr>
<td><i class="fa fa-fw fa-folder-o"></i> All racks</td>
<td>{{ stats.rack_count }}</td>
<td class="text-right noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ site.slug }}" class="btn btn-xs btn-primary" title="View elevations">
<i class="fa fa-eye"></i>
</a>
</td>
</tr>
</table>
</div>
<div class="panel panel-default">
<div class="panel-heading">

View File

@@ -112,7 +112,7 @@
{% if configcontext.roles.all %}
<ul>
{% for role in configcontext.roles.all %}
<li><a href="{{ role.get_absolute_url }}">{{ role }}</a></li>
<li><a href="{% url 'dcim:device_list' %}?role={{ role.slug }}">{{ role }}</a></li>
{% endfor %}
</ul>
{% else %}

View File

@@ -10,7 +10,7 @@
<tr{% if not attachment.size %} class="danger"{% endif %}>
<td>
<i class="fa fa-image"></i>
<a href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
</td>
<td>{{ attachment.size|filesizeformat }}</td>
<td>{{ attachment.created }}</td>

View File

@@ -10,7 +10,7 @@
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="{% url 'home' %}">
<img src="{% static 'img/netbox_logo.png' %}" />
<img src="{% static 'img/netbox_logo.svg' %}" height="30" />
</a>
</div>
<div id="navbar" class="navbar-collapse collapse">

View File

@@ -40,6 +40,7 @@
</div>
<h1>{% block title %}{{ aggregate }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=aggregate %}
{% include 'ipam/inc/toggle_available.html' %}
<div class="pull-right noprint">
{% custom_links aggregate %}
</div>

View File

@@ -0,0 +1,9 @@
{% load helpers %}
{% if show_available is not None %}
<div class="pull-right">
<div class="btn-group" role="group">
<a href="{{ request.path }}{% querystring request show_available='true' %}" class="btn btn-default{% if show_available %} active disabled{% endif %}">Show available</a>
<a href="{{ request.path }}{% querystring request show_available='false' %}" class="btn btn-default{% if not show_available %} active disabled{% endif %}">Hide available</a>
</div>
</div>
{% endif %}

View File

@@ -160,7 +160,7 @@
{% if duplicate_ips_table.rows %}
{% include 'panel_table.html' with table=duplicate_ips_table heading='Duplicate IP Addresses' panel_class='danger' %}
{% endif %}
{% include 'panel_table.html' with table=related_ips_table heading='Related IP Addresses' panel_class='default noprint' %}
{% include 'utilities/obj_table.html' with table=related_ips_table table_template='panel_table.html' heading='Related IP Addresses' panel_class='default noprint' %}
</div>
</div>
{% endblock %}

View File

@@ -24,8 +24,8 @@
<div class="panel panel-default">
<div class="panel-heading"><strong>Select IP Address</strong></div>
<div class="panel-body">
{% render_field form.vrf %}
{% render_field form.address %}
{% render_field form.vrf_id %}
{% render_field form.q %}
</div>
</div>
</div>

View File

@@ -53,6 +53,7 @@
</div>
<h1>{% block title %}{{ prefix }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=prefix %}
{% include 'ipam/inc/toggle_available.html' %}
<div class="pull-right noprint">
{% custom_links prefix %}
</div>

View File

@@ -12,9 +12,11 @@
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
<a href="{% url 'user:profile' %}">Profile</a>
</li>
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
<a href="{% url 'user:change_password' %}">Change Password</a>
</li>
{% if not request.user.ldap_username %}
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
<a href="{% url 'user:change_password' %}">Change Password</a>
</li>
{% endif %}
<li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
<a href="{% url 'user:token_list' %}">API Tokens</a>
</li>

View File

@@ -10,6 +10,7 @@
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
<div class="panel-heading">
<div class="pull-right noprint">
<a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
{% if perms.users.change_token %}
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
{% endif %}
@@ -17,7 +18,8 @@
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% endif %}
</div>
<i class="fa fa-key"></i> {{ token.key }}
<i class="fa fa-key"></i>
<span id="token_{{ token.pk }}">{{ token.key }}</span>
{% if token.is_expired %}
<span class="label label-danger">Expired</span>
{% endif %}
@@ -66,3 +68,9 @@
</div>
</div>
{% endblock %}
{% block javascript %}
<script type="text/javascript">
new ClipboardJS('.copy-token');
</script>
{% endblock %}

View File

@@ -1,5 +1,6 @@
{% extends '_base.html' %}
{% load custom_links %}
{% load static %}
{% load helpers %}
{% block header %}
@@ -253,6 +254,9 @@
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
</button>
</div>
<div class="col-md-2 pull-right noprint">
<input class="form-control interface-filter" type="text" placeholder="Filter" title="RegEx-enabled" style="height: 23px" />
</div>
</div>
<table id="interfaces_table" class="table table-hover table-headings panel-body component-list">
<thead>
@@ -312,18 +316,5 @@
{% endblock %}
{% block javascript %}
<script type="text/javascript">
// Toggle the display of IP addresses under interfaces
$('button.toggle-ips').click(function() {
var selected = $(this).attr('selected');
if (selected) {
$('#interfaces_table tr.ipaddresses').hide();
} else {
$('#interfaces_table tr.ipaddresses').show();
}
$(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
return false;
});
</script>
<script src="{% static 'js/interface_toggles.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@@ -1,11 +1,17 @@
import django_filters
from django.db.models import Q
from extras.filters import CustomFieldFilterSet
from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Tenant, TenantGroup
__all__ = (
'TenantFilter',
'TenantGroupFilter',
)
class TenantGroupFilter(NameSlugSearchFilterSet):
class Meta:
@@ -13,7 +19,7 @@ class TenantGroupFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug']
class TenantFilter(CustomFieldFilterSet):
class TenantFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter(
field_name='id',
lookup_expr='in'

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