Compare commits

...

421 Commits

Author SHA1 Message Date
Jeremy Stretch
fe20c30a81 Fix parameters for ObjectChangeTestCase 2020-07-23 14:57:35 -04:00
Jeremy Stretch
7eb688bdcd Release v2.9-beta1 2020-07-23 14:46:06 -04:00
Jeremy Stretch
22ee6703ad Merge branch 'develop' into develop-2.9 2020-07-23 14:35:34 -04:00
Jeremy Stretch
b734c72be7 Fix VirtualChassis search by name 2020-07-23 14:32:30 -04:00
Jeremy Stretch
9bcfefa31e Fix ordering for Tags 2020-07-23 14:28:22 -04:00
Jeremy Stretch
37706f1c87 Use RestrictedQuerySet for default Tag manager 2020-07-23 14:20:56 -04:00
Jeremy Stretch
78bb2e12fe Clean up migration output 2020-07-23 14:03:19 -04:00
Jeremy Stretch
84d4b2db77 Clean up resolution of HTTP verbs to permission actions 2020-07-23 13:53:36 -04:00
Jeremy Stretch
b47a9f251d Change ObjectChangeFilterForm user field to be API-driven 2020-07-23 13:47:19 -04:00
Jeremy Stretch
46ebeba28f Set default ordering for user and group API endpoints 2020-07-23 13:46:40 -04:00
Jeremy Stretch
cb36f9fdb3 Remove restriction enforcement from RestrictedQuerySet 2020-07-23 12:48:03 -04:00
Jeremy Stretch
d23f97abc8 Merge branch 'develop' into develop-2.9 2020-07-22 17:11:35 -04:00
Jeremy Stretch
380d30e612 Add filters for ObjectPermissions 2020-07-22 16:34:16 -04:00
Jeremy Stretch
4f54ffa9aa Admin UI cleanup 2020-07-22 16:00:28 -04:00
Jeremy Stretch
7cdb0cf560 Allow the assignment of ObjectPermissions to users, groups, and permissions 2020-07-22 15:25:06 -04:00
Jeremy Stretch
798810b3dd Changelog for #4877 2020-07-22 14:41:39 -04:00
Jeremy Stretch
1fcefc486c Merge pull request #4879 from netbox-community/4877-users-api-endpoint
4877 users api endpoint
2020-07-22 14:35:14 -04:00
Jeremy Stretch
79f1248119 Add filter tests for group, users 2020-07-22 13:58:12 -04:00
Jeremy Stretch
2cc4f032b0 Correct FilterSet naming 2020-07-22 13:48:04 -04:00
Jeremy Stretch
64a3bd37e7 Move EXEMPT_EXCLUDE_MODELS to settings; add Group and User models 2020-07-22 12:13:07 -04:00
Jeremy Stretch
b4cf85149b Add tests for users and groups API endpoints 2020-07-22 12:04:14 -04:00
Jeremy Stretch
788f8c9a1c Add view_namespace attrbiute to APITestCase to override model's app_label 2020-07-22 11:51:10 -04:00
Jeremy Stretch
e9199d6ca5 Look for auth model serializers in users app 2020-07-22 11:50:20 -04:00
Jeremy Stretch
1ac215bf87 Introduce API endpoints for Users and Groups 2020-07-22 11:21:51 -04:00
Jeremy Stretch
3e6b257fa0 Exclude ObjectPermissions API endpoint from EXEMPT_VIEW_PERMISSIONS 2020-07-21 17:39:56 -04:00
Jeremy Stretch
a3d1ee474c Clean up v2.9 release notes 2020-07-21 16:58:31 -04:00
Jeremy Stretch
1714902f88 Merge branch 'develop' into develop-2.9 2020-07-21 12:57:02 -04:00
Jeremy Stretch
c6fd6ab329 Remove cacheops monkey patch for RestrictedQuerySet 2020-07-20 16:54:24 -04:00
Jeremy Stretch
0ef016db07 Update installation docs 2020-07-20 16:53:04 -04:00
Jeremy Stretch
08a5e82afc Merge pull request #4852 from glennmatthews/gfm-2006-continued
Some additional fixes to scripts/reports background running (2.9)
2020-07-20 13:10:56 -04:00
Jeremy Stretch
8514a5427c Update related object counts to use dunders in accessor 2020-07-20 13:00:33 -04:00
Jeremy Stretch
de6202c160 Merge pull request #4861 from netbox-community/django-31
Upgrade to Django 3.1 (v2.9)
2020-07-20 12:47:49 -04:00
Jeremy Stretch
0f679e1f03 Closes #4871: Specify ordering for querysets using annotate() to count related objects 2020-07-20 12:07:19 -04:00
Jeremy Stretch
39dc1f882a Merge branch 'develop-2.9' into django-31 2020-07-20 11:15:01 -04:00
Jeremy Stretch
5b43fa0e12 Upgrade Django to 3.1RC1 2020-07-20 11:10:55 -04:00
Jeremy Stretch
ba50bfa939 Address some of the django_tables2 deprecation warnings 2020-07-16 16:52:45 -04:00
Jeremy Stretch
16f44305e4 Update static dependencies 2020-07-16 15:22:31 -04:00
Jeremy Stretch
1dbf776279 Fix handling of ProtectedError exceptions 2020-07-16 13:45:02 -04:00
Jeremy Stretch
bdf41451eb Pin Django version to 3.1-beta1 for v2.9 beta 2020-07-16 13:04:19 -04:00
Jeremy Stretch
82cd24a7de Remove deprecated ifequal template tags 2020-07-16 13:01:31 -04:00
Jeremy Stretch
21a750e8ec Change Postgres-specific JSONField to stock Django field 2020-07-16 12:02:49 -04:00
Jeremy Stretch
68ecddccdb Convert NullBooleanField to BooleanField(null=True) 2020-07-16 11:56:35 -04:00
Jeremy Stretch
8dd41b771e Update import locations for Django 3.1 2020-07-16 11:54:08 -04:00
Jeremy Stretch
a4829198ff Update dependencies for v2.9 2020-07-16 11:28:19 -04:00
Jeremy Stretch
5cfc4ec3a0 Merge pull request #4858 from netbox-community/4857-remove-legacy-choice-ids
Closes #4857: Drop support for legacy numeric choice field values
2020-07-15 17:07:42 -04:00
Jeremy Stretch
19d0d6ff10 Closes #4857: Drop support for legacy numeric choice field values 2020-07-15 16:54:33 -04:00
Jeremy Stretch
7461e76606 Fix regex for Python 3.7 2020-07-15 16:19:30 -04:00
Glenn Matthews
d8b0a11a49 Save JobResult even for a script run with commit=False 2020-07-15 12:58:01 -04:00
Jeremy Stretch
1291fc4187 Add missing changelog view tests 2020-07-14 16:31:55 -04:00
Jeremy Stretch
04d8ab3792 Clean up device component creation forms 2020-07-14 15:51:13 -04:00
Jeremy Stretch
cf0e31ac0f Introduce BulkRenameObjectsViewTestCase 2020-07-14 13:19:00 -04:00
Jeremy Stretch
4458ce69df Enforce constrained permissions when bulk renaming objects 2020-07-14 13:18:22 -04:00
Jeremy Stretch
81ed03575d Rename BulkCreateObjectsViewTestCase to CreateMultipleObjectsViewTestCase 2020-07-14 11:08:59 -04:00
Glenn Matthews
49c6bee6d7 Fix jobresult success label spelling 2020-07-14 09:45:36 -04:00
Jeremy Stretch
d47ea04ec4 Add "add interfaces" button to virtual machine view 2020-07-13 16:59:46 -04:00
Jeremy Stretch
d5a5a4a8d1 Skip report/script execution tests if RQ worker is not running 2020-07-13 16:48:44 -04:00
Jeremy Stretch
9f7ed25e74 Add assigned IP addresses and VLANs to interface tables 2020-07-13 16:18:17 -04:00
Jeremy Stretch
013a2a35e0 Add cluster and virtual machine filters to VM interfaces list 2020-07-13 15:43:54 -04:00
Jeremy Stretch
9cece39ee9 Remove incorrect bulk edit test data 2020-07-13 15:26:06 -04:00
Jeremy Stretch
05aa008ce1 #2006: Prevent script/report execution if no RQ worker is running 2020-07-13 15:11:58 -04:00
Jeremy Stretch
e53839ca2a Add progress counter to VM interface replication migration 2020-07-13 13:13:37 -04:00
Jeremy Stretch
f83ec7256f Add model documentation for VM interfaces 2020-07-10 13:01:02 -04:00
Jeremy Stretch
96b3de7916 Remove extraneous queryset filter 2020-07-10 10:27:07 -04:00
Jeremy Stretch
b1686c2db9 Fix bulk editing, deletion of VM interfaces 2020-07-10 10:26:43 -04:00
Jeremy Stretch
c8418fe550 Fix RestrictedQuerySet evaluation for VLAN CSV export 2020-07-10 10:09:51 -04:00
Jeremy Stretch
9f025747a7 Tweak queryset for Service views to prefetch related IPs with unrestricted() 2020-07-10 09:56:23 -04:00
Jeremy Stretch
59091efa86 Closes #4840: Enable change logging for config contexts 2020-07-09 17:09:03 -04:00
Jeremy Stretch
8d7001fe56 Move abstract ChangeLoggedModel under extras 2020-07-09 17:07:25 -04:00
Jeremy Stretch
c3a7939a77 Merge branch 'develop' into develop-2.9 2020-07-09 16:38:37 -04:00
Jeremy Stretch
4f00b5af4a Miscellaneous RestrictedQuerySet cleanup 2020-07-09 15:11:18 -04:00
Jeremy Stretch
26e81546eb Fix REST API version calculation 2020-07-09 13:08:13 -04:00
Jeremy Stretch
1692fbf5d8 #4837: Fix API tests 2020-07-09 12:06:13 -04:00
Jeremy Stretch
15525392a2 Closes #4837: Use dynamic form widget for relationships to MPTT objects 2020-07-09 09:50:01 -04:00
Jeremy Stretch
b535608519 Refactor imports 2020-07-09 09:13:58 -04:00
Jeremy Stretch
0a44ed1355 Fix permissions evaluation for session-authenticated API requests 2020-07-08 17:51:25 -04:00
Jeremy Stretch
ccdbf820ba Use monospaced font for ObjectPermission constraints 2020-07-08 16:07:07 -04:00
Jeremy Stretch
56c0b48302 Enable filtering ObjectPermissions by enabled, action, and object type 2020-07-08 14:38:19 -04:00
Jeremy Stretch
4c2fdf3b1c Admin UI should use unrestricted queryset for ObjectPermissions 2020-07-08 13:59:57 -04:00
Jeremy Stretch
4eddec4b1e #4806: Add url field to ObjectPermissionSerializer 2020-07-08 13:57:50 -04:00
Jeremy Stretch
39248f9e2f Enable bulk enabling/disabling of permissions 2020-07-08 13:54:09 -04:00
Jeremy Stretch
fac0da224a Add name, enabled fields to ObjectPermission 2020-07-08 13:44:19 -04:00
Jeremy Stretch
6e50ed084d Fix tags display 2020-07-08 13:10:40 -04:00
Jeremy Stretch
bf7bd68b6a Fix tag population for object cloning 2020-07-08 13:07:53 -04:00
Jeremy Stretch
5fd5dbab7b Merge branch 'develop' into develop-2.9 2020-07-08 13:04:57 -04:00
Jeremy Stretch
85b284be54 Fix permissions evaluation for available IPs endpoint 2020-07-08 09:31:10 -04:00
Jeremy Stretch
02a6e2190f Monkey-patch cacheops' _pre_save() receiver to tweak sender's QuerySet 2020-07-07 17:09:41 -04:00
Jeremy Stretch
847fbfd71a Fix unrestricted querysets 2020-07-07 16:46:57 -04:00
Jeremy Stretch
327a93136a TreeManager should provide an unrestricted QuerySet for MPTT use 2020-07-07 16:09:47 -04:00
Jeremy Stretch
353d88f0a6 Use unrestricted querysets for prefetching related objects 2020-07-07 15:45:46 -04:00
Jeremy Stretch
25e1319864 Update filter tests to use unrestricted querysets 2020-07-07 14:26:14 -04:00
Jeremy Stretch
88033c0801 Fix queryset for get_peer_termination() 2020-07-07 14:13:58 -04:00
Jeremy Stretch
f6d05f3906 Tweak ObjectChange queryset for cleanup 2020-07-07 13:54:49 -04:00
Jeremy Stretch
2fbe138c71 Refactor APITestCase to provide dynamic queryset specification 2020-07-07 13:13:19 -04:00
Jeremy Stretch
a2d957ba0d Use stock manager as default for Tag model 2020-07-07 12:18:14 -04:00
Jeremy Stretch
15f5719f44 Remove unused class IPAddressInterfaceSerializer 2020-07-06 16:51:23 -04:00
Jeremy Stretch
924f319343 Closes #4806: Add a url field to all API serializers 2020-07-06 16:41:08 -04:00
Jeremy Stretch
9a075130f1 Changelog for #2006 2020-07-06 14:02:06 -04:00
Jeremy Stretch
6d0281adc8 Merge pull request #4799 from netbox-community/2006-scripts-reports-background
2006 scripts reports background
2020-07-06 13:45:12 -04:00
Jeremy Stretch
592ad18317 Provide a default view for Report instances 2020-07-06 13:30:13 -04:00
Jeremy Stretch
1f905e72d9 Fix stray reference to LOG_LEVEL_CODES 2020-07-06 11:51:28 -04:00
Jeremy Stretch
e02936a44a Fix reports API test case 2020-07-06 11:15:20 -04:00
Jeremy Stretch
4ea4112490 Fix up schema migration; PEP8 cleanup 2020-07-06 10:44:36 -04:00
John Anderson
6547a2bc50 merge conflict 2020-07-06 02:10:52 -04:00
John Anderson
4a74927fa2 Merge branch 'develop-2.9' into 2006-scripts-reports-background 2020-07-06 02:06:53 -04:00
John Anderson
41f92ef8e6 review updates 2020-07-06 02:00:16 -04:00
John Anderson
f092c107b5 PR review updates 2020-07-06 02:00:16 -04:00
Jeremy Stretch
f4c14d4854 Replace get_component_template_actions() with ButtonsColumn 2020-07-02 16:47:28 -04:00
Jeremy Stretch
1ed152cd49 Fix unrestricted queries for Tags 2020-07-02 16:25:13 -04:00
Jeremy Stretch
e635dc1fb3 Arrange device components within tabs 2020-07-02 15:10:56 -04:00
Jeremy Stretch
7b33fac71d Display component templates under tabs 2020-07-02 14:35:36 -04:00
Jeremy Stretch
a1e5a8b86a Introduce badge template tag 2020-07-02 14:29:23 -04:00
Jeremy Stretch
92c889ef9e #4416: Provide bulk rename ability for device component templates 2020-07-02 13:58:53 -04:00
Jeremy Stretch
eb2da300b0 Fix form initialization for interface import 2020-07-02 13:27:54 -04:00
Jeremy Stretch
6abb7e8f4d #4721: Tweak migrations to ensure Interface.device cannot be null 2020-07-02 13:12:37 -04:00
Jeremy Stretch
1f9cdc71d4 Move device and device_type ForeignKeys to abstract component models 2020-07-02 13:07:32 -04:00
Jeremy Stretch
d03d302eef Closes #4817: Standardize device/VM component name field to 64 characters 2020-07-02 12:08:19 -04:00
Jeremy Stretch
c5362f5931 Fix permissions evaluation for nonstandard tests 2020-07-02 11:43:03 -04:00
Jeremy Stretch
f28bde179e Extend label field to all device components 2020-07-02 11:18:08 -04:00
Jeremy Stretch
f98fa364c0 Merge branch 'develop-2.9' into 2006-scripts-reports-background 2020-07-02 11:00:59 -04:00
Jeremy Stretch
8d7377ba04 Merge branch 'develop' into develop-2.9 2020-07-02 10:01:01 -04:00
Jeremy Stretch
fa0c7a76cb Fix permission evaluation for add console/power port buttons 2020-07-01 15:14:06 -04:00
Jeremy Stretch
06ae424b80 #4416: Add bulk rename view for InventoryItem 2020-07-01 15:12:05 -04:00
Jeremy Stretch
e4b5045ec7 #4416: Add bulk_add view for InventoryItems 2020-07-01 14:55:11 -04:00
Jeremy Stretch
7e3e18faea #4416: Add individual & changelog views for InventoryItem 2020-07-01 14:46:12 -04:00
Jeremy Stretch
225b6c6958 Fix collection of assigned IPs when editing a device 2020-07-01 14:23:21 -04:00
Jeremy Stretch
57b73c485f #4416: Remove individual view for extras.Tag 2020-07-01 14:21:51 -04:00
Jeremy Stretch
c484fa99e2 Introduce ButtonsColumn to reduce boilerplate and standardize organizational object links 2020-07-01 13:47:01 -04:00
Jeremy Stretch
8959d2e0a7 #4416: Add individual delete views for organizational objects 2020-07-01 12:08:26 -04:00
Jeremy Stretch
4613b69c28 Extend GetReturnURLMixin to automatically resolve default return URL for querysets 2020-07-01 11:50:31 -04:00
Jeremy Stretch
7fab929194 Fix evaluation of empty label_pattern 2020-06-30 16:30:54 -04:00
Jeremy Stretch
89ea34015d Enable bulk editing of device component labels 2020-06-30 16:15:17 -04:00
Jeremy Stretch
88e3ac30b6 Closes #4807: Add bulk edit ability for device bay templates 2020-06-30 15:22:30 -04:00
Jeremy Stretch
52a13b1960 Closes #4793: Add description field to device component templates 2020-06-30 15:12:53 -04:00
John Anderson
1d922a1848 fix previous job result deletion 2020-06-30 09:29:50 -04:00
Jeremy Stretch
af778f8fca TagFilter should call unrestricted() on its queryset 2020-06-29 16:07:14 -04:00
Jeremy Stretch
15f32bdd73 Wrap ComponentTraceMixin in a parent class 2020-06-29 15:14:12 -04:00
Jeremy Stretch
36498c9dd2 Base manager for Tag should use RestrictedQuerySet 2020-06-29 14:57:29 -04:00
Jeremy Stretch
66703d8963 Fix evaluation of RestrictedQuerySet 2020-06-29 14:42:37 -04:00
Jeremy Stretch
71812d1bd5 Fix evaluation of RestrictedQuerySet 2020-06-29 14:41:43 -04:00
Jeremy Stretch
5ed6136915 Introduce ComponentTraceMixin to minimize boilerplate 2020-06-29 14:40:36 -04:00
John Anderson
f48a079ae6 fix tests and cleanup 2020-06-29 14:34:42 -04:00
Jeremy Stretch
a47a100cb7 Fix unrestricted evaluations of RestrictedQuerySet 2020-06-29 13:31:26 -04:00
Jeremy Stretch
9ea4f82eaa Prefetch tagged VLANs for VMInterfaces 2020-06-29 12:18:59 -04:00
Jeremy Stretch
617e20af0b Standardize VMInterfaceTest 2020-06-29 12:06:36 -04:00
Jeremy Stretch
89ff59d048 Add graphs endpoint to VMInterfaceViewSet 2020-06-29 12:05:00 -04:00
Jeremy Stretch
6ecbf45974 Fix evaluation of RestrictedQuerySets 2020-06-29 11:48:36 -04:00
Jeremy Stretch
eb45ad600e Fix evaluation of RestrictedQuerySets 2020-06-29 11:35:13 -04:00
Jeremy Stretch
10e6b6ca66 Fix RestrictedQuerySet evaluation in tests 2020-06-29 11:27:23 -04:00
Jeremy Stretch
5732466e56 Signal receiver should call unrestricted() 2020-06-29 11:07:11 -04:00
Jeremy Stretch
ce55d0c791 Tweak querysets to work with restriction 2020-06-29 10:57:09 -04:00
Jeremy Stretch
6ab4640cdc Update API tests to work with RestrictedQuerySet 2020-06-29 10:39:06 -04:00
Jeremy Stretch
a6b03b8884 Update WritableNestedSerializer to call unrestricted() on RestrictedQuerySets 2020-06-29 10:38:32 -04:00
Jeremy Stretch
0dbe248df8 Call restrict() when retrieving related Graphs 2020-06-29 10:14:43 -04:00
John Anderson
1681dbfa39 refactor migration 2020-06-29 04:22:01 -04:00
John Anderson
3777fbccc3 Implements #2006 - run reports and scripts in the background 2020-06-29 03:50:05 -04:00
Jeremy Stretch
86d1370512 Apply restrict_form_fields() to import views 2020-06-26 16:26:22 -04:00
Jeremy Stretch
8c0adc9c61 Update test methods to call unrestricted() on RestrictedQuerySets 2020-06-26 16:15:21 -04:00
Jeremy Stretch
a452e78fa6 Use unrestricted() when compiling ObjectPermissions for user 2020-06-26 15:28:08 -04:00
Jeremy Stretch
04571ce920 Fix the initial permissions check on create/edit view tests 2020-06-26 15:21:59 -04:00
Jeremy Stretch
40c416618a Link to cable termination objects 2020-06-26 15:13:41 -04:00
Jeremy Stretch
9a1531442a Apply restrict_form_fields() to bulk edit views 2020-06-26 15:11:05 -04:00
Jeremy Stretch
6128ef4b37 Remove redundant ObjectPermissionViewTestCase 2020-06-26 15:00:47 -04:00
Jeremy Stretch
84db1adfaf Fix create, edit view test methods 2020-06-26 14:48:04 -04:00
Jeremy Stretch
2c354c7f86 Fix automatic creation of UserConfig for user created via admin UI 2020-06-26 14:29:24 -04:00
Jeremy Stretch
edc65a6a34 Introduce restrict_form_fields() to automatically restrict field querysets based on user 2020-06-26 13:59:53 -04:00
Jeremy Stretch
8412f9481c Force restriction of RestrictedQuerySet even for superusers 2020-06-26 13:18:12 -04:00
Jeremy Stretch
95965d65c9 Fix some instances where RestrictedQuerySet is evaluated prematurely 2020-06-26 12:22:02 -04:00
Jeremy Stretch
9777f25b9f Set the default action to 'view' for restrict() 2020-06-26 11:57:07 -04:00
Jeremy Stretch
6e3a32567c Move utility functions to utils.py 2020-06-26 11:56:30 -04:00
Jeremy Stretch
319799b5ce Closes #4795: Add bulk disconnect capability for console and power ports 2020-06-25 17:08:51 -04:00
Jeremy Stretch
128327b8a3 Split url_name template filter into viewname() and validated_viewname() 2020-06-25 16:50:35 -04:00
Jeremy Stretch
6f8f3f98b4 Tweak ObjectChangeLogView to work with both restricted and unrestricted querysets 2020-06-25 15:58:13 -04:00
Jeremy Stretch
2e272132b0 Add test method for changelog view 2020-06-25 15:43:47 -04:00
Jeremy Stretch
1dbae5b64c Closes #4792: Add bulk rename capability for console and power ports 2020-06-25 14:18:29 -04:00
Jeremy Stretch
6d23d9ebb7 Merge pull request #4790 from netbox-community/4788-component-views
#4788: Add individual views for device components
2020-06-25 13:38:09 -04:00
Jeremy Stretch
ec9b33ac97 Fix typo 2020-06-25 13:36:54 -04:00
Jeremy Stretch
5aa2a6eefe Add plugin buttons & content to device component views 2020-06-25 13:27:01 -04:00
Jeremy Stretch
103a44991a Changelog for #4788 2020-06-25 12:22:21 -04:00
Jeremy Stretch
0fcdd63941 Linkify components under device view 2020-06-25 12:21:25 -04:00
Jeremy Stretch
8695714c65 Fix device component changelog display 2020-06-25 12:09:56 -04:00
Jeremy Stretch
2001cfe864 Update and simplify device component tables 2020-06-25 12:03:44 -04:00
Jeremy Stretch
3badfd756c Extend DeviceComponentViewTestCase to include GetObjectViewTestCase 2020-06-25 11:04:42 -04:00
Jeremy Stretch
b08d9a5a8e Add individual views for device components 2020-06-25 11:01:18 -04:00
Jeremy Stretch
ecf40e1525 Add/update device component templates 2020-06-25 11:00:25 -04:00
Jeremy Stretch
909ddd653c Extend ObjectView to provide a default get() method 2020-06-25 10:53:00 -04:00
Jeremy Stretch
2f19350ff5 Tweak url_name template filter to work with URLs which need a PK 2020-06-25 10:49:30 -04:00
Jeremy Stretch
68ef5177f0 Introduce template filters for checking dynamic permissions 2020-06-25 10:48:21 -04:00
Jeremy Stretch
ba138de53b Fix display of tags 2020-06-24 16:27:44 -04:00
Jeremy Stretch
2303034c92 Changelog for #2018 2020-06-24 16:22:37 -04:00
Jeremy Stretch
2ceed475d5 Merge pull request #4787 from netbox-community/2018-virtual-chassis-name
#2018: Add name field to VirtualChassis model
2020-06-24 16:14:21 -04:00
Jeremy Stretch
e2398c8c0e Fix signal logic 2020-06-24 15:57:52 -04:00
Jeremy Stretch
36cf40f25c Enable CSV import for virtual chassis 2020-06-24 15:29:25 -04:00
Jeremy Stretch
59c1e34024 Initial work on #2018: Add name to VirtualChassis 2020-06-24 15:21:42 -04:00
Jeremy Stretch
2ac53afd96 Update changelog for #4721 2020-06-24 12:44:00 -04:00
Jeremy Stretch
d60a2d3723 Merge pull request #4781 from netbox-community/4721-virtualmachine-interface
#4721: Move VM interfaces to a separate model (WIP)
2020-06-24 12:10:20 -04:00
Jeremy Stretch
4d2c75a824 Restore ability to assign interface when editing an IPAddress 2020-06-24 11:30:28 -04:00
Jeremy Stretch
99c72c78c1 Update VMInterface view names 2020-06-24 10:09:22 -04:00
Jeremy Stretch
052555c3f7 Add bulk renaming function for VM interfaces 2020-06-24 10:02:40 -04:00
Jeremy Stretch
9a0bc16c86 Update device/VM interface templates 2020-06-24 09:52:22 -04:00
Jeremy Stretch
6663844a86 Rename 'vm_interface' to 'vminterface'; misc cleanup 2020-06-24 09:27:30 -04:00
Jeremy Stretch
d6386f739e Restore interface filtering for IPAddresses 2020-06-24 09:22:35 -04:00
Jeremy Stretch
afda46d587 Fix VMInterface bulk creation 2020-06-23 17:28:34 -04:00
Jeremy Stretch
603c804535 Add VMInterface CSV import view 2020-06-23 17:09:21 -04:00
Jeremy Stretch
fce19a363d Add VMInterface list view 2020-06-23 16:52:05 -04:00
Jeremy Stretch
e3820e93b7 Misc cleanup, renaming 2020-06-23 16:39:43 -04:00
Jeremy Stretch
459e485555 Restore interface assignment for IPAddress CSV import 2020-06-23 16:09:31 -04:00
Jeremy Stretch
548127cc88 Rename VMInterface serializers 2020-06-23 15:46:20 -04:00
Jeremy Stretch
a1b816b403 Remove 'parent' attribute from VMinterface 2020-06-23 15:31:53 -04:00
Jeremy Stretch
5ad5994b9d Update interface view templates 2020-06-23 15:09:32 -04:00
Jeremy Stretch
25d6bbf659 Update view and permission names for VMInterface 2020-06-23 14:48:37 -04:00
Jeremy Stretch
75354a8a78 Rename Interface to VMInterface 2020-06-23 13:21:44 -04:00
Jeremy Stretch
d1bd010e05 Fix Interface tag replication in schema migration 2020-06-23 12:50:22 -04:00
Jeremy Stretch
bb6be8e3d3 Disable editing assigned interface under IPAddress form 2020-06-22 16:36:06 -04:00
Jeremy Stretch
fc2d08c407 Set related_query_name for GenericRelations to IPAddress 2020-06-22 16:27:13 -04:00
Jeremy Stretch
40938f0c8a Retain ip_addresses name for related IPAddress objects 2020-06-22 16:13:18 -04:00
Jeremy Stretch
490dee1fa0 Merge branch 'develop-2.9' into 4721-virtualmachine-interface 2020-06-22 16:04:20 -04:00
Jeremy Stretch
27796bbd08 Add queryset to IPAddressBulkCreateView 2020-06-22 15:58:47 -04:00
Jeremy Stretch
b5d53fa850 Fix schema deconstruction for NaturalOrderingField 2020-06-22 15:49:09 -04:00
Jeremy Stretch
7b24984280 Update IPAddressSerializer 2020-06-22 15:39:57 -04:00
Jeremy Stretch
37564d630a Misc test fixes 2020-06-22 15:22:03 -04:00
Jeremy Stretch
380a5cf8a7 Fix IP choices for DeviceForm 2020-06-22 15:12:35 -04:00
Jeremy Stretch
f2b26282b8 Disable VM interface bulk creation testing 2020-06-22 15:09:16 -04:00
Jeremy Stretch
31bb70d9a2 Fixed IPAM tests 2020-06-22 14:46:25 -04:00
Jeremy Stretch
2608b3f9f3 Separate VM interface view and template 2020-06-22 14:33:53 -04:00
Jeremy Stretch
e76b1f1daa Fix assigned_object field 2020-06-22 13:50:14 -04:00
Jeremy Stretch
6cb31a274f Initial work on #4721 (WIP) 2020-06-22 13:10:56 -04:00
Jeremy Stretch
181bcd70ad Fix schema migrations for device components 2020-06-22 12:01:57 -04:00
Jeremy Stretch
eb8c0539c5 Update REST API changes for #4615 2020-06-18 14:24:50 -04:00
Jeremy Stretch
4f3fde8055 #4615: Add label field to component tables 2020-06-18 13:57:15 -04:00
Jeremy Stretch
c832e3c2c7 #4615: Add missing label field to device bay forms 2020-06-18 13:35:11 -04:00
Jeremy Stretch
88bf183af5 Merge branch 'develop' into develop-2.9 2020-06-18 13:20:32 -04:00
Jeremy Stretch
11a247edc2 Fix up tests 2020-06-18 10:32:22 -04:00
Jeremy Stretch
328d639886 Merge branch 'develop' into develop-2.9 2020-06-17 16:57:17 -04:00
Jeremy Stretch
fd18395f78 Fix queryset for TagBulkImportView 2020-06-17 13:31:26 -04:00
Jeremy Stretch
360c56ec34 Fix permission evaluation for BulkComponentCreateViews 2020-06-17 13:30:00 -04:00
Jeremy Stretch
3890d17c61 Clean up errant references to TagField 2020-06-17 13:29:27 -04:00
Jeremy Stretch
2d4694e72d Merge pull request #4770 from netbox-community/3703-limit-tag-creation
Closes #3703: Restrict tag creation
2020-06-17 12:28:04 -04:00
Jeremy Stretch
54ece346bc DummyQuerySet should be iterable to allow for serialization 2020-06-17 12:20:56 -04:00
Jeremy Stretch
5e71bad5cf Fix serialization of tags upon object deletion 2020-06-17 12:00:50 -04:00
Jeremy Stretch
d1adc5ea9b Update release notes for #3703 2020-06-17 11:29:59 -04:00
Jeremy Stretch
bb755daf8b Update tests 2020-06-17 11:14:41 -04:00
Jeremy Stretch
ef978b2ebf Update TaggedItemTest 2020-06-17 10:50:37 -04:00
Jeremy Stretch
d0f0aef2ef Remove django-taggit-serializer 2020-06-17 10:00:21 -04:00
Jeremy Stretch
448dc1442c Replace TaggitSerializer and TagListSerializerField with TaggedObjectSerializer 2020-06-17 09:57:17 -04:00
Jeremy Stretch
4ae05dddeb Tag should use a stock manager by default 2020-06-16 17:02:03 -04:00
Jeremy Stretch
b318bde76c Fix queryset reference under BulkDeleteView 2020-06-16 16:40:16 -04:00
Jeremy Stretch
c7aa0a2321 Fix prepare_cloned_fields() inefficiency 2020-06-16 15:08:14 -04:00
Jeremy Stretch
58f4e3756c Fix ObjectChangeLogView 2020-06-16 15:04:34 -04:00
Jeremy Stretch
067e89f6a0 Allow unrestricted access to assigned tags 2020-06-16 14:59:22 -04:00
Jeremy Stretch
efed2bc262 Fix restriction violations for rack model 2020-06-16 14:53:51 -04:00
Jeremy Stretch
adf0255bdf Allow unrestricted access to assigned tags 2020-06-16 14:17:40 -04:00
Jeremy Stretch
1e259f3043 Allow unrestricted retrieval of MPTT ancestors 2020-06-16 14:10:56 -04:00
Jeremy Stretch
ffa3a229b5 Fix restriction violations for provider, circuit models 2020-06-16 14:00:43 -04:00
Jeremy Stretch
0f8df8c985 Add unrestricted() to Rack methods 2020-06-16 13:31:51 -04:00
Jeremy Stretch
ed0b38c7a7 Bypass restriction for querysets used for filtering by related object 2020-06-16 13:15:58 -04:00
Jeremy Stretch
fa0ff8be39 Restrict ExportTemplates available via export button 2020-06-16 13:06:03 -04:00
Jeremy Stretch
5d724f6b84 Fix up permissions enforcement for home view, global search 2020-06-16 13:03:38 -04:00
Jeremy Stretch
ffb43a8534 Introduce unrestricted() method on RestrictedQuerySet 2020-06-16 12:20:21 -04:00
Jeremy Stretch
ce5fd7955f Catch and log evaluation of RestrictedQuerySet without calling restrict() 2020-06-16 10:39:15 -04:00
Jeremy Stretch
e917535380 Merge branch 'develop' into develop-2.9 2020-06-15 16:04:32 -04:00
Jeremy Stretch
e905a36fb2 Update tests for tag changes 2020-06-12 11:33:23 -04:00
Jeremy Stretch
7dc4f8d5cc Remove TagField 2020-06-12 10:01:39 -04:00
Jeremy Stretch
da906f48d9 Standardize add, import, and export functionality for tags 2020-06-12 09:48:23 -04:00
Jeremy Stretch
057a022205 Clean up and improve UI view tests 2020-06-11 16:12:50 -04:00
Jeremy Stretch
7a54bd9f2a Changelog and migrations fix for #4615 2020-06-11 13:42:20 -04:00
Jeremy Stretch
9b48a26aef Merge pull request #4723 from jsenecal/4615_interface_label
Closes: #4615 Physical labels
2020-06-11 13:03:46 -04:00
Jonathan Senecal
a37d06064a Add label to DeviceBay models and serializers 2020-06-11 10:19:53 -04:00
Jonathan Senecal
c1eea166c9 Fix tests for LabeledComponentForm
* Added docstring
* Removed redundant tests since refactoration
2020-06-10 22:58:52 -04:00
Jonathan Senecal
25cbab2ea4 Avoid checking field twice on all iterations
Yields a small performance improvement

Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-06-10 22:16:46 -04:00
Jonathan Senecal
cf81a8979f Avoid component_type on subclasses 2020-06-10 22:10:45 -04:00
Jonathan Senecal
e3a8638471 Move __str__() to the abstract class 2020-06-10 22:04:45 -04:00
Jeremy Stretch
d26fcc9918 Annotated blocked TODO items 2020-06-10 16:56:24 -04:00
Jeremy Stretch
8e9dc9f20e Add EditObjectViewTestCase for VirtualChassis 2020-06-10 16:51:35 -04:00
Jonathan Senecal
81d08ac50b Stay consistent with codebase: i vs pos 2020-06-10 16:41:52 -04:00
Jeremy Stretch
e13320f58d Fix permissions enforcement for VirtualChassisEditView 2020-06-10 16:37:35 -04:00
Jeremy Stretch
1f727f565f Adopted fix from #4743 and updated API tests 2020-06-10 16:11:28 -04:00
Jeremy Stretch
4078d9b669 Remove extraneous test 2020-06-10 15:22:49 -04:00
Jonathan Senecal
3b54d6f8e5 No need to reference the model itself 2020-06-10 15:11:47 -04:00
Jeremy Stretch
58b4f6abca Update v2.9 release notes 2020-06-10 15:05:15 -04:00
Jonathan Senecal
f041c762ac Prevent the table cell from rendering empty for interface.label
Co-authored-by: Jeremy Stretch <jeremy.stretch@networktocode.com>
2020-06-10 14:59:23 -04:00
Jeremy Stretch
88ae522c9a Closes #4742: Add tagging for cables, power panels, and rack reservations 2020-06-10 14:55:46 -04:00
Jonathan Senecal
5cdaaed311 Use a single migration for labels 2020-06-10 14:04:55 -04:00
Jonathan Senecal
e0037c7f70 pycodestyle 2020-06-08 23:07:53 -04:00
Jonathan Senecal
4301c06d17 Refactorization into LabeledComponentForm 2020-06-08 23:07:12 -04:00
Jonathan Senecal
90bc1cd951 Test forms and views with labels 2020-06-08 20:04:31 -04:00
Jeremy Stretch
67784c0568 Merge pull request #4713 from netbox-community/4348-ldap-auth-backend
Closes #4348: Introduce LDAPBackend
2020-06-08 17:07:52 -04:00
Jonathan Senecal
892c0e3d8b Leftover fix 2020-06-08 17:00:07 -04:00
Jeremy Stretch
54dd20cdb4 Merge pull request #4733 from netbox-community/4730-api-test-permissions
Closes #4730: Update REST API tests to enforce ObjectPermissions
2020-06-08 16:53:22 -04:00
Jeremy Stretch
2f53411efc Extend assertInstanceEqual() to handle M2M relations to ContentType 2020-06-08 16:32:50 -04:00
Jeremy Stretch
7a858cea23 Extend test_bulk_create_objects() to inspect created objects 2020-06-08 15:58:54 -04:00
Jeremy Stretch
987414ed7b Introduce NestedObjectPermissionSerializer 2020-06-08 15:44:16 -04:00
Jeremy Stretch
047286f9c0 Add a third initial object to VirtualChassisTest 2020-06-08 15:27:55 -04:00
Jeremy Stretch
a2955196af Remove extraneous permission class from SecretRoleViewSet 2020-06-08 15:21:11 -04:00
Jeremy Stretch
62224857f0 Standardize ObjectPermissionTest 2020-06-08 14:01:15 -04:00
Jeremy Stretch
c1a37db871 Fix managers on Graph and ExportTemplate 2020-06-08 13:41:12 -04:00
Jeremy Stretch
a8145fe4c2 Add permission assignment to custom test methods 2020-06-08 13:40:46 -04:00
Jeremy Stretch
3b44e7c1c4 Update API test methods to evaluate permissions assignment 2020-06-08 11:52:29 -04:00
Jeremy Stretch
830fd5f83a Remove duplicate test method 2020-06-08 11:22:12 -04:00
Jeremy Stretch
f83e435a90 Move APIViewTestCases to api.py 2020-06-08 10:46:53 -04:00
Jeremy Stretch
0ebd87bcb9 Merge branch 'develop' into develop-2.9 2020-06-08 10:33:23 -04:00
Jonathan Senecal
286a3e6ca2 Add label to forms, views and templates 2020-06-05 13:59:59 -04:00
Jonathan Senecal
d65cead212 Return an empty list if value is None 2020-06-05 12:34:09 -04:00
Jonathan Senecal
e21cbf2a06 Add the label to the string representation 2020-06-05 11:01:39 -04:00
Jonathan Senecal
1fae9aff0c Add label to *port serializers 2020-06-05 10:42:13 -04:00
Jonathan Senecal
a06d74472d Add label to *ports models 2020-06-05 10:32:59 -04:00
Jonathan Senecal
f8851121ab Add the label to the string representation 2020-06-04 17:11:27 -04:00
Jonathan Senecal
e9f8640ee6 Add label to Interface serializers 2020-06-04 16:50:51 -04:00
Jonathan Senecal
cde1db4436 Add label to interface models 2020-06-04 16:44:25 -04:00
Jeremy Stretch
dc161d9f2f Update LDAP configuration documentation 2020-06-03 15:57:11 -04:00
Jeremy Stretch
040fadb0c3 Move LDAP authentication support to LDAPBackend 2020-06-03 15:42:24 -04:00
Jeremy Stretch
bb1484a444 Dropped backward compatibility for the /admin/webhook-backend-status URL 2020-06-03 14:15:29 -04:00
Jeremy Stretch
b31cc89478 Dropped backward compatibility for 'webhooks' Redis queue 2020-06-03 14:13:18 -04:00
Jeremy Stretch
05c851301e Merge pull request #4705 from netbox-community/554-object-permissions
Closes #554: Implement object-based permissions
2020-06-03 13:29:16 -04:00
Jeremy Stretch
dbf6c0a075 Split ObjectPermission model documentation 2020-06-03 13:20:35 -04:00
Jeremy Stretch
3084d58da1 Add REST API endpoint for ObjectPermissions 2020-06-03 13:08:04 -04:00
Jeremy Stretch
19b57aa1ea Update permissions documentation 2020-06-03 10:03:15 -04:00
Jeremy Stretch
d157818d7e Rename attrs to constraints 2020-06-03 09:43:46 -04:00
Jeremy Stretch
ddcd172af1 Rename content_types to object_types 2020-06-03 09:27:20 -04:00
Jeremy Stretch
19407ba3bc Uodate script and report views to use ObjectPermissionRequiredMixin 2020-06-02 15:40:39 -04:00
Jeremy Stretch
3502398d1d Remove delete_token permission from TokenDeleteView 2020-06-02 15:36:31 -04:00
Jeremy Stretch
205acd2c4d Update VirtualChassis views to support ObjectPermissions 2020-06-02 15:33:41 -04:00
Jeremy Stretch
e463430d51 Change CableCreateView to use ObjectEditView 2020-06-02 15:15:57 -04:00
Jeremy Stretch
cae412d280 Update ObjectImportView to support ObjectPermissions 2020-06-02 14:23:04 -04:00
Jeremy Stretch
a62b98ac50 Admin UI improvements 2020-06-02 13:21:58 -04:00
Jeremy Stretch
7a7634de2d Accomodate custom legacy permission in schema migration 2020-06-02 10:50:58 -04:00
Jeremy Stretch
c6e85970d4 Remove activate_userkey permission 2020-06-02 09:51:56 -04:00
Jeremy Stretch
110bad7041 Update custom napalm_read, napalm_write permissions 2020-06-02 09:46:32 -04:00
Jeremy Stretch
85e932bfc1 Clean up permissions utility functions 2020-06-02 09:31:59 -04:00
Jeremy Stretch
7b01ba9776 Fix external auth permissions test 2020-06-01 16:46:14 -04:00
Jeremy Stretch
32620dd556 Changelog for #554 2020-06-01 16:30:20 -04:00
Jeremy Stretch
76f74f479b Support permission attribute assignment via REMOTE_AUTH_DEFAULT_PERMISSIONS 2020-06-01 16:23:45 -04:00
Jeremy Stretch
e9831442cd Drafted documentation for object-based permissions 2020-06-01 15:28:36 -04:00
Jeremy Stretch
5d4cc5bf3d Fix ordering of group and user fields in ObjectPermission admin 2020-06-01 13:59:58 -04:00
Jeremy Stretch
26d7c21314 Move authentication backends 2020-06-01 13:47:34 -04:00
Jeremy Stretch
a4af270ea8 Restrict querysets for home, search views 2020-06-01 13:36:57 -04:00
Jeremy Stretch
b6c38ceb73 Call permission_is_exempt() to check for exempt permissions 2020-06-01 13:17:59 -04:00
Jeremy Stretch
3a9512f086 Refine queryset restriction logic 2020-06-01 13:09:34 -04:00
Jeremy Stretch
9679557747 Add permission_is_exempt() 2020-06-01 12:31:18 -04:00
Jeremy Stretch
3c334a0238 Update views to restrict all querysets 2020-06-01 11:43:49 -04:00
Jeremy Stretch
5574aaa8cb Tweak restrict() to accept only an action keyword 2020-06-01 10:45:49 -04:00
Jeremy Stretch
e23b2c4c4f Implement RestrictedQuerySet as a manager 2020-05-29 16:27:36 -04:00
Jeremy Stretch
5b6a6fb63e Move restrict_queryset() function to RestrictedQuerySet 2020-05-29 15:09:08 -04:00
Jeremy Stretch
58989b85c8 Introduce restrict_queryset() 2020-05-29 14:12:24 -04:00
Jeremy Stretch
8786bb25c5 Fix instance evaluation 2020-05-29 13:57:38 -04:00
Jeremy Stretch
670139492d Fix permission action evaluation 2020-05-29 13:47:19 -04:00
Jeremy Stretch
5d3cf8074b Add migration for replicating legact permissions to ObjectPermissions 2020-05-29 13:42:38 -04:00
Jeremy Stretch
85c54703ec Improve the admin form for ObjectPermissions 2020-05-29 12:08:51 -04:00
Jeremy Stretch
02687453f2 Add ArrayField on ObjectPermission to store actions 2020-05-29 11:25:13 -04:00
Jeremy Stretch
90828cedae Introduce proxy models for User and Group to organize admin UI 2020-05-29 10:43:12 -04:00
Jeremy Stretch
f65b2278f0 Enable many-to-many model assignment for ObjectPermissions 2020-05-28 15:04:46 -04:00
Jeremy Stretch
bdfc0364d5 Fix up ObjectPermission content type assignment 2020-05-28 14:20:18 -04:00
Jeremy Stretch
65bd3fbddb Remove built-in permission assignment from admin UI 2020-05-28 14:03:08 -04:00
Jeremy Stretch
f8e29ea66a Remove ObjectPermissionManager 2020-05-28 13:47:52 -04:00
Jeremy Stretch
a8ed04c4d2 Expose assigned ObjectPermissions on User instance 2020-05-28 13:25:37 -04:00
Jeremy Stretch
73b7eb0c7f Skip queryset filtering for superusers 2020-05-28 13:25:12 -04:00
Jeremy Stretch
486f1a74ab Standardize base classes for view test cases 2020-05-28 12:05:54 -04:00
Jeremy Stretch
5d36d81ae1 Restore model-level permission tests 2020-05-28 11:08:35 -04:00
Jeremy Stretch
dc56e49410 Introduce resolve_permission() utility function 2020-05-28 10:35:59 -04:00
Jeremy Stretch
ca199cdefe Reduce ObjectPermission creation boilerplate 2020-05-28 10:27:25 -04:00
Jeremy Stretch
b2ba9d68c9 Fix default permissions assignment under RemoteUserBackend 2020-05-28 10:04:19 -04:00
Jeremy Stretch
00ce3588d3 Fix secrets API tests 2020-05-28 09:51:02 -04:00
Jeremy Stretch
814aff78b5 Update ObjectPermission evaluation to support null attrs 2020-05-28 09:39:27 -04:00
Jeremy Stretch
a261d10bfd Fix permissions assignment for SecretTest 2020-05-27 17:10:45 -04:00
Jeremy Stretch
ce46512c74 Fix permission assignment in tests 2020-05-27 16:53:30 -04:00
Jeremy Stretch
fb7446487e Fix up permissions evaluation 2020-05-27 11:31:07 -04:00
Jeremy Stretch
a6a88a0d2e Delete extraneous test case 2020-05-27 11:30:36 -04:00
Jeremy Stretch
4cee506710 Rebase RemoteUserBackend on BaseBackend 2020-05-27 10:52:59 -04:00
Jeremy Stretch
5dddf6846b Disable built-in model permissions 2020-05-27 10:48:56 -04:00
Jeremy Stretch
03da9348e5 Merge branch 'develop-2.9' into 554-object-permissions 2020-05-26 16:42:39 -04:00
Jeremy Stretch
28a14cf5ae Merge branch 'develop' into develop-2.9 2020-05-26 16:39:23 -04:00
Jeremy Stretch
635fefcb5c Update exempted tests 2020-05-22 16:33:56 -04:00
Jeremy Stretch
77a49fa40e Extend bulk import/edit/delete view tests to support object-level permissions 2020-05-22 16:04:43 -04:00
Jeremy Stretch
5273b9d0ee Rename ImportObjectsViewTestCase 2020-05-22 14:57:35 -04:00
Jeremy Stretch
ae7445ee8e Test object permissions for individual/list model views 2020-05-22 14:53:52 -04:00
Jeremy Stretch
3ef4287d57 Add additional_permissions to ObjectPermissionRequiredMixin 2020-05-22 12:41:20 -04:00
Jeremy Stretch
581dc4e070 Enforce object-level permissions for CableTraceView 2020-05-22 12:05:34 -04:00
Jeremy Stretch
1bce148be2 Enforce object-level permissions for ObjectConfigContextView 2020-05-22 11:55:56 -04:00
Jeremy Stretch
eb9147a575 Enforce object-level permissions for DeviceBay population views 2020-05-22 11:52:19 -04:00
Jeremy Stretch
781334b615 Enforce object-level permissions for RackElevationListView 2020-05-22 11:51:04 -04:00
Jeremy Stretch
5282ae2250 Enforce object-level permissions for cluster add/remove devices views 2020-05-22 11:30:46 -04:00
Jeremy Stretch
bae050e689 Replace legacy add/edit secret views with SecretEditView 2020-05-22 11:24:49 -04:00
Jeremy Stretch
ab60a5d73d Enforce object-level permissions for IPAddressAssignView, VLANGroupVLANsView 2020-05-22 09:51:57 -04:00
Jeremy Stretch
71d4b5c5df Enforce object-level permissions for circuit termination swap view 2020-05-22 09:45:29 -04:00
Jeremy Stretch
7e64d3e653 Transition BulkComponentCreateView to use ObjectPermissionRequiredMixin 2020-05-22 09:23:00 -04:00
Jeremy Stretch
e7fde2795f Fix BulkDisconnectView 2020-05-21 16:34:15 -04:00
Jeremy Stretch
f36c797e98 Transition ComponentCreateView to use ObjectPermissionRequiredMixin 2020-05-21 16:28:11 -04:00
Jeremy Stretch
49b780358e Transition BulkRenameView, BulkDisconnectView to use ObjectPermissionRequiredMixin 2020-05-21 16:11:46 -04:00
Jeremy Stretch
af8e1a6472 Strip 'param' indicators from docstrings 2020-05-21 16:00:18 -04:00
Jeremy Stretch
91362b0f82 Transition BulkCreateView to use ObjectPermissionRequiredMixin 2020-05-21 15:53:50 -04:00
Jeremy Stretch
e61fc1f709 Introduce ObjectView to enforce object-level permissions for individual object views 2020-05-21 15:53:00 -04:00
Jeremy Stretch
8fd860a413 Transition BulkDeleteView to use ObjectPermissionRequiredMixin 2020-05-21 15:14:29 -04:00
Jeremy Stretch
82c247f3cf Transition BulkEditView to use ObjectPermissionRequiredMixin 2020-05-21 15:07:20 -04:00
Jeremy Stretch
5e5038d780 Transition BulkImportView to use ObjectPermissionRequiredMixin 2020-05-21 14:43:27 -04:00
Jeremy Stretch
2b32430a10 Transition ObjectDeleteView to use ObjectPermissionRequiredMixin 2020-05-21 14:34:40 -04:00
Jeremy Stretch
5381c4e0ae Tweak evaluation of required permission for ObjectEditView 2020-05-21 14:26:56 -04:00
Jeremy Stretch
406b076b95 Transition ObjectEditView to use ObjectPermissionRequiredMixin 2020-05-21 13:59:19 -04:00
Jeremy Stretch
993ee8c900 Transition ObjectListView to use ObjectPermissionRequiredMixin 2020-05-21 13:22:09 -04:00
Jeremy Stretch
cc6e74dfd5 Move ObjectPermissionRequiredMixin to utilities.views 2020-05-21 13:12:15 -04:00
Jeremy Stretch
40c590f445 Add queryset to all BulkImportViews 2020-05-21 12:01:42 -04:00
Jeremy Stretch
5486cff441 Add object permission support, tests for bulk import/edit/delete views 2020-05-21 11:49:50 -04:00
Jeremy Stretch
a928d337d9 Add object permission support for create/update/delete API views 2020-05-21 10:51:40 -04:00
Jeremy Stretch
fa8407371b Swap position of REMOTE_AUTH_BACKEND 2020-05-20 16:56:40 -04:00
Jeremy Stretch
8c40148ca7 Add object permission tests for get and list API views 2020-05-20 16:47:33 -04:00
Jeremy Stretch
8eb4d0a36b Remove ViewExemptBackend; use same for model- and object-level permissions 2020-05-20 16:27:56 -04:00
Jeremy Stretch
64f60228ec Add web UI view tests for object-level permissions 2020-05-20 13:35:54 -04:00
Jeremy Stretch
f3b22acc9a Merge branch 'develop' into develop-2.9 2020-05-15 11:29:07 -04:00
Jeremy Stretch
aeb32104a4 Enforce object-level permissions for API views 2020-05-14 17:44:46 -04:00
Jeremy Stretch
73895b1c88 Bypass permission caching for anonymous users 2020-05-14 17:44:15 -04:00
Jeremy Stretch
f54fb67efc Add object-level support to TokenPermissions 2020-05-14 13:49:52 -04:00
Jeremy Stretch
be5962fb3a ObjectPermissionRequiredMixin should exempt superusers 2020-05-12 17:00:03 -04:00
Jeremy Stretch
94d0ebbd7d Fix ObjectPermission attribute consolidation 2020-05-12 16:51:46 -04:00
Jeremy Stretch
a275a30dca Reimplement the ViewExemptModelBackend to explicitly cache all exempted view permissions on the User instance 2020-05-12 16:07:07 -04:00
Jeremy Stretch
c90f680284 Cache object-level permissions on the User instance for evaluation 2020-05-12 15:42:44 -04:00
Jeremy Stretch
daa2c6ff21 Always pass obj=None to ModelBackend 2020-05-11 17:19:11 -04:00
Jeremy Stretch
63f842c7db Implement ObjectPermissionManager 2020-05-11 16:02:42 -04:00
Jeremy Stretch
06aca2e1d5 Merge branch 'develop-2.9' into 554-object-permissions 2020-05-11 12:48:36 -04:00
Jeremy Stretch
3abb52a085 #4624: Refactor ObjectDeleteView to use a queryset 2020-05-11 12:47:01 -04:00
Jeremy Stretch
87fa6bc252 #4624: Refactor ObjectEditView to use a queryset 2020-05-11 12:37:22 -04:00
Jeremy Stretch
4b5d64939d Introduced ObjectPermissionRequiredMixin 2020-05-11 11:51:11 -04:00
Jeremy Stretch
6624fc6076 Initial work on #554 (WIP) 2020-05-08 17:30:25 -04:00
Jeremy Stretch
43ad9aa2b1 Fix version 2020-05-06 15:20:28 -04:00
965 changed files with 13189 additions and 8061 deletions

View File

@@ -42,10 +42,6 @@ django-tables2
# https://github.com/alex/django-taggit
django-taggit
# A Django REST Framework serializer which represents tags
# https://github.com/glemmaPaul/django-taggit-serializer
django-taggit-serializer
# A Django field for representing time zones
# https://github.com/mfogel/django-timezone-field/
django-timezone-field

View File

@@ -0,0 +1,43 @@
# Permissions
NetBox v2.9 introduced a new object-based permissions framework, which replace's Django's built-in permission model. Object-based permissions allow for the assignment of permissions to an arbitrary subset of objects of a certain type, rather than only by type of object. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range.
{!docs/models/users/objectpermission.md!}
### Example Constraint Definitions
| Query Filter | Permission Constraints |
| ------------ | --------------------- |
| `filter(status='active')` | `{"status": "active"}` |
| `filter(status='active', role='testing')` | `{"status": "active", "role": "testing"}` |
| `filter(status__in=['planned', 'reserved'])` | `{"status__in": ["planned", "reserved"]}` |
| `filter(name__startswith('Foo')` | `{"name__startswith": "Foo"}` |
| `filter(vid__gte=100, vid__lt=200)` | `{"vid__gte": 100, "vid__lt": 200}` |
## Permissions Enforcement
### Viewing Objects
Object-based permissions work by filtering the database query generated by a user's request to restrict the set of objects returned. When a request is received, NetBox first determines whether the user is authenticated and has been granted to perform the requested action. For example, if the requested URL is `/dcim/devices/`, NetBox will check for the `dcim.view_device` permission. If the user has not been assigned this permission (either directly or via a group assignment), NetBox will return a 403 (forbidden) HTTP response.
If the permission has been granted, NetBox will compile any specified constraints for the model and action. For example, suppose two permissions have been assigned to the user granting view access to the device model, with the following constraints:
```json
[
{"site__name__in": ["NYC1", "NYC2"]},
{"status": "offline", "tenant__isnull": true}
]
```
This grants the user access to view any device that is in NYC1 or NYC2, **or** which has a status of "offline" and has no tenant assigned. These constraints will result in the following ORM query:
```no-highlight
Site.objects.filter(
Q(site__name__in=['NYC1', 'NYC2']),
Q(status='active', tenant__isnull=True)
)
```
### Creating and Modifying Objects
The same sort of logic is in play when a user attempts to create or modify an object in NetBox, with a twist. Once validation has completed, NetBox starts an atomic database transaction to facilitate the change, and the object is created or saved normally. Next, still within the transaction, NetBox issues a second query to retrieve the newly created/updated object, filtering the restricted queryset with the object's primary key. If this query fails to return the object, NetBox knows that the new revision does not match the constraints imposed by the permission. The transaction is then aborted, and the database is left in its original state.

View File

@@ -172,6 +172,9 @@ To exempt _all_ models from view permission enforcement, set the following. (Not
EXEMPT_VIEW_PERMISSIONS = ['*']
```
!!! note
Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually.
---
## ENFORCE_GLOBAL_UNIQUE
@@ -408,9 +411,12 @@ NetBox can be configured to support remote user authentication by inferring user
## REMOTE_AUTH_BACKEND
Default: `'utilities.auth_backends.RemoteUserBackend'`
Default: `'netbox.authentication.RemoteUserBackend'`
Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication, if not using NetBox's built-in backend. (Requires `REMOTE_AUTH_ENABLED`.)
Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though backends may also be provided via other packages.
* `netbox.authentication.RemoteUserBackend`
* `netbox.authentication.LDAPBackend`
---
@@ -440,9 +446,9 @@ The list of groups to assign a new user account when created using remote authen
## REMOTE_AUTH_DEFAULT_PERMISSIONS
Default: `[]` (Empty list)
Default: `{}` (Empty dictionary)
The list of permissions to assign a new user account when created using remote authentication. (Requires `REMOTE_AUTH_ENABLED`.)
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
---

View File

@@ -7,3 +7,4 @@
---
{!docs/models/virtualization/virtualmachine.md!}
{!docs/models/virtualization/vminterface.md!}

View File

@@ -4,6 +4,10 @@ Utility views are reusable views that handle common CRUD tasks, such as listing
## Individual Views
### ObjectView
Retrieve and display a single object.
### ObjectListView
Generates a paginated table of objects from a given queryset, which may optionally be filtered.

View File

@@ -3,7 +3,7 @@
This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md).
!!! warning
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** supported.
NetBox requires PostgreSQL 9.6 or higher. Please note that MySQL and other relational databases are **not** currently supported.
The installation instructions provided here have been tested to work on Ubuntu 18.04 and CentOS 7.5. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors.
@@ -20,7 +20,7 @@ If a recent enough version of PostgreSQL is not available through your distribut
#### CentOS
CentOS 7.5 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
CentOS 7 does not ship with a recent enough version of PostgreSQL, so it will need to be installed from an external repository. The instructions below show the installation of PostgreSQL 9.6, however you may opt to install a more recent version.
```no-highlight
# yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
@@ -47,11 +47,11 @@ Then, start the service and enable it to run at boot:
At a minimum, we need to create a database for NetBox and assign it a username and password for authentication. This is done with the following commands.
!!! danger
DO NOT USE THE PASSWORD FROM THE EXAMPLE.
**Do not use the password from the example.** Choose a strong, random password to ensure secure database authentication for your NetBox installation.
```no-highlight
# sudo -u postgres psql
psql (10.10)
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
Type "help" for help.
postgres=# CREATE DATABASE netbox;
@@ -68,7 +68,13 @@ postgres=# \q
You can verify that authentication works issuing the following command and providing the configured password. (Replace `localhost` with your database server if using a remote database.)
```no-highlight
# psql -U netbox -W -h localhost netbox
# psql --username netbox --password --host localhost netbox
Password for user netbox:
psql (10.12 (Ubuntu 10.12-0ubuntu0.18.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
netbox=> \q
```
If successful, you will enter a `netbox` prompt. Type `\q` to exit.

View File

@@ -4,6 +4,9 @@
[Redis](https://redis.io/) is an in-memory key-value store which NetBox employs for caching and queuing. This section entails the installation and configuration of a local Redis instance. If you already have a Redis service in place, skip to [the next section](3-netbox.md).
!!! note
NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details.
### Ubuntu
```no-highlight

View File

@@ -4,7 +4,10 @@ This section of the documentation discusses installing and configuring the NetBo
## Install System Packages
Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required.
Begin by installing all system packages required by NetBox and its dependencies.
!!! note
NetBox v2.8.0 and later require Python 3.6 or 3.7. This documentation assumes Python 3.6.
### Ubuntu
@@ -19,22 +22,32 @@ Begin by installing all system packages required by NetBox and its dependencies.
# easy_install-3.6 pip
```
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
```no-highlight
# pip install --upgrade pip
```
## Download NetBox
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.
This documentation provides two options for installing NetBox: from a downloadable archive, or from the git repository. Installing from a package (option A below) requires manually fetching and decompressing the archive for every future update, whereas installation via git (option B) allows for seamless upgrades by re-pulling the `master` branch.
### Option A: Download a Release
### Option A: Download a Release Archive
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`.
Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
```no-highlight
# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
# tar -xzf vX.Y.Z.tar.gz -C /opt
# cd /opt/
# ln -s netbox-X.Y.Z/ netbox
# cd /opt/netbox/
# ln -s /opt/netbox-X.Y.Z/ /opt/netbox
# ls -l /opt | grep netbox
lrwxrwxrwx 1 root root 13 Jul 20 13:44 netbox -> netbox-2.9.0/
drwxr-xr-x 2 root root 4096 Jul 20 13:44 netbox-2.9.0
```
!!! note
It is recommended to install NetBox in a directory named for its version number. For example, NetBox v2.9.0 would be installed into `/opt/netbox-2.9.0`, and a symlink from `/opt/netbox/` would point to this location. This allows for future releases to be installed in parallel without interrupting the current installation. When changing to the new release, only the symlink needs to be updated.
### Option B: Clone the Git Repository
Create the base directory for the NetBox installation. For this guide, we'll use `/opt/netbox`.
@@ -57,7 +70,7 @@ If `git` is not already installed, install it:
# yum install -y git
```
Next, clone the **master** branch of the NetBox GitHub repository into the current directory:
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight
# git clone -b master https://github.com/netbox-community/netbox.git .
@@ -70,72 +83,38 @@ Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
```
## Create the NetBox User
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save local files.
!!! note
CentOS users may need to create the `netbox` group first.
Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release.
## Create the NetBox System User
Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files.
#### Ubuntu
```
# groupadd --system netbox
# adduser --system --gid netbox netbox
# adduser --system --group netbox
# chown --recursive netbox /opt/netbox/netbox/media/
```
## Set Up Python Environment
#### CentOS
We'll use a Python [virtual environment](https://docs.python.org/3.6/tutorial/venv.html) to ensure NetBox's required packages don't conflict with anything in the base system. This will create a directory named `venv` in our NetBox root.
```no-highlight
# python3 -m venv /opt/netbox/venv
```
Next, activate the virtual environment and install the required Python packages. You should see your console prompt change to indicate the active environment. (Activating the virtual environment updates your command shell to use the local copy of Python that we just installed for NetBox instead of the system's Python interpreter.)
```no-highlight
# source venv/bin/activate
(venv) # pip3 install -r requirements.txt
```
### NAPALM Automation (Optional)
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package:
```no-highlight
(venv) # pip3 install napalm
```
To ensure NAPALM is automatically re-installed during future upgrades, create a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`) and list the `napalm` package:
```no-highlight
# echo napalm >> local_requirements.txt
```
### Remote File Storage (Optional)
By default, NetBox will use the local filesystem to storage uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired backend](../../configuration/optional-settings/#storage_backend) in `configuration.py`.
```no-highlight
(venv) # pip3 install django-storages
```
Don't forget to add the `django-storages` package to `local_requirements.txt` to ensure it gets re-installed during future upgrades:
```no-highlight
# echo django-storages >> local_requirements.txt
# groupadd --system netbox
# adduser --system -g netbox netbox
# chown --recursive netbox /opt/netbox/netbox/media/
```
## Configuration
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`.
Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters.
```no-highlight
(venv) # cd netbox/netbox/
(venv) # cp configuration.example.py configuration.py
# cd /opt/netbox/netbox/netbox/
# cp configuration.example.py configuration.py
```
Open `configuration.py` with your preferred editor and set the following variables:
Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations:
* `ALLOWED_HOSTS`
* `DATABASE`
@@ -144,19 +123,21 @@ Open `configuration.py` with your preferred editor and set the following variabl
### ALLOWED_HOSTS
This is a list of the valid hostnames by which this server can be reached. You must specify at least one name or IP address.
Example:
This is a list of the valid hostnames and IP addresses by which this server can be reached. You must specify at least one name or IP address. (Note that this does not restrict the locations from which NetBox may be accessed: It is merely for [HTTP host header validation](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting).)
```python
ALLOWED_HOSTS = ['netbox.example.com', '192.0.2.123']
```
If you are not yet sure what the domain name and/or IP address of the NetBox installation will be, you can set this to a wildcard (asterisk) to allow all host values:
```python
ALLOWED_HOSTS = ['*']
```
### DATABASE
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, replace `localhost` with its address. See the [configuration documentation](../../configuration/required-settings/#database) for more detail on individual parameters.
Example:
This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](/configuration/required-settings/#database) for more detail on individual parameters.
```python
DATABASE = {
@@ -165,29 +146,31 @@ DATABASE = {
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
'HOST': 'localhost', # Database server
'PORT': '', # Database port (leave blank for default)
'CONN_MAX_AGE': 300, # Max database connection age
'CONN_MAX_AGE': 300, # Max database connection age (seconds)
}
```
### REDIS
Redis is a in-memory key-value store required as part of the NetBox installation. It is used for features such as webhooks and caching. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../../configuration/required-settings/#redis) for more detail on individual parameters.
Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](/configuration/required-settings/#redis) for more detail on individual parameters.
Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique database ID.
```python
REDIS = {
'tasks': {
'HOST': 'redis.example.com',
'PORT': 1234,
'PASSWORD': 'foobar',
'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False,
'HOST': 'localhost', # Redis server
'PORT': 6379, # Redis port
'PASSWORD': '', # Redis password (optional)
'DATABASE': 0, # Database ID
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
'SSL': False, # Use SSL (optional)
},
'caching': {
'HOST': 'localhost',
'PORT': 6379,
'PASSWORD': '',
'DATABASE': 1,
'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False,
}
@@ -196,37 +179,69 @@ REDIS = {
### SECRET_KEY
Generate a random secret key of at least 50 alphanumeric characters. This key must be unique to this installation and must not be shared outside the local system.
This parameter must be assigned a randomly-generated key employed as a salt for hashing and related cryptographic functions. (Note, however, that it is _never_ directly used in the encryption of secret data.) This key must be unique to this installation and is recommended to be at least 50 characters long. It should not be shared outside the local system.
You may use the script located at `netbox/generate_secret_key.py` to generate a suitable key.
!!! note
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
## Run Database Migrations
Before NetBox can run, we need to install the database schema. This is done by running `python3 manage.py migrate` from the `netbox` directory (`/opt/netbox/netbox/` in our example):
A simple Python script named `generate_secret_key.py` is provided in the parent directory to assist in generating a suitable key:
```no-highlight
(venv) # cd /opt/netbox/netbox/
(venv) # python3 manage.py migrate
Operations to perform:
Apply all migrations: dcim, sessions, admin, ipam, utilities, auth, circuits, contenttypes, extras, secrets, users
Running migrations:
Rendering model states... DONE
Applying contenttypes.0001_initial... OK
Applying auth.0001_initial... OK
Applying admin.0001_initial... OK
...
# python3 ../generate_secret_key.py
```
If this step results in a PostgreSQL authentication error, ensure that the username and password created in the database match what has been specified in `configuration.py`
!!! warning
In the case of a highly available installation with multiple web servers, `SECRET_KEY` must be identical among all servers in order to maintain a persistent user session state.
When you have finished modifying the configuration, remember to save the file.
## Optional Requirements
All Python packages required by NetBox are listed in `requirements.txt` and will be installed automatically. NetBox also supports some optional packages. If desired, these packages must be listed in `local_requirements.txt` within the NetBox root directory.
### NAPALM
The [NAPALM automation](https://napalm-automation.net/) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device.
```no-highlight
# echo napalm >> /opt/netbox/local_requirements.txt
```
### Remote File Storage
By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](/configuration/optional-settings/#storage_backend) in `configuration.py`.
```no-highlight
# echo django-storages >> /opt/netbox/local_requirements.txt
```
## Run the Upgrade Script
Once NetBox has been configured, we're ready to proceed with the actual installation. We'll run the packaged upgrade script (`upgrade.sh`) to perform the following actions:
* Create a Python virtual environment
* Install all required Python packages
* Run database schema migrations
* Aggregate static resource files on disk
```no-highlight
# /opt/netbox/upgrade.sh
```
!!! note
Upon completion, the upgrade script may warn that no existing virtual environment was detected. As this is a new installation, this warning can be safely ignored.
## Create a Super User
NetBox does not come with any predefined user accounts. You'll need to create a super user to be able to log into NetBox:
NetBox does not come with any predefined user accounts. You'll need to create a super user (administrative account) to be able to log into NetBox. First, enter the Python virtual environment created by the upgrade script:
```no-highlight
# source /opt/netbox/venv/bin/activate
```
Once the virtual environment has been activated, you should notice the string `(venv)` prepended to your console prompt.
Next, we'll create a superuser account using the `createsuperuser` Django management command (via `manage.py`). Specifying an email address for the user is not required, but be sure to use a very strong password.
```no-highlight
(venv) # cd /opt/netbox/netbox
(venv) # python3 manage.py createsuperuser
Username: admin
Email address: admin@example.com
@@ -235,17 +250,9 @@ Password (again):
Superuser created successfully.
```
## Collect Static Files
```no-highlight
(venv) # python3 manage.py collectstatic --no-input
959 static files copied to '/opt/netbox/netbox/static'.
```
## Test the Application
At this point, NetBox should be able to run. We can verify this by starting a development instance:
At this point, we should be able to run NetBox. We can check by starting a development instance:
```no-highlight
(venv) # python3 manage.py runserver 0.0.0.0:8000 --insecure
@@ -267,6 +274,6 @@ Note that the initial UI will be locked down for non-authenticated users.
![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_ui_guest.png)
After logging in as the superuser you created earlier, all areas of the UI will be available.
Try logging in as the super user we just created. Once authenticated, you'll be able to access all areas of the UI:
![NetBox UI as seen by an administrator](../media/installation/netbox_ui_admin.png)

View File

@@ -0,0 +1,49 @@
# Gunicorn
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) for this role, however other WSGIs are available and should work similarly well.
## Configuration
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any changes to it do not get overwritten by a future upgrade.)
```no-highlight
# cd /opt/netbox
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
```
While this default configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
## systemd Setup
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
```no-highlight
# cp contrib/*.service /etc/systemd/system/
# systemctl daemon-reload
```
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
```no-highlight
# systemctl status netbox.service
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
...
```
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.

View File

@@ -1,9 +1,9 @@
# HTTP Server Setup
We'll set up a simple WSGI front end using [gunicorn](http://gunicorn.org/) for the purposes of this guide. For web servers, we provide example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4). (You are of course free to use whichever combination of HTTP and WSGI services you'd like.) We'll use systemd to enable service persistence.
This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/2.4), though any HTTP server which supports WSGI should be compatible.
!!! info
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, but this sort of web server and WSGI configuration is not unique to NetBox. Please consult your distribution's documentation for assistance if needed.
For the sake of brevity, only Ubuntu 18.04 instructions are provided here, these tasks not unique to NetBox and should carry over to other distributions with mininal changes. Please consult your distribution's documentation for assistance if needed.
## Obtain an SSL Certificate
@@ -17,17 +17,19 @@ The command below can be used to generate a self-signed certificate for testing
-out /etc/ssl/certs/netbox.crt
```
## HTTP Daemon Installation
The above command will prompt you for additional details of the certificate; all of these are optional.
## HTTP Server Installation
### Option A: nginx
The following will serve as a minimal nginx configuration. Be sure to modify your server name and installation path appropriately.
Begin by installing nginx:
```no-highlight
# apt-get install -y nginx
```
Once nginx is installed, copy the default nginx configuration file to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
Once nginx is installed, copy the nginx configuration file provided by NetBox to `/etc/nginx/sites-available/netbox`. Be sure to replace `netbox.example.com` with the domain name or IP address of your installation. (This should match the value configured for `ALLOWED_HOSTS` in `configuration.py`.)
```no-highlight
# cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
@@ -69,67 +71,25 @@ Finally, ensure that the required Apache modules are enabled, enable the `netbox
# service apache2 restart
```
!!! note
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
## Gunicorn Configuration
Copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file to ensure that any changes to it do not get overwritten by a future upgrade.)
```no-highlight
# cd /opt/netbox
# cp contrib/gunicorn.py /opt/netbox/gunicorn.py
```
You may wish to edit this file to change the bound IP address or port number, or to make performance-related adjustments. See [the Gunicorn documentation](https://docs.gunicorn.org/en/stable/configure.html) for the available configuration parameters.
## systemd Configuration
We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory:
```no-highlight
# cp contrib/*.service /etc/systemd/system/
```
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
# systemctl daemon-reload
# systemctl start netbox netbox-rq
# systemctl enable netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
```no-highlight
# systemctl status netbox.service
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Thu 2019-12-12 19:23:40 UTC; 25s ago
Docs: https://netbox.readthedocs.io/en/stable/
Main PID: 11993 (gunicorn)
Tasks: 6 (limit: 2362)
CGroup: /system.slice/netbox.service
├─11993 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12015 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
├─12016 /usr/bin/python3 /usr/local/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/...
...
```
## Confirm Connectivity
At this point, you should be able to connect to the HTTP service at the server name or IP address you provided.
!!! info
Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment.
!!! warning
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
## Troubleshooting
If you are unable to connect to the HTTP server, check that:
* Nginx/Apache is running and configured to listen on the correct port.
* Access is not being blocked by a firewall. (Try connecting locally from the server itself.)
* Access is not being blocked by a firewall somewhere along the path. (Try connecting locally from the server itself.)
If you are able to connect but receive a 502 (bad gateway) error, check the following:
* The NetBox system process (gunicorn) is running: `systemctl status netbox`
* The WSGI worker processes (gunicorn) are running (`systemctl status netbox` should show a status of "active (running)")
* nginx/Apache is configured to connect to the port on which gunicorn is listening (default is 8001).
* SELinux is not preventing the reverse proxy connection. You may need to allow HTTP network connections with the command `setsebool -P httpd_can_network_connect 1`

View File

@@ -36,7 +36,13 @@ Once installed, add the package to `local_requirements.txt` to ensure it is re-i
## Configuration
Create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
First, enable the LDAP authentication backend in `configuration.py`. (Be sure to overwrite this definition if it is already set to `RemoteUserBackend`.)
```python
REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend'
```
Next, create a file in the same directory as `configuration.py` (typically `netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/).
### General Server Configuration
@@ -145,7 +151,8 @@ logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5)
logfile, maxBytes=1024 * 500, backupCount=5
)
my_logger.addHandler(handler)
```

View File

@@ -5,8 +5,9 @@ The following sections detail how to set up a new instance of NetBox:
1. [PostgreSQL database](1-postgresql.md)
1. [Redis](2-redis.md)
3. [NetBox components](3-netbox.md)
4. [HTTP daemon](4-http-daemon.md)
5. [LDAP authentication](5-ldap.md) (optional)
4. [Gunicorn](4-gunicorn.md)
5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional)
Below is a simplified overview of the NetBox application stack for reference:
@@ -16,4 +17,5 @@ Below is a simplified overview of the NetBox application stack for reference:
If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md).
Netbox v2.5.9 and later moved to using systemd instead of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.
!!! note
Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord.

View File

@@ -0,0 +1,36 @@
# Object Permissions
Assigning a permission in NetBox entails defining a relationship among several components:
* Object type(s) - One or more types of object in NetBox
* User(s) - One or more users or groups of users
* Actions - The actions that can be performed (view, add, change, and/or delete)
* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects
At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s).
## Actions
There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete):
* View - Retrieve an object from the database
* Add - Create a new object
* Change - Modify an existing object
* Delete - Delete an existing object
Some models introduce additional permissions that can be granted to allow other actions. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field.
## Constraints
Constraints are defined as a JSON object representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below.
All constraints defined on a permission are applied with a logic AND. For example, suppose you assign a permission for the site model with the following constraints.
```json
{
"status": "active",
"region__name": "Americas"
}
```
The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. To achieve a logical OR with a different set of constraints, simply create another permission assignment for the same model and user/group.

View File

@@ -0,0 +1,3 @@
## Interfaces
Virtual machine interfaces are similar to device interfaces, but lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. However, they can have IP address and VLANs (both tagged and untagged) associated with them, just as device interfaces do.

View File

@@ -1 +1 @@
version-2.8.md
version-2.9.md

View File

@@ -0,0 +1,94 @@
# NetBox v2.9
## v2.9-beta1 (2020-07-23)
**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.9 release will be provided from this beta, and users should assume that all data entered into the application will be lost. Please reference [the v2.9 beta documentation](https://netbox.readthedocs.io/en/develop-2.9/) for further information regarding this release.
### New Features
#### Object-Based Permissions ([#554](https://github.com/netbox-community/netbox/issues/554))
NetBox v2.9 replaces Django's built-in permissions framework with one that supports object-based assignment of permissions using arbitrary constraints. When granting a user or group permission to perform a certain action on one or more types of objects, an administrator can optionally specify a set of constraints. The permission will apply only to objects which match the specified constraints. For example, assigning permission to modify devices with the constraint `{"tenant__group__name": "Customers"}` would allow the associated users/groups to perform an action only on devices assigned to a tenant belonging to the "Customers" group.
#### Background Execution of Scripts & Reports ([#2006](https://github.com/netbox-community/netbox/issues/2006))
When running a report or custom script, its execution is now queued for background processing and the user receives an immediate response indicating its status. This prevents long-running scripts from resulting in a timeout error. Once the execution has completed, the page will automatically refresh to display its results. Both scripts and reports now store their output in the new JobResult model. (The ReportResult model has been removed.)
### Enhancements
* [#2018](https://github.com/netbox-community/netbox/issues/2018) - Add `name` field to virtual chassis model
* [#3703](https://github.com/netbox-community/netbox/issues/3703) - Tags must be created administratively before being assigned to an object
* [#4615](https://github.com/netbox-community/netbox/issues/4615) - Add `label` field for all device components and component templates
* [#4742](https://github.com/netbox-community/netbox/issues/4742) - Add tagging for cables, power panels, and rack reservations
* [#4788](https://github.com/netbox-community/netbox/issues/4788) - Add dedicated views for all device components
* [#4792](https://github.com/netbox-community/netbox/issues/4792) - Add bulk rename capability for console and power ports
* [#4793](https://github.com/netbox-community/netbox/issues/4793) - Add `description` field to device component templates
* [#4795](https://github.com/netbox-community/netbox/issues/4795) - Add bulk disconnect capability for console and power ports
* [#4806](https://github.com/netbox-community/netbox/issues/4806) - Add a `url` field to all API serializers
* [#4807](https://github.com/netbox-community/netbox/issues/4807) - Add bulk edit ability for device bay templates
* [#4817](https://github.com/netbox-community/netbox/issues/4817) - Standardize device/VM component `name` field to 64 characters
* [#4837](https://github.com/netbox-community/netbox/issues/4837) - Use dynamic form widget for relationships to MPTT objects (e.g. regions)
* [#4840](https://github.com/netbox-community/netbox/issues/4840) - Enable change logging for config contexts
* [#4877](https://github.com/netbox-community/netbox/issues/4877) - Add REST API endpoints for users and groups
### Configuration Changes
* If in use, LDAP authentication must be enabled by setting `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
### REST API Changes
* Added new endpoints for users, groups, and permissions under `/api/users/`.
* A `url` field is now included on all object representations, identifying the unique REST API URL for each object.
* The `tags` field of an object now includes a more complete representation of each tag, rather than just its name.
* The assignment of tags to an object is now achieved in the same manner as specifying any other related device. The `tags` field accepts a list of JSON objects each matching a desired tag. (Alternatively, a list of numeric primary keys corresponding to tags may be passed instead.) For example:
```json
"tags": [
{"name": "First Tag"},
{"name": "Second Tag"}
]
```
* Legacy numeric values for choice fields are no longer conveyed or accepted.
* dcim.Cable: Added `tags` field
* dcim.ConsolePort: Added `label` field
* dcim.ConsolePortTemplate: Added `description` and `label` fields
* dcim.ConsoleServerPort: Added `label` field
* dcim.ConsoleServerPortTemplate: Added `description` and `label` fields
* dcim.DeviceBay: Added `label` field
* dcim.DeviceBayTemplate: Added `description` and `label` fields
* dcim.FrontPort: Added `label` field
* dcim.FrontPortTemplate: Added `description` and `label` fields
* dcim.Interface: Added `label` field
* dcim.InterfaceTemplate: Added `description` and `label` fields
* dcim.PowerPanel: Added `tags` field
* dcim.PowerPort: Added ``label` field
* dcim.PowerPortTemplate: Added `description` and `label` fields
* dcim.PowerOutlet: Added `label` field
* dcim.PowerOutletTemplate: Added `description` and `label` fields
* dcim.RackGroup: Added a `_depth` attribute indicating an object's position in the tree.
* dcim.RackReservation: Added `tags` field
* dcim.RearPort: Added `label` field
* dcim.RearPortTemplate: Added `description` and `label` fields
* dcim.Region: Added a `_depth` attribute indicating an object's position in the tree.
* dcim.VirtualChassis: Added `name` field (required)
* extras.ConfigContext: Added `created` and `last_updated` fields
* extras.JobResult: Added the `/api/extras/job-results/` endpoint
* extras.Report: The `failed` field has been removed. The `completed` (boolean) and `status` (string) fields have been introduced to convey the status of a report's most recent execution. Additionally, the `result` field now conveys the nested representation of a JobResult.
* extras.Script: Added `module` and `result` fields. The `result` field now conveys the nested representation of a JobResult.
* extras.Tag: The count of `tagged_items` is no longer included when viewing the tags list when `brief` is passed.
* ipam.IPAddress: Removed `interface` field; replaced with `assigned_object` generic foreign key. This may represent either a device interface or a virtual machine interface. Assign an object by setting `assigned_object_type` and `assigned_object_id`.
* tenancy.TenantGroup: Added a `_depth` attribute indicating an object's position in the tree.
* users.ObjectPermissions: Added the `/api/users/permissions/` endpoint
* virtualization.VMInterface: Removed `type` field (VM interfaces have no type)
### Other Changes
* A new model, `VMInterface` has been introduced to represent interfaces assigned to VirtualMachine instances. Previously, these interfaces utilized the DCIM model `Interface`. Instances will be replicated automatically upon upgrade, however any custom code which references or manipulates virtual machine interfaces will need to be updated accordingly.
* The `secrets.activate_userkey` permission no longer exists. Instead, `secrets.change_userkey` is checked to determine whether a user has the ability to activate a UserKey.
* The `users.delete_token` permission is no longer enforced. All users are permitted to delete their own API tokens.
* Dropped backward compatibility for the `webhooks` Redis queue configuration (use `tasks` instead).
* Dropped backward compatibility for the `/admin/webhook-backend-status` URL (moved to `/admin/background-tasks/`).
* Virtual chassis are now created by navigating to `/dcim/virtual-chassis/add/` rather than via the devices list.
* A name is required when creating a virtual chassis.

View File

@@ -20,8 +20,9 @@ nav:
- 1. PostgreSQL: 'installation/1-postgresql.md'
- 2. Redis: 'installation/2-redis.md'
- 3. NetBox: 'installation/3-netbox.md'
- 4. HTTP Daemon: 'installation/4-http-daemon.md'
- 5. LDAP (Optional): 'installation/5-ldap.md'
- 4. Gunicorn: 'installation/4-gunicorn.md'
- 5. HTTP Server: 'installation/5-http-server.md'
- 6. LDAP (Optional): 'installation/6-ldap.md'
- Upgrading NetBox: 'installation/upgrading.md'
- Migrating to systemd: 'installation/migrating-to-systemd.md'
- Configuration:
@@ -58,6 +59,7 @@ nav:
- Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md'
- Administration:
- Permissions: 'administration/permissions.md'
- Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md'
- API:

View File

@@ -1,11 +1,11 @@
from rest_framework import serializers
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from circuits.choices import CircuitStatusChoices
from circuits.models import Provider, Circuit, CircuitTermination, CircuitType
from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer
from dcim.api.serializers import ConnectedEndpointSerializer
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer
from .nested_serializers import *
@@ -15,14 +15,14 @@ from .nested_serializers import *
# Providers
#
class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
tags = TagListSerializerField(required=False)
class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = Provider
fields = [
'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', 'circuit_count',
]
@@ -32,11 +32,12 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class CircuitTypeSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail')
circuit_count = serializers.IntegerField(read_only=True)
class Meta:
model = CircuitType
fields = ['id', 'name', 'slug', 'description', 'circuit_count']
fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count']
class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
@@ -49,24 +50,25 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id']
class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer):
class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = NestedProviderSerializer()
status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = NestedCircuitTypeSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
termination_a = CircuitCircuitTerminationSerializer(read_only=True)
termination_z = CircuitCircuitTerminationSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Circuit
fields = [
'id', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'id', 'url', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description',
'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]
class CircuitTerminationSerializer(ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer()
cable = NestedCableSerializer(read_only=True)
@@ -74,6 +76,6 @@ class CircuitTerminationSerializer(ConnectedEndpointSerializer):
class Meta:
model = CircuitTermination
fields = [
'id', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable',
]

View File

@@ -1,4 +1,4 @@
from django.db.models import Count
from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -19,7 +19,7 @@ from . import serializers
class ProviderViewSet(CustomFieldModelViewSet):
queryset = Provider.objects.prefetch_related('tags').annotate(
circuit_count=Count('circuits')
)
).order_by(*Provider._meta.ordering)
serializer_class = serializers.ProviderSerializer
filterset_class = filters.ProviderFilterSet
@@ -28,8 +28,8 @@ class ProviderViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular provider.
"""
provider = get_object_or_404(Provider, pk=pk)
queryset = Graph.objects.filter(type__model='provider')
provider = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='provider')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': provider})
return Response(serializer.data)
@@ -41,7 +41,7 @@ class ProviderViewSet(CustomFieldModelViewSet):
class CircuitTypeViewSet(ModelViewSet):
queryset = CircuitType.objects.annotate(
circuit_count=Count('circuits')
)
).order_by(*CircuitType._meta.ordering)
serializer_class = serializers.CircuitTypeSerializer
filterset_class = filters.CircuitTypeFilterSet
@@ -52,7 +52,10 @@ class CircuitTypeViewSet(ModelViewSet):
class CircuitViewSet(CustomFieldModelViewSet):
queryset = Circuit.objects.prefetch_related(
'type', 'tenant', 'provider', 'terminations__site', 'terminations__connected_endpoint__device'
Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related(
'site', 'connected_endpoint__device'
)),
'type', 'tenant', 'provider',
).prefetch_related('tags')
serializer_class = serializers.CircuitSerializer
filterset_class = filters.CircuitFilterSet

View File

@@ -23,15 +23,6 @@ class CircuitStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONED, 'Decommissioned'),
)
LEGACY_MAP = {
STATUS_DEPROVISIONING: 0,
STATUS_ACTIVE: 1,
STATUS_PLANNED: 2,
STATUS_PROVISIONING: 3,
STATUS_OFFLINE: 4,
STATUS_DECOMMISSIONED: 5,
}
#
# CircuitTerminations

View File

@@ -3,8 +3,8 @@ from django import forms
from dcim.models import Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm,
TagField,
)
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -23,7 +23,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderForm(BootstrapMixin, CustomFieldModelForm):
slug = SlugField()
comments = CommentField()
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@@ -165,7 +166,8 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=CircuitType.objects.all()
)
comments = CommentField()
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.1b1 on 2020-07-16 15:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('circuits', '0018_standardize_description'),
]
operations = [
migrations.AlterField(
model_name='circuittermination',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
]

View File

@@ -6,9 +6,9 @@ from taggit.managers import TaggableManager
from dcim.constants import CONNECTION_STATUS_CHOICES
from dcim.fields import ASNField
from dcim.models import CableTermination
from extras.models import CustomFieldModel, ObjectChange, TaggedItem
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from .choices import *
from .querysets import CircuitQuerySet
@@ -66,9 +66,10 @@ class Provider(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments',
]
@@ -115,6 +116,8 @@ class CircuitType(ChangeLoggedModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -272,9 +275,10 @@ class CircuitTermination(CableTermination):
blank=True,
null=True
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
port_speed = models.PositiveIntegerField(
verbose_name='Port speed (Kbps)'
@@ -300,6 +304,8 @@ class CircuitTermination(CableTermination):
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['circuit', 'term_side']
unique_together = ['circuit', 'term_side']
@@ -330,6 +336,9 @@ class CircuitTermination(CableTermination):
def get_peer_termination(self):
peer_side = 'Z' if self.term_side == 'A' else 'A'
try:
return CircuitTermination.objects.prefetch_related('site').get(circuit=self.circuit, term_side=peer_side)
return CircuitTermination.objects.prefetch_related('site').get(
circuit=self.circuit,
term_side=peer_side
)
except CircuitTermination.DoesNotExist:
return None

View File

@@ -1,7 +1,9 @@
from django.db.models import OuterRef, QuerySet, Subquery
from django.db.models import OuterRef, Subquery
from utilities.querysets import RestrictedQuerySet
class CircuitQuerySet(QuerySet):
class CircuitQuerySet(RestrictedQuerySet):
def annotate_sites(self):
"""

View File

@@ -2,19 +2,9 @@ import django_tables2 as tables
from django_tables2.utils import Accessor
from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, TagColumn, ToggleColumn
from utilities.tables import BaseTable, ButtonsColumn, TagColumn, ToggleColumn
from .models import Circuit, CircuitType, Provider
CIRCUITTYPE_ACTIONS = """
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}"
class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
STATUS_LABEL = """
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
"""
@@ -53,11 +43,7 @@ class CircuitTypeTable(BaseTable):
circuit_count = tables.Column(
verbose_name='Circuits'
)
actions = tables.TemplateColumn(
template_code=CIRCUITTYPE_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
actions = ButtonsColumn(CircuitType, pk_field='slug')
class Meta(BaseTable.Meta):
model = CircuitType
@@ -76,7 +62,7 @@ class CircuitTable(BaseTable):
)
provider = tables.LinkColumn(
viewname='circuits:provider',
args=[Accessor('provider.slug')]
args=[Accessor('provider__slug')]
)
status = tables.TemplateColumn(
template_code=STATUS_LABEL

View File

@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from circuits.choices import *
@@ -45,6 +46,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
)
Provider.objects.bulk_create(providers)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_provider_graphs(self):
"""
Test retrieval of Graphs assigned to Providers.
@@ -58,6 +60,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
self.add_permissions('circuits.view_provider')
url = reverse('circuits-api:provider-graphs', kwargs={'pk': provider.pk})
response = self.client.get(url, **self.header)

View File

@@ -17,6 +17,8 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Provider(name='Provider 3', slug='provider-3', asn=65003),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Provider X',
'slug': 'provider-x',
@@ -26,7 +28,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'noc_contact': 'noc@example.com',
'admin_contact': 'admin@example.com',
'comments': 'Another provider',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -96,6 +98,8 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Circuit(cid='Circuit 3', provider=providers[0], type=circuittypes[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'cid': 'Circuit X',
'provider': providers[1].pk,
@@ -106,7 +110,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 1000,
'description': 'A new circuit',
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -124,5 +128,4 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'commit_rate': 2000,
'description': 'New description',
'comments': 'New comments',
}

View File

@@ -10,7 +10,7 @@ urlpatterns = [
# Providers
path('providers/', views.ProviderListView.as_view(), name='provider_list'),
path('providers/add/', views.ProviderCreateView.as_view(), name='provider_add'),
path('providers/add/', views.ProviderEditView.as_view(), name='provider_add'),
path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'),
path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'),
path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'),
@@ -21,15 +21,16 @@ urlpatterns = [
# Circuit types
path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'),
path('circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'),
path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'),
path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
path('circuit-types/<slug:slug>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
path('circuit-types/<slug:slug>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
path('circuit-types/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),
# Circuits
path('circuits/', views.CircuitListView.as_view(), name='circuit_list'),
path('circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'),
path('circuits/add/', views.CircuitEditView.as_view(), name='circuit_add'),
path('circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'),
path('circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'),
path('circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'),
@@ -37,11 +38,10 @@ urlpatterns = [
path('circuits/<int:pk>/edit/', views.CircuitEditView.as_view(), name='circuit_edit'),
path('circuits/<int:pk>/delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'),
path('circuits/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}),
path('circuits/<int:pk>/terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'),
path('circuits/<int:pk>/terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'),
# Circuit terminations
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'),
path('circuits/<int:circuit>/terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
path('circuit-terminations/<int:pk>/edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'),
path('circuit-terminations/<int:pk>/delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'),
path('circuit-terminations/<int:termination_a_id>/connect/<str:termination_b_type>/', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}),

View File

@@ -1,18 +1,15 @@
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.decorators import permission_required
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count, OuterRef, Subquery
from django.db.models import Count, Prefetch
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
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
)
from . import filters, forms, tables
from .choices import CircuitTerminationSideChoices
@@ -23,21 +20,20 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider
# Providers
#
class ProviderListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
class ProviderListView(ObjectListView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
filterset_form = forms.ProviderFilterForm
table = tables.ProviderTable
class ProviderView(PermissionRequiredMixin, View):
permission_required = 'circuits.view_provider'
class ProviderView(ObjectView):
queryset = Provider.objects.all()
def get(self, request, slug):
provider = get_object_or_404(Provider, slug=slug)
circuits = Circuit.objects.filter(
provider = get_object_or_404(self.queryset, slug=slug)
circuits = Circuit.objects.restrict(request.user, 'view').filter(
provider=provider
).prefetch_related(
'type', 'tenant', 'terminations__site'
@@ -60,114 +56,98 @@ class ProviderView(PermissionRequiredMixin, View):
})
class ProviderCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_provider'
model = Provider
class ProviderEditView(ObjectEditView):
queryset = Provider.objects.all()
model_form = forms.ProviderForm
template_name = 'circuits/provider_edit.html'
default_return_url = 'circuits:provider_list'
class ProviderEditView(ProviderCreateView):
permission_required = 'circuits.change_provider'
class ProviderDeleteView(ObjectDeleteView):
queryset = Provider.objects.all()
class ProviderDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_provider'
model = Provider
default_return_url = 'circuits:provider_list'
class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_provider'
class ProviderBulkImportView(BulkImportView):
queryset = Provider.objects.all()
model_form = forms.ProviderCSVForm
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
class ProviderBulkEditView(BulkEditView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
form = forms.ProviderBulkEditForm
default_return_url = 'circuits:provider_list'
class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_provider'
queryset = Provider.objects.annotate(count_circuits=Count('circuits'))
class ProviderBulkDeleteView(BulkDeleteView):
queryset = Provider.objects.annotate(count_circuits=Count('circuits')).order_by(*Provider._meta.ordering)
filterset = filters.ProviderFilterSet
table = tables.ProviderTable
default_return_url = 'circuits:provider_list'
#
# Circuit Types
#
class CircuitTypeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
class CircuitTypeListView(ObjectListView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
table = tables.CircuitTypeTable
class CircuitTypeCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittype'
model = CircuitType
class CircuitTypeEditView(ObjectEditView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeForm
default_return_url = 'circuits:circuittype_list'
class CircuitTypeEditView(CircuitTypeCreateView):
permission_required = 'circuits.change_circuittype'
class CircuitTypeDeleteView(ObjectDeleteView):
queryset = CircuitType.objects.all()
class CircuitTypeBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuittype'
class CircuitTypeBulkImportView(BulkImportView):
queryset = CircuitType.objects.all()
model_form = forms.CircuitTypeCSVForm
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits'))
class CircuitTypeBulkDeleteView(BulkDeleteView):
queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')).order_by(*CircuitType._meta.ordering)
table = tables.CircuitTypeTable
default_return_url = 'circuits:circuittype_list'
#
# Circuits
#
class CircuitListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'circuits.view_circuit'
_terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk'))
class CircuitListView(ObjectListView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations__site'
'provider', 'type', 'tenant', 'terminations'
).annotate_sites()
filterset = filters.CircuitFilterSet
filterset_form = forms.CircuitFilterForm
table = tables.CircuitTable
class CircuitView(PermissionRequiredMixin, View):
permission_required = 'circuits.view_circuit'
class CircuitView(ObjectView):
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant__group')
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
circuit = get_object_or_404(Circuit.objects.prefetch_related('provider', 'type', 'tenant__group'), pk=pk)
termination_a = CircuitTermination.objects.prefetch_related(
termination_a = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
termination_z = CircuitTermination.objects.prefetch_related(
if termination_a and termination_a.connected_endpoint:
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
'site__region', 'connected_endpoint__device'
).filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if termination_z and termination_z.connected_endpoint:
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return render(request, 'circuits/circuit.html', {
'circuit': circuit,
@@ -176,67 +156,80 @@ class CircuitView(PermissionRequiredMixin, View):
})
class CircuitCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuit'
model = Circuit
class CircuitEditView(ObjectEditView):
queryset = Circuit.objects.all()
model_form = forms.CircuitForm
template_name = 'circuits/circuit_edit.html'
default_return_url = 'circuits:circuit_list'
class CircuitEditView(CircuitCreateView):
permission_required = 'circuits.change_circuit'
class CircuitDeleteView(ObjectDeleteView):
queryset = Circuit.objects.all()
class CircuitDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuit'
model = Circuit
default_return_url = 'circuits:circuit_list'
class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView):
permission_required = 'circuits.add_circuit'
class CircuitBulkImportView(BulkImportView):
queryset = Circuit.objects.all()
model_form = forms.CircuitCSVForm
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'
class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'circuits.change_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
class CircuitBulkEditView(BulkEditView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
)
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
form = forms.CircuitBulkEditForm
default_return_url = 'circuits:circuit_list'
class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuit'
queryset = Circuit.objects.prefetch_related('provider', 'type', 'tenant').prefetch_related('terminations__site')
class CircuitBulkDeleteView(BulkDeleteView):
queryset = Circuit.objects.prefetch_related(
'provider', 'type', 'tenant', 'terminations'
)
filterset = filters.CircuitFilterSet
table = tables.CircuitTable
default_return_url = 'circuits:circuit_list'
@permission_required('circuits.change_circuittermination')
def circuit_terminations_swap(request, pk):
class CircuitSwapTerminations(ObjectEditView):
"""
Swap the A and Z terminations of a circuit.
"""
queryset = Circuit.objects.all()
circuit = get_object_or_404(Circuit, pk=pk)
termination_a = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
termination_z = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if not termination_a and not termination_z:
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
def get(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm()
if request.method == 'POST':
# Circuit must have at least one termination to swap
if not circuit.termination_a and not circuit.termination_z:
messages.error(request, "No terminations have been defined for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': circuit.termination_a,
'termination_z': circuit.termination_z,
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
def post(self, request, pk):
circuit = get_object_or_404(self.queryset, pk=pk)
form = ConfirmationForm(request.POST)
if form.is_valid():
termination_a = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_A
).first()
termination_z = CircuitTermination.objects.filter(
circuit=circuit, term_side=CircuitTerminationSideChoices.SIDE_Z
).first()
if termination_a and termination_z:
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
print('swapping')
with transaction.atomic():
termination_a.term_side = '_'
termination_a.save()
@@ -250,30 +243,27 @@ def circuit_terminations_swap(request, pk):
else:
termination_z.term_side = 'A'
termination_z.save()
messages.success(request, "Swapped terminations for circuit {}.".format(circuit))
return redirect('circuits:circuit', pk=circuit.pk)
else:
form = ConfirmationForm()
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': termination_a,
'termination_z': termination_z,
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
return render(request, 'circuits/circuit_terminations_swap.html', {
'circuit': circuit,
'termination_a': circuit.termination_a,
'termination_z': circuit.termination_z,
'form': form,
'panel_class': 'default',
'button_class': 'primary',
'return_url': circuit.get_absolute_url(),
})
#
# Circuit terminations
#
class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'circuits.add_circuittermination'
model = CircuitTermination
class CircuitTerminationEditView(ObjectEditView):
queryset = CircuitTermination.objects.all()
model_form = forms.CircuitTerminationForm
template_name = 'circuits/circuittermination_edit.html'
@@ -286,10 +276,5 @@ class CircuitTerminationCreateView(PermissionRequiredMixin, ObjectEditView):
return obj.circuit.get_absolute_url()
class CircuitTerminationEditView(CircuitTerminationCreateView):
permission_required = 'circuits.change_circuittermination'
class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'circuits.delete_circuittermination'
model = CircuitTermination
class CircuitTerminationDeleteView(ObjectDeleteView):
queryset = CircuitTermination.objects.all()

View File

@@ -47,10 +47,11 @@ __all__ = [
class NestedRegionSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.Region
fields = ['id', 'url', 'name', 'slug', 'site_count']
fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth']
class NestedSiteSerializer(WritableNestedSerializer):
@@ -68,10 +69,11 @@ class NestedSiteSerializer(WritableNestedSerializer):
class NestedRackGroupSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
rack_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = models.RackGroup
fields = ['id', 'url', 'name', 'slug', 'rack_count']
fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth']
class NestedRackRoleSerializer(WritableNestedSerializer):
@@ -332,7 +334,7 @@ class NestedVirtualChassisSerializer(WritableNestedSerializer):
class Meta:
model = models.VirtualChassis
fields = ['id', 'url', 'master', 'member_count']
fields = ['id', 'name', 'url', 'master', 'member_count']
#

View File

@@ -3,7 +3,6 @@ from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.choices import *
from dcim.constants import *
@@ -15,6 +14,7 @@ from dcim.models import (
VirtualChassis,
)
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer
from ipam.models import VLAN
from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -60,20 +60,22 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer):
#
class RegionSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
parent = NestedRegionSerializer(required=False, allow_null=True)
site_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = Region
fields = ['id', 'name', 'slug', 'parent', 'description', 'site_count']
fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', '_depth']
class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail')
status = ChoiceField(choices=SiteStatusChoices, required=False)
region = NestedRegionSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
time_zone = TimeZoneField(required=False)
tags = TagListSerializerField(required=False)
circuit_count = serializers.IntegerField(read_only=True)
device_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
@@ -84,7 +86,7 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta:
model = Site
fields = [
'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description',
'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count',
'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count',
@@ -96,24 +98,28 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RackGroupSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail')
site = NestedSiteSerializer()
parent = NestedRackGroupSerializer(required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = RackGroup
fields = ['id', 'name', 'slug', 'site', 'parent', 'description', 'rack_count']
fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth']
class RackRoleSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail')
rack_count = serializers.IntegerField(read_only=True)
class Meta:
model = RackRole
fields = ['id', 'name', 'slug', 'color', 'description', 'rack_count']
fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count']
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail')
site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -122,14 +128,13 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
powerfeed_count = serializers.IntegerField(read_only=True)
class Meta:
model = Rack
fields = [
'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
]
@@ -162,14 +167,15 @@ class RackUnitSerializer(serializers.Serializer):
device = NestedDeviceSerializer(read_only=True)
class RackReservationSerializer(ValidatedModelSerializer):
class RackReservationSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail')
rack = NestedRackSerializer()
user = NestedUserSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
class Meta:
model = RackReservation
fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description']
fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags']
class RackElevationDetailFilterSerializer(serializers.Serializer):
@@ -213,6 +219,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
#
class ManufacturerSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail')
devicetype_count = serializers.IntegerField(read_only=True)
inventoryitem_count = serializers.IntegerField(read_only=True)
platform_count = serializers.IntegerField(read_only=True)
@@ -220,26 +227,27 @@ class ManufacturerSerializer(ValidatedModelSerializer):
class Meta:
model = Manufacturer
fields = [
'id', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count',
]
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail')
manufacturer = NestedManufacturerSerializer()
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False)
tags = TagListSerializerField(required=False)
device_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceType
fields = [
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'device_count',
]
class ConsolePortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -249,10 +257,11 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsolePortTemplate
fields = ['id', 'device_type', 'name', 'type']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -262,10 +271,11 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ConsoleServerPortTemplate
fields = ['id', 'device_type', 'name', 'type']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description']
class PowerPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
@@ -275,10 +285,11 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'maximum_draw', 'allocated_draw']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
class PowerOutletTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlettemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
@@ -296,43 +307,47 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = PowerOutletTemplate
fields = ['id', 'device_type', 'name', 'type', 'power_port', 'feed_leg']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
class InterfaceTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interfacetemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
class Meta:
model = InterfaceTemplate
fields = ['id', 'device_type', 'name', 'type', 'mgmt_only']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description']
class RearPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PortTypeChoices)
class Meta:
model = RearPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'positions']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description']
class FrontPortTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontporttemplate-detail')
device_type = NestedDeviceTypeSerializer()
type = ChoiceField(choices=PortTypeChoices)
rear_port = NestedRearPortTemplateSerializer()
class Meta:
model = FrontPortTemplate
fields = ['id', 'device_type', 'name', 'type', 'rear_port', 'rear_port_position']
fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
class DeviceBayTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
device_type = NestedDeviceTypeSerializer()
class Meta:
model = DeviceBayTemplate
fields = ['id', 'device_type', 'name']
fields = ['id', 'url', 'device_type', 'name', 'label', 'description']
#
@@ -340,17 +355,19 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
#
class DeviceRoleSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail')
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
class Meta:
model = DeviceRole
fields = [
'id', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count',
]
class PlatformSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail')
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True)
device_count = serializers.IntegerField(read_only=True)
virtualmachine_count = serializers.IntegerField(read_only=True)
@@ -358,12 +375,13 @@ class PlatformSerializer(ValidatedModelSerializer):
class Meta:
model = Platform
fields = [
'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count',
'virtualmachine_count',
]
class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail')
device_type = NestedDeviceTypeSerializer()
device_role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -378,15 +396,14 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
parent_device = serializers.SerializerMethodField()
cluster = NestedClusterSerializer(required=False, allow_null=True)
virtual_chassis = NestedVirtualChassisSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Device
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
'custom_fields', 'created', 'last_updated',
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'created', 'last_updated',
]
validators = []
@@ -419,10 +436,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class Meta(DeviceSerializer.Meta):
fields = [
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag',
'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', 'tags',
'custom_fields', 'config_context', 'created', 'last_updated',
'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data',
'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)
@@ -434,7 +451,8 @@ class DeviceNAPALMSerializer(serializers.Serializer):
method = serializers.DictField()
class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class ConsoleServerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -442,17 +460,17 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer)
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsoleServerPort
fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class ConsolePortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=ConsolePortTypeChoices,
@@ -460,17 +478,17 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = ConsolePort
fields = [
'id', 'device', 'name', 'type', 'description', 'connected_endpoint_type', 'connected_endpoint',
'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class PowerOutletSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerOutletTypeChoices,
@@ -488,19 +506,17 @@ class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer):
cable = NestedCableSerializer(
read_only=True
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerOutlet
fields = [
'id', 'device', 'name', 'type', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class PowerPortSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
choices=PowerPortTypeChoices,
@@ -508,17 +524,17 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
required=False
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = PowerPort
fields = [
'id', 'device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type',
'connected_endpoint', 'connection_status', 'cable', 'tags',
'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'tags',
]
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
class InterfaceSerializer(TaggedObjectSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
@@ -531,15 +547,14 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
many=True
)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
count_ipaddresses = serializers.IntegerField(read_only=True)
class Meta:
model = Interface
fields = [
'id', 'device', 'name', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description',
'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan',
'tagged_vlans', 'tags', 'count_ipaddresses',
'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only',
'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
]
# TODO: This validation should be handled by Interface.clean()
@@ -563,15 +578,15 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
return super().validate(data)
class RearPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class RearPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = RearPort
fields = ['id', 'device', 'name', 'type', 'positions', 'description', 'cable', 'tags']
fields = ['id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'tags']
class FrontPortRearPortSerializer(WritableNestedSerializer):
@@ -582,46 +597,49 @@ class FrontPortRearPortSerializer(WritableNestedSerializer):
class Meta:
model = RearPort
fields = ['id', 'url', 'name']
fields = ['id', 'url', 'name', 'label']
class FrontPortSerializer(TaggitSerializer, ValidatedModelSerializer):
class FrontPortSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
rear_port = FrontPortRearPortSerializer()
cable = NestedCableSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = FrontPort
fields = ['id', 'device', 'name', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'tags']
fields = [
'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
'tags',
]
class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer):
class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
device = NestedDeviceSerializer()
installed_device = NestedDeviceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = DeviceBay
fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags']
fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags']
#
# Inventory items
#
class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False)
class Meta:
model = InventoryItem
fields = [
'id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'id', 'url', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags',
]
@@ -630,7 +648,8 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
# Cables
#
class CableSerializer(ValidatedModelSerializer):
class CableSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail')
termination_a_type = ContentTypeField(
queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS)
)
@@ -645,8 +664,8 @@ class CableSerializer(ValidatedModelSerializer):
class Meta:
model = Cable
fields = [
'id', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id',
'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit',
'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags',
]
def _get_termination(self, obj, side):
@@ -709,21 +728,22 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer):
# Virtual chassis
#
class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer):
master = NestedDeviceSerializer()
tags = TagListSerializerField(required=False)
class VirtualChassisSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer(required=False)
member_count = serializers.IntegerField(read_only=True)
class Meta:
model = VirtualChassis
fields = ['id', 'master', 'domain', 'tags', 'member_count']
fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'member_count']
#
# Power panels
#
class PowerPanelSerializer(ValidatedModelSerializer):
class PowerPanelSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail')
site = NestedSiteSerializer()
rack_group = NestedRackGroupSerializer(
required=False,
@@ -734,10 +754,11 @@ class PowerPanelSerializer(ValidatedModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count']
fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'powerfeed_count']
class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
class PowerFeedSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
required=False,
@@ -760,13 +781,10 @@ class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer):
choices=PowerFeedPhaseChoices,
default=PowerFeedPhaseChoices.PHASE_SINGLE
)
tags = TagListSerializerField(
required=False
)
class Meta:
model = PowerFeed
fields = [
'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
]

View File

@@ -45,7 +45,7 @@ class CableTraceMixin(object):
"""
Trace a complete cable path and return each segment as a three-tuple of (termination, cable, termination).
"""
obj = get_object_or_404(self.queryset.model, pk=pk)
obj = get_object_or_404(self.queryset, pk=pk)
# Initialize the path array
path = []
@@ -75,8 +75,12 @@ class CableTraceMixin(object):
#
class RegionViewSet(ModelViewSet):
queryset = Region.objects.annotate(
site_count=Count('sites')
queryset = Region.objects.add_related_count(
Region.objects.all(),
Site,
'region',
'site_count',
cumulative=True
)
serializer_class = serializers.RegionSerializer
filterset_class = filters.RegionFilterSet
@@ -96,7 +100,7 @@ class SiteViewSet(CustomFieldModelViewSet):
vlan_count=get_subquery(VLAN, 'site'),
circuit_count=get_subquery(Circuit, 'terminations__site'),
virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'),
)
).order_by(*Site._meta.ordering)
serializer_class = serializers.SiteSerializer
filterset_class = filters.SiteFilterSet
@@ -105,8 +109,8 @@ class SiteViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular site.
"""
site = get_object_or_404(Site, pk=pk)
queryset = Graph.objects.filter(type__model='site')
site = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='site')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': site})
return Response(serializer.data)
@@ -116,9 +120,13 @@ class SiteViewSet(CustomFieldModelViewSet):
#
class RackGroupViewSet(ModelViewSet):
queryset = RackGroup.objects.prefetch_related('site').annotate(
rack_count=Count('racks')
)
queryset = RackGroup.objects.add_related_count(
RackGroup.objects.all(),
Rack,
'group',
'rack_count',
cumulative=True
).prefetch_related('site')
serializer_class = serializers.RackGroupSerializer
filterset_class = filters.RackGroupFilterSet
@@ -130,7 +138,7 @@ class RackGroupViewSet(ModelViewSet):
class RackRoleViewSet(ModelViewSet):
queryset = RackRole.objects.annotate(
rack_count=Count('racks')
)
).order_by(*RackRole._meta.ordering)
serializer_class = serializers.RackRoleSerializer
filterset_class = filters.RackRoleFilterSet
@@ -145,7 +153,7 @@ class RackViewSet(CustomFieldModelViewSet):
).annotate(
device_count=get_subquery(Device, 'rack'),
powerfeed_count=get_subquery(PowerFeed, 'rack')
)
).order_by(*Rack._meta.ordering)
serializer_class = serializers.RackSerializer
filterset_class = filters.RackFilterSet
@@ -158,7 +166,7 @@ class RackViewSet(CustomFieldModelViewSet):
"""
Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG.
"""
rack = get_object_or_404(Rack, pk=pk)
rack = get_object_or_404(self.queryset, pk=pk)
serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET)
if not serializer.is_valid():
return Response(serializer.errors, 400)
@@ -218,7 +226,7 @@ class ManufacturerViewSet(ModelViewSet):
devicetype_count=get_subquery(DeviceType, 'manufacturer'),
inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'),
platform_count=get_subquery(Platform, 'manufacturer')
)
).order_by(*Manufacturer._meta.ordering)
serializer_class = serializers.ManufacturerSerializer
filterset_class = filters.ManufacturerFilterSet
@@ -228,9 +236,9 @@ class ManufacturerViewSet(ModelViewSet):
#
class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.prefetch_related('manufacturer').prefetch_related('tags').annotate(
queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate(
device_count=Count('instances')
)
).order_by(*DeviceType._meta.ordering)
serializer_class = serializers.DeviceTypeSerializer
filterset_class = filters.DeviceTypeFilterSet
@@ -295,7 +303,7 @@ class DeviceRoleViewSet(ModelViewSet):
queryset = DeviceRole.objects.annotate(
device_count=get_subquery(Device, 'device_role'),
virtualmachine_count=get_subquery(VirtualMachine, 'role')
)
).order_by(*DeviceRole._meta.ordering)
serializer_class = serializers.DeviceRoleSerializer
filterset_class = filters.DeviceRoleFilterSet
@@ -308,7 +316,7 @@ class PlatformViewSet(ModelViewSet):
queryset = Platform.objects.annotate(
device_count=get_subquery(Device, 'platform'),
virtualmachine_count=get_subquery(VirtualMachine, 'platform')
)
).order_by(*Platform._meta.ordering)
serializer_class = serializers.PlatformSerializer
filterset_class = filters.PlatformFilterSet
@@ -349,8 +357,8 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
A convenience method for rendering graphs for a particular Device.
"""
device = get_object_or_404(Device, pk=pk)
queryset = Graph.objects.filter(type__model='device')
device = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='device')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': device})
return Response(serializer.data)
@@ -371,7 +379,9 @@ class DeviceViewSet(CustomFieldModelViewSet):
"""
Execute a NAPALM method on a Device
"""
device = get_object_or_404(Device, pk=pk)
device = get_object_or_404(self.queryset, pk=pk)
if not device.primary_ip:
raise ServiceUnavailable("This device does not have a primary IP address configured.")
if device.platform is None:
raise ServiceUnavailable("No platform is configured for this device.")
if not device.platform.napalm_driver:
@@ -411,7 +421,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
))
# Verify user permission
if not request.user.has_perm('dcim.napalm_read'):
if not request.user.has_perm('dcim.napalm_read_device'):
return HttpResponseForbidden()
napalm_methods = request.GET.getlist('method')
@@ -511,8 +521,8 @@ class InterfaceViewSet(CableTraceMixin, ModelViewSet):
"""
A convenience method for rendering graphs for a particular interface.
"""
interface = get_object_or_404(Interface, pk=pk)
queryset = Graph.objects.filter(type__model='interface')
interface = get_object_or_404(self.queryset, pk=pk)
queryset = Graph.objects.restrict(request.user).filter(type__model='interface')
serializer = RenderedGraphSerializer(queryset, many=True, context={'graphed_object': interface})
return Response(serializer.data)
@@ -597,7 +607,7 @@ class CableViewSet(ModelViewSet):
class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.prefetch_related('tags').annotate(
member_count=Count('members')
)
).order_by(*VirtualChassis._meta.ordering)
serializer_class = serializers.VirtualChassisSerializer
filterset_class = filters.VirtualChassisFilterSet
@@ -611,7 +621,7 @@ class PowerPanelViewSet(ModelViewSet):
'site', 'rack_group'
).annotate(
powerfeed_count=Count('powerfeeds')
)
).order_by(*PowerPanel._meta.ordering)
serializer_class = serializers.PowerPanelSerializer
filterset_class = filters.PowerPanelFilterSet
@@ -671,7 +681,11 @@ class ConnectedDeviceViewSet(ViewSet):
raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.')
# Determine local interface from peer interface's connection
peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name)
peer_interface = get_object_or_404(
Interface.objects.all(),
device__name=peer_device_name,
name=peer_interface_name
)
local_interface = peer_interface._connected_interface
if local_interface is None:

View File

@@ -21,12 +21,6 @@ class SiteStatusChoices(ChoiceSet):
(STATUS_RETIRED, 'Retired'),
)
LEGACY_MAP = {
STATUS_ACTIVE: 1,
STATUS_PLANNED: 2,
STATUS_RETIRED: 4,
}
#
# Racks
@@ -48,14 +42,6 @@ class RackTypeChoices(ChoiceSet):
(TYPE_WALLCABINET, 'Wall-mounted cabinet'),
)
LEGACY_MAP = {
TYPE_2POST: 100,
TYPE_4POST: 200,
TYPE_CABINET: 300,
TYPE_WALLFRAME: 1000,
TYPE_WALLCABINET: 1100,
}
class RackWidthChoices(ChoiceSet):
@@ -88,14 +74,6 @@ class RackStatusChoices(ChoiceSet):
(STATUS_DEPRECATED, 'Deprecated'),
)
LEGACY_MAP = {
STATUS_RESERVED: 0,
STATUS_AVAILABLE: 1,
STATUS_PLANNED: 2,
STATUS_ACTIVE: 3,
STATUS_DEPRECATED: 4,
}
class RackDimensionUnitChoices(ChoiceSet):
@@ -107,11 +85,6 @@ class RackDimensionUnitChoices(ChoiceSet):
(UNIT_INCH, 'Inches'),
)
LEGACY_MAP = {
UNIT_MILLIMETER: 1000,
UNIT_INCH: 2000,
}
class RackElevationDetailRenderChoices(ChoiceSet):
@@ -138,11 +111,6 @@ class SubdeviceRoleChoices(ChoiceSet):
(ROLE_CHILD, 'Child'),
)
LEGACY_MAP = {
ROLE_PARENT: True,
ROLE_CHILD: False,
}
#
# Devices
@@ -158,11 +126,6 @@ class DeviceFaceChoices(ChoiceSet):
(FACE_REAR, 'Rear'),
)
LEGACY_MAP = {
FACE_FRONT: 0,
FACE_REAR: 1,
}
class DeviceStatusChoices(ChoiceSet):
@@ -184,16 +147,6 @@ class DeviceStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
LEGACY_MAP = {
STATUS_OFFLINE: 0,
STATUS_ACTIVE: 1,
STATUS_PLANNED: 2,
STATUS_STAGED: 3,
STATUS_FAILED: 4,
STATUS_INVENTORY: 5,
STATUS_DECOMMISSIONING: 6,
}
#
# ConsolePorts
@@ -611,12 +564,6 @@ class PowerOutletFeedLegChoices(ChoiceSet):
(FEED_LEG_C, 'C'),
)
LEGACY_MAP = {
FEED_LEG_A: 1,
FEED_LEG_B: 2,
FEED_LEG_C: 3,
}
#
# Interfaces
@@ -846,80 +793,6 @@ class InterfaceTypeChoices(ChoiceSet):
),
)
LEGACY_MAP = {
TYPE_VIRTUAL: 0,
TYPE_LAG: 200,
TYPE_100ME_FIXED: 800,
TYPE_1GE_FIXED: 1000,
TYPE_1GE_GBIC: 1050,
TYPE_1GE_SFP: 1100,
TYPE_2GE_FIXED: 1120,
TYPE_5GE_FIXED: 1130,
TYPE_10GE_FIXED: 1150,
TYPE_10GE_CX4: 1170,
TYPE_10GE_SFP_PLUS: 1200,
TYPE_10GE_XFP: 1300,
TYPE_10GE_XENPAK: 1310,
TYPE_10GE_X2: 1320,
TYPE_25GE_SFP28: 1350,
TYPE_40GE_QSFP_PLUS: 1400,
TYPE_50GE_QSFP28: 1420,
TYPE_100GE_CFP: 1500,
TYPE_100GE_CFP2: 1510,
TYPE_100GE_CFP4: 1520,
TYPE_100GE_CPAK: 1550,
TYPE_100GE_QSFP28: 1600,
TYPE_200GE_CFP2: 1650,
TYPE_200GE_QSFP56: 1700,
TYPE_400GE_QSFP_DD: 1750,
TYPE_400GE_OSFP: 1800,
TYPE_80211A: 2600,
TYPE_80211G: 2610,
TYPE_80211N: 2620,
TYPE_80211AC: 2630,
TYPE_80211AD: 2640,
TYPE_GSM: 2810,
TYPE_CDMA: 2820,
TYPE_LTE: 2830,
TYPE_SONET_OC3: 6100,
TYPE_SONET_OC12: 6200,
TYPE_SONET_OC48: 6300,
TYPE_SONET_OC192: 6400,
TYPE_SONET_OC768: 6500,
TYPE_SONET_OC1920: 6600,
TYPE_SONET_OC3840: 6700,
TYPE_1GFC_SFP: 3010,
TYPE_2GFC_SFP: 3020,
TYPE_4GFC_SFP: 3040,
TYPE_8GFC_SFP_PLUS: 3080,
TYPE_16GFC_SFP_PLUS: 3160,
TYPE_32GFC_SFP28: 3320,
TYPE_128GFC_QSFP28: 3400,
TYPE_INFINIBAND_SDR: 7010,
TYPE_INFINIBAND_DDR: 7020,
TYPE_INFINIBAND_QDR: 7030,
TYPE_INFINIBAND_FDR10: 7040,
TYPE_INFINIBAND_FDR: 7050,
TYPE_INFINIBAND_EDR: 7060,
TYPE_INFINIBAND_HDR: 7070,
TYPE_INFINIBAND_NDR: 7080,
TYPE_INFINIBAND_XDR: 7090,
TYPE_T1: 4000,
TYPE_E1: 4010,
TYPE_T3: 4040,
TYPE_E3: 4050,
TYPE_STACKWISE: 5000,
TYPE_STACKWISE_PLUS: 5050,
TYPE_FLEXSTACK: 5100,
TYPE_FLEXSTACK_PLUS: 5150,
TYPE_JUNIPER_VCP: 5200,
TYPE_SUMMITSTACK: 5300,
TYPE_SUMMITSTACK128: 5310,
TYPE_SUMMITSTACK256: 5320,
TYPE_SUMMITSTACK512: 5330,
TYPE_OTHER: 32767,
}
class InterfaceModeChoices(ChoiceSet):
@@ -933,12 +806,6 @@ class InterfaceModeChoices(ChoiceSet):
(MODE_TAGGED_ALL, 'Tagged (All)'),
)
LEGACY_MAP = {
MODE_ACCESS: 100,
MODE_TAGGED: 200,
MODE_TAGGED_ALL: 300,
}
#
# FrontPorts/RearPorts
@@ -988,22 +855,6 @@ class PortTypeChoices(ChoiceSet):
)
)
LEGACY_MAP = {
TYPE_8P8C: 1000,
TYPE_110_PUNCH: 1100,
TYPE_BNC: 1200,
TYPE_ST: 2000,
TYPE_SC: 2100,
TYPE_SC_APC: 2110,
TYPE_FC: 2200,
TYPE_LC: 2300,
TYPE_LC_APC: 2310,
TYPE_MTRJ: 2400,
TYPE_MPO: 2500,
TYPE_LSH: 2600,
TYPE_LSH_APC: 2610,
}
#
# Cables
@@ -1063,28 +914,6 @@ class CableTypeChoices(ChoiceSet):
(TYPE_POWER, 'Power'),
)
LEGACY_MAP = {
TYPE_CAT3: 1300,
TYPE_CAT5: 1500,
TYPE_CAT5E: 1510,
TYPE_CAT6: 1600,
TYPE_CAT6A: 1610,
TYPE_CAT7: 1700,
TYPE_DAC_ACTIVE: 1800,
TYPE_DAC_PASSIVE: 1810,
TYPE_COAXIAL: 1900,
TYPE_MMF: 3000,
TYPE_MMF_OM1: 3010,
TYPE_MMF_OM2: 3020,
TYPE_MMF_OM3: 3030,
TYPE_MMF_OM4: 3040,
TYPE_SMF: 3500,
TYPE_SMF_OS1: 3510,
TYPE_SMF_OS2: 3520,
TYPE_AOC: 3800,
TYPE_POWER: 5000,
}
class CableStatusChoices(ChoiceSet):
@@ -1098,11 +927,6 @@ class CableStatusChoices(ChoiceSet):
(STATUS_DECOMMISSIONING, 'Decommissioning'),
)
LEGACY_MAP = {
STATUS_CONNECTED: True,
STATUS_PLANNED: False,
}
class CableLengthUnitChoices(ChoiceSet):
@@ -1118,13 +942,6 @@ class CableLengthUnitChoices(ChoiceSet):
(UNIT_INCH, 'Inches'),
)
LEGACY_MAP = {
UNIT_METER: 1200,
UNIT_CENTIMETER: 1100,
UNIT_FOOT: 2100,
UNIT_INCH: 2000,
}
#
# PowerFeeds
@@ -1144,13 +961,6 @@ class PowerFeedStatusChoices(ChoiceSet):
(STATUS_FAILED, 'Failed'),
)
LEGACY_MAP = {
STATUS_OFFLINE: 0,
STATUS_ACTIVE: 1,
STATUS_PLANNED: 2,
STATUS_FAILED: 4,
}
class PowerFeedTypeChoices(ChoiceSet):
@@ -1162,11 +972,6 @@ class PowerFeedTypeChoices(ChoiceSet):
(TYPE_REDUNDANT, 'Redundant'),
)
LEGACY_MAP = {
TYPE_PRIMARY: 1,
TYPE_REDUNDANT: 2,
}
class PowerFeedSupplyChoices(ChoiceSet):
@@ -1178,11 +983,6 @@ class PowerFeedSupplyChoices(ChoiceSet):
(SUPPLY_DC, 'DC'),
)
LEGACY_MAP = {
SUPPLY_AC: 1,
SUPPLY_DC: 2,
}
class PowerFeedPhaseChoices(ChoiceSet):
@@ -1193,8 +993,3 @@ class PowerFeedPhaseChoices(ChoiceSet):
(PHASE_SINGLE, 'Single phase'),
(PHASE_3PHASE, 'Three-phase'),
)
LEGACY_MAP = {
PHASE_SINGLE: 1,
PHASE_3PHASE: 3,
}

View File

@@ -298,6 +298,7 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet):
to_field_name='username',
label='User (name)',
)
tag = TagFilter()
class Meta:
model = RackReservation
@@ -383,28 +384,28 @@ class DeviceTypeFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFil
)
def _console_ports(self, queryset, name, value):
return queryset.exclude(consoleport_templates__isnull=value)
return queryset.exclude(consoleporttemplates__isnull=value)
def _console_server_ports(self, queryset, name, value):
return queryset.exclude(consoleserverport_templates__isnull=value)
return queryset.exclude(consoleserverporttemplates__isnull=value)
def _power_ports(self, queryset, name, value):
return queryset.exclude(powerport_templates__isnull=value)
return queryset.exclude(powerporttemplates__isnull=value)
def _power_outlets(self, queryset, name, value):
return queryset.exclude(poweroutlet_templates__isnull=value)
return queryset.exclude(poweroutlettemplates__isnull=value)
def _interfaces(self, queryset, name, value):
return queryset.exclude(interface_templates__isnull=value)
return queryset.exclude(interfacetemplates__isnull=value)
def _pass_through_ports(self, queryset, name, value):
return queryset.exclude(
frontport_templates__isnull=value,
rearport_templates__isnull=value
frontporttemplates__isnull=value,
rearporttemplates__isnull=value
)
def _device_bays(self, queryset, name, value):
return queryset.exclude(device_bay_templates__isnull=value)
return queryset.exclude(devicebaytemplates__isnull=value)
class DeviceTypeComponentFilterSet(NameSlugSearchFilterSet):
@@ -655,7 +656,7 @@ class DeviceFilterSet(
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(inventory_items__serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)
).distinct()
@@ -697,7 +698,7 @@ class DeviceFilterSet(
)
def _device_bays(self, queryset, name, value):
return queryset.exclude(device_bays__isnull=value)
return queryset.exclude(devicebays__isnull=value)
class DeviceComponentFilterSet(django_filters.FilterSet):
@@ -746,6 +747,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(label__icontains=value) |
Q(description__icontains=value)
)
@@ -1066,7 +1068,8 @@ class VirtualChassisFilterSet(BaseFilterSet):
if not value.strip():
return queryset
qs_filter = (
Q(master__name__icontains=value) |
Q(name__icontains=value) |
Q(members__name__icontains=value) |
Q(domain__icontains=value)
)
return queryset.filter(qs_filter)
@@ -1117,6 +1120,7 @@ class CableFilterSet(BaseFilterSet):
method='filter_device',
field_name='device__tenant__slug'
)
tag = TagFilter()
class Meta:
model = Cable
@@ -1265,6 +1269,7 @@ class PowerPanelFilterSet(BaseFilterSet):
lookup_expr='in',
label='Rack group (ID)',
)
tag = TagFilter()
class Meta:
model = PowerPanel

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ['name'], 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ['name']},
),
migrations.AddField(
model_name='platform',

View File

@@ -12,7 +12,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ('name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',

View File

@@ -79,42 +79,42 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebay',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='inventoryitem',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlet',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearport',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleports,

View File

@@ -75,37 +75,37 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='consoleporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='devicebaytemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='frontporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='powerporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='rearporttemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_consoleporttemplates,

View File

@@ -30,7 +30,7 @@ class Migration(migrations.Migration):
operations = [
migrations.AlterModelOptions(
name='device',
options={'ordering': ('_name', 'pk'), 'permissions': (('napalm_read', 'Read-only access to devices via NAPALM'), ('napalm_write', 'Read/write access to devices via NAPALM'))},
options={'ordering': ('_name', 'pk')},
),
migrations.AlterModelOptions(
name='rack',
@@ -43,17 +43,17 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='device',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize, null=True),
),
migrations.AddField(
model_name='rack',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.AddField(
model_name='site',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize),
),
migrations.RunPython(
code=naturalize_sites,

View File

@@ -35,12 +35,12 @@ class Migration(migrations.Migration):
migrations.AddField(
model_name='interface',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.AddField(
model_name='interfacetemplate',
name='_name',
field=utilities.fields.NaturalOrderingField('target_field', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
field=utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize_interface),
),
migrations.RunPython(
code=naturalize_interfacetemplates,

View File

@@ -0,0 +1,96 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0106_role_default_color'),
]
operations = [
migrations.AddField(
model_name='consoleport',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='consoleporttemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='consoleserverport',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='devicebay',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='devicebaytemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='frontport',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='frontporttemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='interface',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='interfacetemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='inventoryitem',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='poweroutlet',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='powerport',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='powerporttemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='rearport',
name='label',
field=models.CharField(blank=True, max_length=64),
),
migrations.AddField(
model_name='rearporttemplate',
name='label',
field=models.CharField(blank=True, max_length=64),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 3.0.6 on 2020-06-10 18:32
from django.db import migrations
import taggit.managers
class Migration(migrations.Migration):
dependencies = [
('extras', '0042_customfield_manager'),
('dcim', '0107_component_labels'),
]
operations = [
migrations.AddField(
model_name='cable',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='powerpanel',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
migrations.AddField(
model_name='rackreservation',
name='tags',
field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'),
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0108_add_tags'),
('virtualization', '0016_replicate_interfaces'),
]
operations = [
migrations.RemoveField(
model_name='interface',
name='virtual_machine',
),
# device is now a required field
migrations.AlterField(
model_name='interface',
name='device',
field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device'),
preserve_default=False,
),
]

View File

@@ -0,0 +1,46 @@
from django.db import migrations, models
import django.db.models.deletion
def copy_master_name(apps, schema_editor):
"""
Copy the master device's name to the VirtualChassis.
"""
VirtualChassis = apps.get_model('dcim', 'VirtualChassis')
for vc in VirtualChassis.objects.prefetch_related('master'):
name = vc.master.name if vc.master.name else f'Unnamed VC #{vc.pk}'
VirtualChassis.objects.filter(pk=vc.pk).update(name=name)
class Migration(migrations.Migration):
dependencies = [
('dcim', '0109_interface_remove_vm'),
]
operations = [
migrations.AlterModelOptions(
name='virtualchassis',
options={'ordering': ['name'], 'verbose_name_plural': 'virtual chassis'},
),
migrations.AddField(
model_name='virtualchassis',
name='name',
field=models.CharField(blank=True, max_length=64),
),
migrations.AlterField(
model_name='virtualchassis',
name='master',
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='vc_master_for', to='dcim.Device'),
),
migrations.RunPython(
code=copy_master_name,
reverse_code=migrations.RunPython.noop
),
migrations.AlterField(
model_name='virtualchassis',
name='name',
field=models.CharField(max_length=64),
),
]

View File

@@ -0,0 +1,53 @@
# Generated by Django 3.0.6 on 2020-06-30 18:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0110_virtualchassis_name'),
]
operations = [
migrations.AddField(
model_name='consoleporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='consoleserverporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='devicebaytemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='frontporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='interfacetemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='poweroutlettemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='powerporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
migrations.AddField(
model_name='rearporttemplate',
name='description',
field=models.CharField(blank=True, max_length=200),
),
]

View File

@@ -0,0 +1,120 @@
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('dcim', '0111_component_template_description'),
]
operations = [
# Set max_length=64 for all name fields
migrations.AlterField(
model_name='consoleport',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='consoleserverport',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='devicebay',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='inventoryitem',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='poweroutlet',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='powerport',
name='name',
field=models.CharField(max_length=64),
),
migrations.AlterField(
model_name='powerporttemplate',
name='name',
field=models.CharField(max_length=64),
),
# Update related_name for necessary component and component template models
migrations.AlterField(
model_name='consoleporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='devicebay',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.Device'),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='frontporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='interfacetemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='inventoryitem',
name='device',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.Device'),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='powerporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.DeviceType'),
),
migrations.AlterField(
model_name='rearporttemplate',
name='device_type',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.DeviceType'),
),
]

View File

@@ -0,0 +1,43 @@
# Generated by Django 3.1b1 on 2020-07-16 15:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0112_standardize_components'),
]
operations = [
migrations.AlterField(
model_name='consoleport',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='consoleserverport',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='interface',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='powerfeed',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='poweroutlet',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
migrations.AlterField(
model_name='powerport',
name='connection_status',
field=models.BooleanField(blank=True, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.1b1 on 2020-07-16 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0113_nullbooleanfield_to_booleanfield'),
]
operations = [
migrations.AlterField(
model_name='device',
name='local_context_data',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='platform',
name='napalm_args',
field=models.JSONField(blank=True, null=True),
),
]

View File

@@ -6,7 +6,7 @@ from django.conf import settings
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, JSONField
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
@@ -21,11 +21,12 @@ from dcim.choices import *
from dcim.constants import *
from dcim.fields import ASNField
from dcim.elevations import RackElevationSVG
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
from utilities.mptt import TreeManager
from utilities.utils import serialize_object, to_meters
from utilities.validators import ExclusionValidator
from .device_component_templates import (
@@ -33,11 +34,12 @@ from .device_component_templates import (
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
)
from .device_components import (
CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, PowerOutlet,
PowerPort, RearPort,
BaseInterface, CableTermination, ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem,
PowerOutlet, PowerPort, RearPort,
)
__all__ = (
'BaseInterface',
'Cable',
'CableTermination',
'ConsolePort',
@@ -103,6 +105,8 @@ class Region(MPTTModel, ChangeLoggedModel):
blank=True
)
objects = TreeManager()
csv_headers = ['name', 'slug', 'parent', 'description']
class MPTTMeta:
@@ -244,6 +248,8 @@ class Site(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address',
'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments',
@@ -328,6 +334,8 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
blank=True
)
objects = TreeManager()
csv_headers = ['site', 'parent', 'name', 'slug', 'description']
class Meta:
@@ -390,6 +398,8 @@ class RackRole(ChangeLoggedModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
@@ -528,6 +538,8 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width',
'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments',
@@ -569,7 +581,11 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
if self.pk:
# Validate that Rack is tall enough to house the installed Devices
top_device = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('-position').first()
top_device = Device.objects.filter(
rack=self
).exclude(
position__isnull=True
).order_by('-position').first()
if top_device:
min_height = top_device.position + top_device.device_type.u_height - 1
if self.u_height < min_height:
@@ -663,7 +679,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
'device_type__manufacturer',
'device_role'
).annotate(
devicebay_count=Count('device_bays')
devicebay_count=Count('devicebays')
).exclude(
pk=exclude
).filter(
@@ -685,7 +701,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
return [u for u in elevation.values()]
def get_available_units(self, u_height=1, rack_face=None, exclude=list()):
def get_available_units(self, u_height=1, rack_face=None, exclude=None):
"""
Return a list of units within the rack available to accommodate a device of a given U height (default 1).
Optionally exclude one or more devices when calculating empty units (needed when moving a device from one
@@ -695,9 +711,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel):
:param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth
:param exclude: List of devices IDs to exclude (useful when moving a device within a rack)
"""
# Gather all devices which consume U space within the rack
devices = self.devices.prefetch_related('device_type').filter(position__gte=1).exclude(pk__in=exclude)
devices = self.devices.prefetch_related('device_type').filter(position__gte=1)
if exclude is not None:
devices = devices.exclude(pk__in=exclude)
# Initialize the rack unit skeleton
units = list(range(1, self.u_height + 1))
@@ -822,6 +839,9 @@ class RackReservation(ChangeLoggedModel):
description = models.CharField(
max_length=200
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description']
@@ -902,6 +922,8 @@ class Manufacturer(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'description']
class Meta:
@@ -984,9 +1006,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
clone_fields = [
'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role',
]
@@ -1027,23 +1050,23 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
))
# Component templates
if self.consoleport_templates.exists():
if self.consoleporttemplates.exists():
data['console-ports'] = [
{
'name': c.name,
'type': c.type,
}
for c in self.consoleport_templates.all()
for c in self.consoleporttemplates.all()
]
if self.consoleserverport_templates.exists():
if self.consoleserverporttemplates.exists():
data['console-server-ports'] = [
{
'name': c.name,
'type': c.type,
}
for c in self.consoleserverport_templates.all()
for c in self.consoleserverporttemplates.all()
]
if self.powerport_templates.exists():
if self.powerporttemplates.exists():
data['power-ports'] = [
{
'name': c.name,
@@ -1051,9 +1074,9 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
}
for c in self.powerport_templates.all()
for c in self.powerporttemplates.all()
]
if self.poweroutlet_templates.exists():
if self.poweroutlettemplates.exists():
data['power-outlets'] = [
{
'name': c.name,
@@ -1061,18 +1084,18 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
}
for c in self.poweroutlet_templates.all()
for c in self.poweroutlettemplates.all()
]
if self.interface_templates.exists():
if self.interfacetemplates.exists():
data['interfaces'] = [
{
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
}
for c in self.interface_templates.all()
for c in self.interfacetemplates.all()
]
if self.frontport_templates.exists():
if self.frontporttemplates.exists():
data['front-ports'] = [
{
'name': c.name,
@@ -1080,23 +1103,23 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
}
for c in self.frontport_templates.all()
for c in self.frontporttemplates.all()
]
if self.rearport_templates.exists():
if self.rearporttemplates.exists():
data['rear-ports'] = [
{
'name': c.name,
'type': c.type,
'positions': c.positions,
}
for c in self.rearport_templates.all()
for c in self.rearporttemplates.all()
]
if self.device_bay_templates.exists():
if self.devicebaytemplates.exists():
data['device-bays'] = [
{
'name': c.name,
}
for c in self.device_bay_templates.all()
for c in self.devicebaytemplates.all()
]
return yaml.dump(dict(data), sort_keys=False)
@@ -1122,7 +1145,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
# If modifying the height of an existing DeviceType to 0U, check for any instances assigned to a rack position.
elif self.pk and self._original_u_height > 0 and self.u_height == 0:
racked_instance_count = Device.objects.filter(device_type=self, position__isnull=False).count()
racked_instance_count = Device.objects.filter(
device_type=self,
position__isnull=False
).count()
if racked_instance_count:
url = f"{reverse('dcim:device_list')}?manufactuer_id={self.manufacturer_id}&device_type_id={self.pk}"
raise ValidationError({
@@ -1134,7 +1160,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
if (
self.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT
) and self.device_bay_templates.count():
) and self.devicebaytemplates.count():
raise ValidationError({
'subdevice_role': "Must delete all device bay templates associated with this device before "
"declassifying it as a parent device."
@@ -1208,6 +1234,8 @@ class DeviceRole(ChangeLoggedModel):
blank=True,
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'vm_role', 'description']
class Meta:
@@ -1254,7 +1282,7 @@ class Platform(ChangeLoggedModel):
verbose_name='NAPALM driver',
help_text='The name of the NAPALM driver to use when interacting with devices'
)
napalm_args = JSONField(
napalm_args = models.JSONField(
blank=True,
null=True,
verbose_name='NAPALM arguments',
@@ -1265,6 +1293,8 @@ class Platform(ChangeLoggedModel):
blank=True
)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description']
class Meta:
@@ -1431,6 +1461,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
'site', 'rack_group', 'rack_name', 'position', 'face', 'comments',
@@ -1456,10 +1488,6 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
('rack', 'position', 'face'),
('virtual_chassis', 'vc_position'),
)
permissions = (
('napalm_read', 'Read-only access to devices via NAPALM'),
('napalm_write', 'Read/write access to devices via NAPALM'),
)
def __str__(self):
return self.display_name or super().__str__()
@@ -1473,7 +1501,11 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# because Django does not consider two NULL fields to be equal, and thus will not trigger a violation
# of the uniqueness constraint without manual intervention.
if self.name and self.tenant is None:
if Device.objects.exclude(pk=self.pk).filter(name=self.name, site=self.site, tenant__isnull=True):
if Device.objects.exclude(pk=self.pk).filter(
name=self.name,
site=self.site,
tenant__isnull=True
):
raise ValidationError({
'name': 'A device with this name already exists.'
})
@@ -1552,9 +1584,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
raise ValidationError({
'primary_ip4': f"{self.primary_ip4} is not an IPv4 address."
})
if self.primary_ip4.interface in vc_interfaces:
if self.primary_ip4.assigned_object in vc_interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces:
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
@@ -1565,9 +1597,9 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
raise ValidationError({
'primary_ip6': f"{self.primary_ip6} is not an IPv6 address."
})
if self.primary_ip6.interface in vc_interfaces:
if self.primary_ip6.assigned_object in vc_interfaces:
pass
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces:
elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.assigned_object in vc_interfaces:
pass
else:
raise ValidationError({
@@ -1603,28 +1635,28 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
# If this is a new Device, instantiate all of the related components per the DeviceType definition
if is_new:
ConsolePort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.consoleport_templates.all()]
[x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
)
ConsoleServerPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.consoleserverport_templates.all()]
[x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
)
PowerPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.powerport_templates.all()]
[x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
)
PowerOutlet.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.poweroutlet_templates.all()]
[x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
)
Interface.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.interface_templates.all()]
[x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
)
RearPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.rearport_templates.all()]
[x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
)
FrontPort.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.frontport_templates.all()]
[x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
)
DeviceBay.objects.bulk_create(
[x.instantiate(self) for x in self.device_type.device_bay_templates.all()]
[x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
)
# Update Site and Rack assignment for any child Devices
@@ -1737,23 +1769,29 @@ class VirtualChassis(ChangeLoggedModel):
master = models.OneToOneField(
to='Device',
on_delete=models.PROTECT,
related_name='vc_master_for'
related_name='vc_master_for',
blank=True,
null=True
)
name = models.CharField(
max_length=64
)
domain = models.CharField(
max_length=30,
blank=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['master', 'domain']
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'domain', 'master']
class Meta:
ordering = ['master']
ordering = ['name']
verbose_name_plural = 'virtual chassis'
def __str__(self):
return str(self.master) if hasattr(self, 'master') else 'New Virtual Chassis'
return self.name
def get_absolute_url(self):
return reverse('dcim:virtualchassis', kwargs={'pk': self.pk})
@@ -1762,9 +1800,9 @@ class VirtualChassis(ChangeLoggedModel):
# Verify that the selected master device has been assigned to this VirtualChassis. (Skip when creating a new
# VirtualChassis.)
if self.pk and self.master not in self.members.all():
if self.pk and self.master and self.master not in self.members.all():
raise ValidationError({
'master': "The selected master is not assigned to this virtual chassis."
'master': f"The selected master ({self.master}) is not assigned to this virtual chassis."
})
def delete(self, *args, **kwargs):
@@ -1778,8 +1816,7 @@ class VirtualChassis(ChangeLoggedModel):
)
if interfaces:
raise ProtectedError(
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
"LAG".format(self),
f"Unable to delete virtual chassis {self}. There are member interfaces which form a cross-chassis LAG",
interfaces
)
@@ -1787,8 +1824,9 @@ class VirtualChassis(ChangeLoggedModel):
def to_csv(self):
return (
self.master,
self.name,
self.domain,
self.master.name if self.master else None,
)
@@ -1814,6 +1852,9 @@ class PowerPanel(ChangeLoggedModel):
name = models.CharField(
max_length=50
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = ['site', 'rack_group', 'name']
@@ -1866,9 +1907,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
blank=True,
null=True
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
name = models.CharField(
max_length=50
@@ -1918,9 +1960,10 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
content_type_field='obj_type',
object_id_field='obj_id'
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'comments',
@@ -2085,6 +2128,9 @@ class Cable(ChangeLoggedModel):
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id', 'type', 'status', 'label',

View File

@@ -6,6 +6,7 @@ from dcim.choices import *
from dcim.constants import *
from extras.models import ObjectChange
from utilities.fields import NaturalOrderingField
from utilities.querysets import RestrictedQuerySet
from utilities.ordering import naturalize_interface
from utilities.utils import serialize_object
from .device_components import (
@@ -26,10 +27,39 @@ __all__ = (
class ComponentTemplateModel(models.Model):
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='%(class)ss'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
description = models.CharField(
max_length=200,
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
def __str__(self):
if self.label:
return f"{self.name} ({self.label})"
return self.name
def instantiate(self, device):
"""
Instantiate a new component on the specified Device.
@@ -56,19 +86,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
"""
A template for a ConsolePort to be created for a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='consoleport_templates'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -79,9 +96,6 @@ class ConsolePortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return ConsolePort(
device=device,
@@ -94,19 +108,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
"""
A template for a ConsoleServerPort to be created for a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='consoleserverport_templates'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -117,9 +118,6 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return ConsoleServerPort(
device=device,
@@ -132,19 +130,6 @@ class PowerPortTemplate(ComponentTemplateModel):
"""
A template for a PowerPort to be created for a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='powerport_templates'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -167,9 +152,6 @@ class PowerPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return PowerPort(
device=device,
@@ -184,19 +166,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
"""
A template for a PowerOutlet to be created for a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='poweroutlet_templates'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -220,9 +189,6 @@ class PowerOutletTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def clean(self):
# Validate power port assignment
@@ -249,14 +215,7 @@ class InterfaceTemplate(ComponentTemplateModel):
"""
A template for a physical data interface on a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='interface_templates'
)
name = models.CharField(
max_length=64
)
# Override ComponentTemplateModel._name to specify naturalize_interface function
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
@@ -276,9 +235,6 @@ class InterfaceTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return Interface(
device=device,
@@ -292,19 +248,6 @@ class FrontPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the front of a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='frontport_templates'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -326,9 +269,6 @@ class FrontPortTemplate(ComponentTemplateModel):
('rear_port', 'rear_port_position'),
)
def __str__(self):
return self.name
def clean(self):
# Validate rear port assignment
@@ -363,19 +303,6 @@ class RearPortTemplate(ComponentTemplateModel):
"""
Template for a pass-through port on the rear of a new Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='rearport_templates'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -389,9 +316,6 @@ class RearPortTemplate(ComponentTemplateModel):
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return RearPort(
device=device,
@@ -405,27 +329,10 @@ class DeviceBayTemplate(ComponentTemplateModel):
"""
A template for a DeviceBay to be created for a new parent Device.
"""
device_type = models.ForeignKey(
to='dcim.DeviceType',
on_delete=models.CASCADE,
related_name='device_bay_templates'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
class Meta:
ordering = ('device_type', '_name')
unique_together = ('device_type', 'name')
def __str__(self):
return self.name
def instantiate(self, device):
return DeviceBay(
device=device,

View File

@@ -16,9 +16,9 @@ from extras.models import ObjectChange, TaggedItem
from extras.utils import extras_features
from utilities.fields import NaturalOrderingField
from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
from utilities.utils import serialize_object
from virtualization.choices import VMInterfaceTypeChoices
__all__ = (
@@ -36,30 +36,46 @@ __all__ = (
class ComponentModel(models.Model):
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='%(class)ss'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
label = models.CharField(
max_length=64,
blank=True,
help_text="Physical label"
)
description = models.CharField(
max_length=200,
blank=True
)
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
def __str__(self):
return getattr(self, 'name')
if self.label:
return f"{self.name} ({self.label})"
return self.name
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
parent = getattr(self, 'device', None) or getattr(self, 'virtual_machine', None)
except ObjectDoesNotExist:
# The parent device/VM has already been deleted
parent = None
# Annotate the parent Device
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent,
related_object=self.device,
object_data=serialize_object(self)
)
@@ -235,19 +251,6 @@ class ConsolePort(CableTermination, ComponentModel):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='consoleports'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
@@ -261,25 +264,27 @@ class ConsolePort(CableTermination, ComponentModel):
blank=True,
null=True
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.type,
self.description,
)
@@ -294,44 +299,33 @@ class ConsoleServerPort(CableTermination, ComponentModel):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='consoleserverports'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=ConsolePortTypeChoices,
blank=True,
help_text='Physical port type'
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:consoleserverport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.type,
self.description,
)
@@ -346,19 +340,6 @@ class PowerPort(CableTermination, ComponentModel):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='powerports'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerPortTypeChoices,
@@ -391,25 +372,27 @@ class PowerPort(CableTermination, ComponentModel):
blank=True,
null=True
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'maximum_draw', 'allocated_draw', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:powerport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.maximum_draw,
self.allocated_draw,
@@ -506,19 +489,6 @@ class PowerOutlet(CableTermination, ComponentModel):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='poweroutlets'
)
name = models.CharField(
max_length=50
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PowerOutletTypeChoices,
@@ -538,25 +508,27 @@ class PowerOutlet(CableTermination, ComponentModel):
blank=True,
help_text="Phase (for three-phase feeds)"
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'power_port', 'feed_leg', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:poweroutlet', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.power_port.name if self.power_port else None,
self.get_feed_leg_display(),
@@ -576,29 +548,40 @@ class PowerOutlet(CableTermination, ComponentModel):
# Interfaces
#
class BaseInterface(models.Model):
"""
Abstract base class for fields shared by dcim.Interface and virtualization.VMInterface.
"""
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
class Meta:
abstract = True
@extras_features('graphs', 'export_templates', 'webhooks')
class Interface(CableTermination, ComponentModel):
class Interface(CableTermination, ComponentModel, BaseInterface):
"""
A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
Interface.
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
device = models.ForeignKey(
to='Device',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
on_delete=models.CASCADE,
related_name='interfaces',
null=True,
blank=True
)
name = models.CharField(
max_length=64
)
# Override ComponentModel._name to specify naturalize_interface function
_name = NaturalOrderingField(
target_field='name',
naturalize_function=naturalize_interface,
@@ -619,9 +602,10 @@ class Interface(CableTermination, ComponentModel):
blank=True,
null=True
)
connection_status = models.NullBooleanField(
connection_status = models.BooleanField(
choices=CONNECTION_STATUS_CHOICES,
blank=True
blank=True,
null=True
)
lag = models.ForeignKey(
to='self',
@@ -635,30 +619,11 @@ class Interface(CableTermination, ComponentModel):
max_length=50,
choices=InterfaceTypeChoices
)
enabled = models.BooleanField(
default=True
)
mac_address = MACAddressField(
null=True,
blank=True,
verbose_name='MAC Address'
)
mtu = models.PositiveIntegerField(
blank=True,
null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU'
)
mgmt_only = models.BooleanField(
default=False,
verbose_name='OOB Management',
help_text='This interface is used only for out-of-band management'
)
mode = models.CharField(
max_length=50,
choices=InterfaceModeChoices,
blank=True
)
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -673,15 +638,19 @@ class Interface(CableTermination, ComponentModel):
blank=True,
verbose_name='Tagged VLANs'
)
ip_addresses = GenericRelation(
to='ipam.IPAddress',
content_type_field='assigned_object_type',
object_id_field='assigned_object_id',
related_query_name='interface'
)
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only',
'description', 'mode',
'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode',
]
class Meta:
# TODO: ordering and unique_together should include virtual_machine
ordering = ('device', CollateAsChar('_name'))
unique_together = ('device', 'name')
@@ -691,8 +660,8 @@ class Interface(CableTermination, ComponentModel):
def to_csv(self):
return (
self.device.identifier if self.device else None,
self.virtual_machine.name if self.virtual_machine else None,
self.name,
self.label,
self.lag.name if self.lag else None,
self.get_type_display(),
self.enabled,
@@ -705,18 +674,6 @@ class Interface(CableTermination, ComponentModel):
def clean(self):
# An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.")
if not self.device and not self.virtual_machine:
raise ValidationError("An interface must belong to either a device or a virtual machine.")
# VM interfaces must be virtual
if self.virtual_machine and self.type not in VMInterfaceTypeChoices.values():
raise ValidationError({
'type': "Invalid interface type for a virtual machine: {}".format(self.type)
})
# Virtual interfaces cannot be connected
if self.type in NONCONNECTABLE_IFACE_TYPES and (
self.cable or getattr(self, 'circuit_termination', False)
@@ -752,7 +709,7 @@ class Interface(CableTermination, ComponentModel):
if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]:
raise ValidationError({
'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent "
"device/VM, or it must be global".format(self.untagged_vlan)
"device, or it must be global".format(self.untagged_vlan)
})
def save(self, *args, **kwargs):
@@ -767,21 +724,6 @@ class Interface(CableTermination, ComponentModel):
return super().save(*args, **kwargs)
def to_objectchange(self, action):
# Annotate the parent Device/VM
try:
parent_obj = self.device or self.virtual_machine
except ObjectDoesNotExist:
parent_obj = None
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
related_object=parent_obj,
object_data=serialize_object(self)
)
@property
def connected_endpoint(self):
"""
@@ -820,7 +762,7 @@ class Interface(CableTermination, ComponentModel):
@property
def parent(self):
return self.device or self.virtual_machine
return self.device
@property
def is_connectable(self):
@@ -852,19 +794,6 @@ class FrontPort(CableTermination, ComponentModel):
"""
A pass-through port on the front of a Device.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='frontports'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -880,7 +809,7 @@ class FrontPort(CableTermination, ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description']
class Meta:
ordering = ('device', '_name')
@@ -889,10 +818,14 @@ class FrontPort(CableTermination, ComponentModel):
('rear_port', 'rear_port_position'),
)
def get_absolute_url(self):
return reverse('dcim:frontport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.rear_port.name,
self.rear_port_position,
@@ -921,19 +854,6 @@ class RearPort(CableTermination, ComponentModel):
"""
A pass-through port on the rear of a Device.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='rearports'
)
name = models.CharField(
max_length=64
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
type = models.CharField(
max_length=50,
choices=PortTypeChoices
@@ -944,16 +864,20 @@ class RearPort(CableTermination, ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'type', 'positions', 'description']
csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return reverse('dcim:rearport', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.get_type_display(),
self.positions,
self.description,
@@ -969,20 +893,6 @@ class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='device_bays'
)
name = models.CharField(
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
installed_device = models.OneToOneField(
to='dcim.Device',
on_delete=models.SET_NULL,
@@ -992,19 +902,20 @@ class DeviceBay(ComponentModel):
)
tags = TaggableManager(through=TaggedItem)
csv_headers = ['device', 'name', 'installed_device', 'description']
csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
class Meta:
ordering = ('device', '_name')
unique_together = ('device', 'name')
def get_absolute_url(self):
return self.device.get_absolute_url()
return reverse('dcim:devicebay', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.identifier,
self.name,
self.label,
self.installed_device.identifier if self.installed_device else None,
self.description,
)
@@ -1042,11 +953,6 @@ class InventoryItem(ComponentModel):
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
InventoryItems are used only for inventory purposes.
"""
device = models.ForeignKey(
to='dcim.Device',
on_delete=models.CASCADE,
related_name='inventory_items'
)
parent = models.ForeignKey(
to='self',
on_delete=models.CASCADE,
@@ -1054,15 +960,6 @@ class InventoryItem(ComponentModel):
blank=True,
null=True
)
name = models.CharField(
max_length=50,
verbose_name='Name'
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
@@ -1097,23 +994,21 @@ class InventoryItem(ComponentModel):
tags = TaggableManager(through=TaggedItem)
csv_headers = [
'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
]
class Meta:
ordering = ('device__id', 'parent__id', '_name')
unique_together = ('device', 'parent', 'name')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:device_inventory', kwargs={'pk': self.device.pk})
return reverse('dcim:inventoryitem', kwargs={'pk': self.pk})
def to_csv(self):
return (
self.device.name or '{{{}}}'.format(self.device.pk),
self.name,
self.label,
self.manufacturer.name if self.manufacturer else None,
self.part_id,
self.serial,

View File

@@ -10,14 +10,13 @@ from .models import Cable, CableTermination, Device, FrontPort, RearPort, Virtua
@receiver(post_save, sender=VirtualChassis)
def assign_virtualchassis_master(instance, created, **kwargs):
"""
When a VirtualChassis is created, automatically assign its master device to the VC.
When a VirtualChassis is created, automatically assign its master device (if any) to the VC.
"""
if created:
devices = Device.objects.filter(pk=instance.master.pk)
for device in devices:
device.virtual_chassis = instance
device.vc_position = None
device.save()
if created and instance.master:
master = Device.objects.get(pk=instance.master.pk)
master.virtual_chassis = instance
master.vc_position = 1
master.save()
@receiver(pre_delete, sender=VirtualChassis)

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
@@ -28,9 +29,46 @@ class AppTest(APITestCase):
self.assertEqual(response.status_code, 200)
class Mixins:
class ComponentTraceMixin(APITestCase):
peer_termination_type = None
def test_trace(self):
"""
Test tracing a device component's attached cable.
"""
obj = self.model.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
if self.peer_termination_type is None:
raise NotImplementedError("Test case must set peer_termination_type")
peer_obj = self.peer_termination_type.objects.create(
device=peer_device,
name='Peer Termination'
)
cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1')
cable.save()
self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
url = reverse(f'dcim-api:{self.model._meta.model_name}-trace', kwargs={'pk': obj.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], obj.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], peer_obj.name)
class RegionTest(APIViewTestCases.APIViewTestCase):
model = Region
brief_fields = ['id', 'name', 'site_count', 'slug', 'url']
brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url']
create_data = [
{
'name': 'Region 4',
@@ -94,6 +132,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
},
]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_site_graphs(self):
"""
Test retrieval of Graphs assigned to Sites.
@@ -106,6 +145,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_site')
url = reverse('dcim-api:site-graphs', kwargs={'pk': Site.objects.first().pk})
response = self.client.get(url, **self.header)
@@ -115,7 +155,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
class RackGroupTest(APIViewTestCases.APIViewTestCase):
model = RackGroup
brief_fields = ['id', 'name', 'rack_count', 'slug', 'url']
brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url']
@classmethod
def setUpTestData(cls):
@@ -241,48 +281,35 @@ class RackTest(APIViewTestCases.APIViewTestCase):
},
]
# TODO: Document this test
def test_get_elevation_rack_units(self):
rack = Rack.objects.first()
url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 13)
url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 11)
url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 1)
def test_get_rack_elevation(self):
"""
GET a single rack elevation.
"""
rack = Rack.objects.first()
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk})
response = self.client.get(url, **self.header)
# Retrieve all units
response = self.client.get(url, **self.header)
self.assertEqual(response.data['count'], 42)
# Search for specific units
response = self.client.get(f'{url}?q=3', **self.header)
self.assertEqual(response.data['count'], 13)
response = self.client.get(f'{url}?q=U3', **self.header)
self.assertEqual(response.data['count'], 11)
response = self.client.get(f'{url}?q=U10', **self.header)
self.assertEqual(response.data['count'], 1)
def test_get_rack_elevation_svg(self):
"""
GET a single rack elevation in SVG format.
"""
rack = Rack.objects.first()
self.add_permissions('dcim.view_rack')
url = '{}?render=svg'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': rack.pk}))
response = self.client.get(url, **self.header)
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.get('Content-Type'), 'image/svg+xml')
@@ -293,9 +320,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase):
@classmethod
def setUpTestData(cls):
user = User.objects.create(username='user1', is_active=True)
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
cls.racks = (
@@ -877,6 +902,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
},
]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_device_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
@@ -889,6 +915,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-graphs', kwargs={'pk': Device.objects.first().pk})
response = self.client.get(url, **self.header)
@@ -899,6 +926,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
"""
Check that config context data is included by default in the devices list.
"""
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-list') + '?slug=device-with-context-data'
response = self.client.get(url, **self.header)
@@ -908,6 +936,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
"""
Check that config context data can be excluded by passing ?exclude=config_context.
"""
self.add_permissions('dcim.view_device')
url = reverse('dcim-api:device-list') + '?exclude=config_context'
response = self.client.get(url, **self.header)
@@ -925,15 +954,17 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
'name': device.name,
}
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
class ConsolePortTest(APIViewTestCases.APIViewTestCase):
class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsolePort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = ConsoleServerPort
@classmethod
def setUpTestData(cls):
@@ -965,38 +996,11 @@ class ConsolePortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_consoleport(self):
"""
Test tracing a ConsolePort cable.
"""
consoleport = ConsolePort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
consoleserverport = ConsoleServerPort.objects.create(
device=peer_device,
name='Console Server Port 1'
)
cable = Cable(termination_a=consoleport, termination_b=consoleserverport, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleport-trace', kwargs={'pk': consoleport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], consoleport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], consoleserverport.name)
class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = ConsoleServerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = ConsolePort
@classmethod
def setUpTestData(cls):
@@ -1028,38 +1032,11 @@ class ConsoleServerPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_consoleserverport(self):
"""
Test tracing a ConsoleServerPort cable.
"""
consoleserverport = ConsoleServerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
consoleport = ConsolePort.objects.create(
device=peer_device,
name='Console Port 1'
)
cable = Cable(termination_a=consoleserverport, termination_b=consoleport, label='Cable 1')
cable.save()
url = reverse('dcim-api:consoleserverport-trace', kwargs={'pk': consoleserverport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], consoleserverport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], consoleport.name)
class PowerPortTest(APIViewTestCases.APIViewTestCase):
class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerPort
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = PowerOutlet
@classmethod
def setUpTestData(cls):
@@ -1091,38 +1068,11 @@ class PowerPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_powerport(self):
"""
Test tracing a PowerPort cable.
"""
powerport = PowerPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
poweroutlet = PowerOutlet.objects.create(
device=peer_device,
name='Power Outlet 1'
)
cable = Cable(termination_a=powerport, termination_b=poweroutlet, label='Cable 1')
cable.save()
url = reverse('dcim-api:powerport-trace', kwargs={'pk': powerport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], powerport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], poweroutlet.name)
class PowerOutletTest(APIViewTestCases.APIViewTestCase):
class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = PowerOutlet
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = PowerPort
@classmethod
def setUpTestData(cls):
@@ -1154,38 +1104,11 @@ class PowerOutletTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_poweroutlet(self):
"""
Test tracing a PowerOutlet cable.
"""
poweroutlet = PowerOutlet.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
powerport = PowerPort.objects.create(
device=peer_device,
name='Power Port 1'
)
cable = Cable(termination_a=poweroutlet, termination_b=powerport, label='Cable 1')
cable.save()
url = reverse('dcim-api:poweroutlet-trace', kwargs={'pk': poweroutlet.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], poweroutlet.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], powerport.name)
class InterfaceTest(APIViewTestCases.APIViewTestCase):
class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = Interface
brief_fields = ['cable', 'connection_status', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1236,6 +1159,7 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
},
]
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_get_interface_graphs(self):
"""
Test retrieval of Graphs assigned to Devices.
@@ -1248,44 +1172,18 @@ class InterfaceTest(APIViewTestCases.APIViewTestCase):
)
Graph.objects.bulk_create(graphs)
self.add_permissions('dcim.view_interface')
url = reverse('dcim-api:interface-graphs', kwargs={'pk': Interface.objects.first().pk})
response = self.client.get(url, **self.header)
self.assertEqual(len(response.data), 3)
self.assertEqual(response.data[0]['embed_url'], 'http://example.com/graphs.py?interface=Interface 1&foo=1')
def test_trace_interface(self):
"""
Test tracing an Interface cable.
"""
interface_a = Interface.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface_b = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=interface_a, termination_b=interface_b, label='Cable 1')
cable.save()
url = reverse('dcim-api:interface-trace', kwargs={'pk': interface_a.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], interface_a.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface_b.name)
class FrontPortTest(APIViewTestCases.APIViewTestCase):
class FrontPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = FrontPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1336,38 +1234,11 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_frontport(self):
"""
Test tracing a FrontPort cable.
"""
frontport = FrontPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=frontport, termination_b=interface, label='Cable 1')
cable.save()
url = reverse('dcim-api:frontport-trace', kwargs={'pk': frontport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], frontport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class RearPortTest(APIViewTestCases.APIViewTestCase):
class RearPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
model = RearPort
brief_fields = ['cable', 'device', 'id', 'name', 'url']
peer_termination_type = Interface
@classmethod
def setUpTestData(cls):
@@ -1402,34 +1273,6 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
},
]
def test_trace_rearport(self):
"""
Test tracing a RearPort cable.
"""
rearport = RearPort.objects.first()
peer_device = Device.objects.create(
site=Site.objects.first(),
device_type=DeviceType.objects.first(),
device_role=DeviceRole.objects.first(),
name='Peer Device'
)
interface = Interface.objects.create(
device=peer_device,
name='Interface X'
)
cable = Cable(termination_a=rearport, termination_b=interface, label='Cable 1')
cable.save()
url = reverse('dcim-api:rearport-trace', kwargs={'pk': rearport.pk})
response = self.client.get(url, **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
segment1 = response.data[0]
self.assertEqual(segment1[0]['name'], rearport.name)
self.assertEqual(segment1[1]['label'], cable.label)
self.assertEqual(segment1[2]['name'], interface.name)
class DeviceBayTest(APIViewTestCases.APIViewTestCase):
model = DeviceBay
@@ -1635,6 +1478,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': consoleserverport1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1673,6 +1517,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Console port to panel1 front
@@ -1728,6 +1573,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': poweroutlet1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1763,6 +1609,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': interface2.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1801,6 +1648,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface1 to panel1 front
@@ -1865,6 +1713,7 @@ class ConnectionTest(APITestCase):
'termination_b_id': circuittermination1.pk,
}
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
response = self.client.post(url, data, format='json', **self.header)
@@ -1912,6 +1761,7 @@ class ConnectionTest(APITestCase):
device=self.panel2, name='Test Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rearport2
)
self.add_permissions('dcim.add_cable')
url = reverse('dcim-api:cable-list')
cables = [
# Interface to panel1 front
@@ -1996,7 +1846,7 @@ class ConnectedDeviceTest(APITestCase):
class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
model = VirtualChassis
brief_fields = ['id', 'master', 'member_count', 'url']
brief_fields = ['id', 'master', 'member_count', 'name', 'url']
@classmethod
def setUpTestData(cls):
@@ -2015,6 +1865,9 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
Device(name='Device 7', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 8', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 9', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 10', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 11', device_type=devicetype, device_role=devicerole, site=site),
Device(name='Device 12', device_type=devicetype, device_role=devicerole, site=site),
)
Device.objects.bulk_create(devices)
@@ -2028,35 +1881,39 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase):
)
Interface.objects.bulk_create(interfaces)
# Create two VirtualChassis with three members each
# Create three VirtualChassis with three members each
virtual_chassis = (
VirtualChassis(master=devices[0], domain='domain-1'),
VirtualChassis(master=devices[3], domain='domain-2'),
VirtualChassis(name='Virtual Chassis 1', master=devices[0], domain='domain-1'),
VirtualChassis(name='Virtual Chassis 2', master=devices[3], domain='domain-2'),
VirtualChassis(name='Virtual Chassis 3', master=devices[6], domain='domain-3'),
)
VirtualChassis.objects.bulk_create(virtual_chassis)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis[0], vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=virtual_chassis[0], vc_position=3)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=virtual_chassis[1], vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=virtual_chassis[1], vc_position=3)
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=virtual_chassis[2], vc_position=2)
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=virtual_chassis[2], vc_position=3)
cls.update_data = {
'master': devices[1].pk,
'name': 'Virtual Chassis X',
'domain': 'domain-x',
'master': devices[1].pk,
}
cls.create_data = [
{
'master': devices[6].pk,
'domain': 'domain-3',
},
{
'master': devices[7].pk,
'name': 'Virtual Chassis 4',
'domain': 'domain-4',
},
{
'master': devices[8].pk,
'name': 'Virtual Chassis 5',
'domain': 'domain-5',
},
{
'name': 'Virtual Chassis 6',
'domain': 'domain-6',
},
]

View File

@@ -1254,8 +1254,8 @@ class DeviceTestCase(TestCase):
# Assign primary IPs for filtering
ipaddresses = (
IPAddress(address='192.0.2.1/24', interface=interfaces[0]),
IPAddress(address='192.0.2.2/24', interface=interfaces[1]),
IPAddress(address='192.0.2.1/24', assigned_object=interfaces[0]),
IPAddress(address='192.0.2.2/24', assigned_object=interfaces[1]),
)
IPAddress.objects.bulk_create(ipaddresses)
Device.objects.filter(pk=devices[0].pk).update(primary_ip4=ipaddresses[0])

View File

@@ -116,3 +116,45 @@ class DeviceTestCase(TestCase):
# Check that the initial value for the cluster group is set automatically when assigning the cluster
self.assertEqual(test.initial['cluster_group'], cluster.group.pk)
class LabelTestCase(TestCase):
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 2', slug='site-2')
manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
cls.device_type = DeviceType.objects.create(
manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
)
device_role = DeviceRole.objects.create(
name='Device Role 2', slug='device-role-2', color='ffff00'
)
cls.device = Device.objects.create(
name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
)
def test_interface_label_count_valid(self):
"""Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-9]',
'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
}
form = InterfaceCreateForm(interface_data)
self.assertTrue(form.is_valid())
def test_interface_label_count_mismatch(self):
"""Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
bad_interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-1]',
'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
}
form = InterfaceCreateForm(bad_interface_data)
self.assertFalse(form.is_valid())
self.assertIn('label_pattern', form.errors)

View File

@@ -4,6 +4,7 @@ import pytz
import yaml
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
from netaddr import EUI
@@ -76,6 +77,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Site(name='Site 3', slug='site-3', region=regions[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Site X',
'slug': 'site-x',
@@ -94,7 +97,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'contact_phone': '123-555-9999',
'contact_email': 'hank@stricklandpropane.com',
'comments': 'Test site',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -196,12 +199,15 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
RackReservation(rack=rack, user=user2, units=[7, 8, 9], description='Reservation 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'rack': rack.pk,
'units': "10,11,12",
'user': user3.pk,
'tenant': None,
'description': 'Rack reservation',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -249,6 +255,8 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Rack(name='Rack 3', site=sites[0]),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Rack X',
'facility_id': 'Facility X',
@@ -267,7 +275,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -321,7 +329,18 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
)
class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by absence of bulk import view for DeviceTypes
class DeviceTypeTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = DeviceType
@classmethod
@@ -339,6 +358,8 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
DeviceType(model='Device Type 3', slug='device-type-3', manufacturer=manufacturers[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'model': 'Device Type X',
@@ -348,7 +369,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'is_full_depth': True,
'subdevice_role': '', # CharField
'comments': 'Some comments',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -357,6 +378,7 @@ class DeviceTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'is_full_depth': False,
}
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_import_objects(self):
"""
Custom import test for YAML-based imports (versus CSV)
@@ -460,45 +482,45 @@ device-bays:
self.assertEqual(dt.comments, 'test comment')
# Verify all of the components were created
self.assertEqual(dt.consoleport_templates.count(), 3)
self.assertEqual(dt.consoleporttemplates.count(), 3)
cp1 = ConsolePortTemplate.objects.first()
self.assertEqual(cp1.name, 'Console Port 1')
self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
self.assertEqual(dt.consoleserverport_templates.count(), 3)
self.assertEqual(dt.consoleserverporttemplates.count(), 3)
csp1 = ConsoleServerPortTemplate.objects.first()
self.assertEqual(csp1.name, 'Console Server Port 1')
self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
self.assertEqual(dt.powerport_templates.count(), 3)
self.assertEqual(dt.powerporttemplates.count(), 3)
pp1 = PowerPortTemplate.objects.first()
self.assertEqual(pp1.name, 'Power Port 1')
self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
self.assertEqual(dt.poweroutlet_templates.count(), 3)
self.assertEqual(dt.poweroutlettemplates.count(), 3)
po1 = PowerOutletTemplate.objects.first()
self.assertEqual(po1.name, 'Power Outlet 1')
self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
self.assertEqual(po1.power_port, pp1)
self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
self.assertEqual(dt.interface_templates.count(), 3)
self.assertEqual(dt.interfacetemplates.count(), 3)
iface1 = InterfaceTemplate.objects.first()
self.assertEqual(iface1.name, 'Interface 1')
self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
self.assertTrue(iface1.mgmt_only)
self.assertEqual(dt.rearport_templates.count(), 3)
self.assertEqual(dt.rearporttemplates.count(), 3)
rp1 = RearPortTemplate.objects.first()
self.assertEqual(rp1.name, 'Rear Port 1')
self.assertEqual(dt.frontport_templates.count(), 3)
self.assertEqual(dt.frontporttemplates.count(), 3)
fp1 = FrontPortTemplate.objects.first()
self.assertEqual(fp1.name, 'Front Port 1')
self.assertEqual(fp1.rear_port, rp1)
self.assertEqual(fp1.rear_port_position, 1)
self.assertEqual(dt.device_bay_templates.count(), 3)
self.assertEqual(dt.devicebaytemplates.count(), 3)
db1 = DeviceBayTemplate.objects.first()
self.assertEqual(db1.name, 'Device Bay 1')
@@ -699,6 +721,8 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
cls.bulk_create_data = {
'device_type': devicetypes[1].pk,
'name_pattern': 'Interface Template [4-6]',
# Test that a label can be applied to each generated interface templates
'label_pattern': 'Interface Template Label [3-5]',
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'mgmt_only': True,
}
@@ -795,9 +819,6 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
model = DeviceBayTemplate
# Disable inapplicable views
test_bulk_edit_objects = None
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
@@ -823,6 +844,10 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
'name_pattern': 'Device Bay Template [4-6]',
}
cls.bulk_edit_data = {
'description': 'Foo bar',
}
class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = DeviceRole
@@ -930,6 +955,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Device(name='Device 3', site=sites[0], rack=racks[0], device_type=devicetypes[0], device_role=deviceroles[0], platform=platforms[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device_type': devicetypes[1].pk,
'device_role': deviceroles[1].pk,
@@ -950,7 +977,7 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'vc_position': None,
'vc_priority': None,
'comments': 'A new device',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
'local_context_data': None,
}
@@ -984,20 +1011,24 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsolePort(device=device, name='Console Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Console Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie',
'tags': sorted([t.pk for t in tags]),
}
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Console Port [4-6]',
# Test that a label can be applied to each generated console ports
'label_pattern': 'Serial[3-5]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console port',
'tags': 'Alpha,Bravo,Charlie',
'tags': sorted([t.pk for t in tags]),
}
cls.bulk_edit_data = {
@@ -1026,12 +1057,14 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
ConsoleServerPort(device=device, name='Console Server Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Console Server Port X',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1039,12 +1072,11 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name_pattern': 'Console Server Port [4-6]',
'type': ConsolePortTypeChoices.TYPE_RJ45,
'description': 'A console server port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'device': device.pk,
'type': ConsolePortTypeChoices.TYPE_RJ45,
'type': ConsolePortTypeChoices.TYPE_RJ11,
'description': 'New description',
}
@@ -1069,6 +1101,8 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerPort(device=device, name='Power Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Power Port X',
@@ -1076,7 +1110,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1086,7 +1120,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'maximum_draw': 100,
'allocated_draw': 50,
'description': 'A power port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1123,6 +1157,8 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
PowerOutlet(device=device, name='Power Outlet 3', power_port=powerports[0]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Power Outlet X',
@@ -1130,7 +1166,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1140,12 +1176,11 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'A power outlet',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'device': device.pk,
'type': PowerOutletTypeChoices.TYPE_IEC_C13,
'type': PowerOutletTypeChoices.TYPE_IEC_C15,
'power_port': powerports[1].pk,
'feed_leg': PowerOutletFeedLegChoices.FEED_LEG_B,
'description': 'New description',
@@ -1159,10 +1194,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
class InterfaceTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.DeviceComponentViewTestCase,
):
class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
model = Interface
@classmethod
@@ -1185,6 +1217,8 @@ class InterfaceTestCase(
)
VLAN.objects.bulk_create(vlans)
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'virtual_machine': None,
@@ -1199,7 +1233,7 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1215,13 +1249,12 @@ class InterfaceTestCase(
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,
'tagged_vlans': [v.pk for v in vlans[1:4]],
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'device': device.pk,
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
'enabled': False,
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
'enabled': True,
'lag': interfaces[3].pk,
'mac_address': EUI('01:02:03:04:05:06'),
'mtu': 2000,
@@ -1263,6 +1296,8 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
FrontPort(device=device, name='Front Port 3', rear_port=rearports[2]),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Front Port X',
@@ -1270,7 +1305,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'rear_port': rearports[3].pk,
'rear_port_position': 1,
'description': 'New description',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1281,7 +1316,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'{}:1'.format(rp.pk) for rp in rearports[3:6]
],
'description': 'New description',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1310,13 +1345,15 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
RearPort(device=device, name='Rear Port 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Rear Port X',
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1325,7 +1362,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
'type': PortTypeChoices.TYPE_8P8C,
'positions': 3,
'description': 'A rear port',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1357,18 +1394,20 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
DeviceBay(device=device, name='Device Bay 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'name': 'Device Bay X',
'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
'device': device.pk,
'name_pattern': 'Device Bay [4-6]',
'description': 'A device bay',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
@@ -1397,6 +1436,8 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
InventoryItem(device=device, name='Inventory Item 3'),
])
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'device': device.pk,
'manufacturer': manufacturer.pk,
@@ -1407,7 +1448,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'serial': '123ABC',
'asset_tag': 'ABC123',
'description': 'An inventory item',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_create_data = {
@@ -1419,12 +1460,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
'part_id': '123456',
'serial': '123ABC',
'description': 'An inventory item',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
}
cls.bulk_edit_data = {
'device': device.pk,
'manufacturer': manufacturer.pk,
'part_id': '123456',
'description': 'New description',
}
@@ -1437,12 +1476,20 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
)
class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by lack of common creation view for cables (termination A must be initialized)
class CableTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.EditObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkImportObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Cable
# TODO: Creation URL needs termination context
test_create_object = None
@classmethod
def setUpTestData(cls):
@@ -1479,6 +1526,8 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save()
Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save()
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
interface_ct = ContentType.objects.get_for_model(Interface)
cls.form_data = {
# Changing terminations not supported when editing an existing Cable
@@ -1492,6 +1541,7 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'color': 'c0c0c0',
'length': 100,
'length_unit': CableLengthUnitChoices.UNIT_FOOT,
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -1514,13 +1564,6 @@ class CableTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = VirtualChassis
# Disable inapplicable tests
test_import_objects = None
# TODO: Requires special form handling
test_create_object = None
test_edit_object = None
@classmethod
def setUpTestData(cls):
@@ -1533,33 +1576,56 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
name='Device Role', slug='device-role-1'
)
# Create 9 member Devices
device1 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 1', site=site
devices = (
Device(device_type=device_type, device_role=device_role, name='Device 1', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 2', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 3', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 4', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 5', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 6', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 7', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 8', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 9', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 10', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 11', site=site),
Device(device_type=device_type, device_role=device_role, name='Device 12', site=site),
)
device2 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 2', site=site
)
device3 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 3', site=site
)
device4 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 4', site=site
)
device5 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 5', site=site
)
device6 = Device.objects.create(
device_type=device_type, device_role=device_role, name='Device 6', site=site
Device.objects.bulk_create(devices)
# Create three VirtualChassis with three members each
vc1 = VirtualChassis.objects.create(name='VC1', master=devices[0], domain='domain-1')
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=vc1, vc_position=1)
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=vc1, vc_position=2)
Device.objects.filter(pk=devices[2].pk).update(virtual_chassis=vc1, vc_position=3)
vc2 = VirtualChassis.objects.create(name='VC2', master=devices[3], domain='domain-2')
Device.objects.filter(pk=devices[3].pk).update(virtual_chassis=vc2, vc_position=1)
Device.objects.filter(pk=devices[4].pk).update(virtual_chassis=vc2, vc_position=2)
Device.objects.filter(pk=devices[5].pk).update(virtual_chassis=vc2, vc_position=3)
vc3 = VirtualChassis.objects.create(name='VC3', master=devices[6], domain='domain-3')
Device.objects.filter(pk=devices[6].pk).update(virtual_chassis=vc3, vc_position=1)
Device.objects.filter(pk=devices[7].pk).update(virtual_chassis=vc3, vc_position=2)
Device.objects.filter(pk=devices[8].pk).update(virtual_chassis=vc3, vc_position=3)
cls.form_data = {
'name': 'VC4',
'domain': 'domain-4',
# Management form data for VC members
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 3,
'form-MIN_NUM_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
cls.csv_data = (
"name,domain,master",
"VC4,Domain 4,Device 10",
"VC5,Domain 5,Device 11",
"VC6,Domain 6,Device 12",
)
# Create three VirtualChassis with two members each
vc1 = VirtualChassis.objects.create(master=device1, domain='test-domain-1')
Device.objects.filter(pk=device2.pk).update(virtual_chassis=vc1, vc_position=2)
vc2 = VirtualChassis.objects.create(master=device3, domain='test-domain-2')
Device.objects.filter(pk=device4.pk).update(virtual_chassis=vc2, vc_position=2)
vc3 = VirtualChassis.objects.create(master=device5, domain='test-domain-3')
Device.objects.filter(pk=device6.pk).update(virtual_chassis=vc3, vc_position=2)
cls.bulk_edit_data = {
'domain': 'domain-x',
}
class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -1587,10 +1653,13 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'site': sites[1].pk,
'rack_group': rackgroups[1].pk,
'name': 'Power Panel X',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
@@ -1632,6 +1701,8 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
PowerFeed(name='Power Feed 3', power_panel=powerpanels[0], rack=racks[0]),
))
tags = cls.create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'name': 'Power Feed X',
'power_panel': powerpanels[1].pk,
@@ -1644,7 +1715,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'amperage': 100,
'max_utilization': 50,
'comments': 'New comments',
'tags': 'Alpha,Bravo,Charlie',
'tags': [t.pk for t in tags],
# Connection
'cable': None,

View File

@@ -1,12 +1,12 @@
from django.urls import path
from extras.views import ObjectChangeLogView, ImageAttachmentEditView
from ipam.views import ServiceCreateView
from ipam.views import ServiceEditView
from . import views
from .models import (
Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site,
VirtualChassis,
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface,
InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup,
RackReservation, RackRole, RearPort, Region, Site, VirtualChassis,
)
app_name = 'dcim'
@@ -14,15 +14,16 @@ urlpatterns = [
# Regions
path('regions/', views.RegionListView.as_view(), name='region_list'),
path('regions/add/', views.RegionCreateView.as_view(), name='region_add'),
path('regions/add/', views.RegionEditView.as_view(), name='region_add'),
path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
# Sites
path('sites/', views.SiteListView.as_view(), name='site_list'),
path('sites/add/', views.SiteCreateView.as_view(), name='site_add'),
path('sites/add/', views.SiteEditView.as_view(), name='site_add'),
path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'),
path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'),
path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'),
@@ -34,23 +35,25 @@ urlpatterns = [
# Rack groups
path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'),
path('rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'),
path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'),
path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'),
path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
path('rack-groups/<int:pk>/edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'),
path('rack-groups/<int:pk>/delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'),
path('rack-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}),
# Rack roles
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
path('rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'),
path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'),
path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
# Rack reservations
path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
path('rack-reservations/add/', views.RackReservationEditView.as_view(), name='rackreservation_add'),
path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
@@ -62,7 +65,7 @@ urlpatterns = [
# Racks
path('racks/', views.RackListView.as_view(), name='rack_list'),
path('rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'),
path('racks/add/', views.RackCreateView.as_view(), name='rack_add'),
path('racks/add/', views.RackEditView.as_view(), name='rack_add'),
path('racks/import/', views.RackBulkImportView.as_view(), name='rack_import'),
path('racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'),
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
@@ -74,15 +77,16 @@ urlpatterns = [
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'),
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),
path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
path('manufacturers/<slug:slug>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
path('manufacturers/<slug:slug>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
path('manufacturers/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
# Device types
path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'),
path('device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'),
path('device-types/add/', views.DeviceTypeEditView.as_view(), name='devicetype_add'),
path('device-types/import/', views.DeviceTypeImportView.as_view(), name='devicetype_import'),
path('device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'),
path('device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'),
@@ -94,6 +98,7 @@ urlpatterns = [
# Console port templates
path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
path('console-port-templates/rename/', views.ConsolePortTemplateBulkRenameView.as_view(), name='consoleporttemplate_bulk_rename'),
path('console-port-templates/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='consoleporttemplate_bulk_delete'),
path('console-port-templates/<int:pk>/edit/', views.ConsolePortTemplateEditView.as_view(), name='consoleporttemplate_edit'),
path('console-port-templates/<int:pk>/delete/', views.ConsolePortTemplateDeleteView.as_view(), name='consoleporttemplate_delete'),
@@ -101,6 +106,7 @@ urlpatterns = [
# Console server port templates
path('console-server-port-templates/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='consoleserverporttemplate_add'),
path('console-server-port-templates/edit/', views.ConsoleServerPortTemplateBulkEditView.as_view(), name='consoleserverporttemplate_bulk_edit'),
path('console-server-port-templates/rename/', views.ConsoleServerPortTemplateBulkRenameView.as_view(), name='consoleserverporttemplate_bulk_rename'),
path('console-server-port-templates/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='consoleserverporttemplate_bulk_delete'),
path('console-server-port-templates/<int:pk>/edit/', views.ConsoleServerPortTemplateEditView.as_view(), name='consoleserverporttemplate_edit'),
path('console-server-port-templates/<int:pk>/delete/', views.ConsoleServerPortTemplateDeleteView.as_view(), name='consoleserverporttemplate_delete'),
@@ -108,6 +114,7 @@ urlpatterns = [
# Power port templates
path('power-port-templates/add/', views.PowerPortTemplateCreateView.as_view(), name='powerporttemplate_add'),
path('power-port-templates/edit/', views.PowerPortTemplateBulkEditView.as_view(), name='powerporttemplate_bulk_edit'),
path('power-port-templates/rename/', views.PowerPortTemplateBulkRenameView.as_view(), name='powerporttemplate_bulk_rename'),
path('power-port-templates/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='powerporttemplate_bulk_delete'),
path('power-port-templates/<int:pk>/edit/', views.PowerPortTemplateEditView.as_view(), name='powerporttemplate_edit'),
path('power-port-templates/<int:pk>/delete/', views.PowerPortTemplateDeleteView.as_view(), name='powerporttemplate_delete'),
@@ -115,6 +122,7 @@ urlpatterns = [
# Power outlet templates
path('power-outlet-templates/add/', views.PowerOutletTemplateCreateView.as_view(), name='poweroutlettemplate_add'),
path('power-outlet-templates/edit/', views.PowerOutletTemplateBulkEditView.as_view(), name='poweroutlettemplate_bulk_edit'),
path('power-outlet-templates/rename/', views.PowerOutletTemplateBulkRenameView.as_view(), name='poweroutlettemplate_bulk_rename'),
path('power-outlet-templates/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='poweroutlettemplate_bulk_delete'),
path('power-outlet-templates/<int:pk>/edit/', views.PowerOutletTemplateEditView.as_view(), name='poweroutlettemplate_edit'),
path('power-outlet-templates/<int:pk>/delete/', views.PowerOutletTemplateDeleteView.as_view(), name='poweroutlettemplate_delete'),
@@ -122,6 +130,7 @@ urlpatterns = [
# Interface templates
path('interface-templates/add/', views.InterfaceTemplateCreateView.as_view(), name='interfacetemplate_add'),
path('interface-templates/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='interfacetemplate_bulk_edit'),
path('interface-templates/rename/', views.InterfaceTemplateBulkRenameView.as_view(), name='interfacetemplate_bulk_rename'),
path('interface-templates/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='interfacetemplate_bulk_delete'),
path('interface-templates/<int:pk>/edit/', views.InterfaceTemplateEditView.as_view(), name='interfacetemplate_edit'),
path('interface-templates/<int:pk>/delete/', views.InterfaceTemplateDeleteView.as_view(), name='interfacetemplate_delete'),
@@ -129,6 +138,7 @@ urlpatterns = [
# Front port templates
path('front-port-templates/add/', views.FrontPortTemplateCreateView.as_view(), name='frontporttemplate_add'),
path('front-port-templates/edit/', views.FrontPortTemplateBulkEditView.as_view(), name='frontporttemplate_bulk_edit'),
path('front-port-templates/rename/', views.FrontPortTemplateBulkRenameView.as_view(), name='frontporttemplate_bulk_rename'),
path('front-port-templates/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='frontporttemplate_bulk_delete'),
path('front-port-templates/<int:pk>/edit/', views.FrontPortTemplateEditView.as_view(), name='frontporttemplate_edit'),
path('front-port-templates/<int:pk>/delete/', views.FrontPortTemplateDeleteView.as_view(), name='frontporttemplate_delete'),
@@ -136,36 +146,40 @@ urlpatterns = [
# Rear port templates
path('rear-port-templates/add/', views.RearPortTemplateCreateView.as_view(), name='rearporttemplate_add'),
path('rear-port-templates/edit/', views.RearPortTemplateBulkEditView.as_view(), name='rearporttemplate_bulk_edit'),
path('rear-port-templates/rename/', views.RearPortTemplateBulkRenameView.as_view(), name='rearporttemplate_bulk_rename'),
path('rear-port-templates/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='rearporttemplate_bulk_delete'),
path('rear-port-templates/<int:pk>/edit/', views.RearPortTemplateEditView.as_view(), name='rearporttemplate_edit'),
path('rear-port-templates/<int:pk>/delete/', views.RearPortTemplateDeleteView.as_view(), name='rearporttemplate_delete'),
# Device bay templates
path('device-bay-templates/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicebaytemplate_add'),
# path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/edit/', views.DeviceBayTemplateBulkEditView.as_view(), name='devicebaytemplate_bulk_edit'),
path('device-bay-templates/rename/', views.DeviceBayTemplateBulkRenameView.as_view(), name='devicebaytemplate_bulk_rename'),
path('device-bay-templates/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicebaytemplate_bulk_delete'),
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
# Device roles
path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
path('device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'),
path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
path('device-roles/<slug:slug>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
path('device-roles/<slug:slug>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
path('device-roles/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
# Platforms
path('platforms/', views.PlatformListView.as_view(), name='platform_list'),
path('platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'),
path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'),
path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
path('platforms/<slug:slug>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
path('platforms/<slug:slug>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
path('platforms/<slug:slug>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),
# Devices
path('devices/', views.DeviceListView.as_view(), name='device_list'),
path('devices/add/', views.DeviceCreateView.as_view(), name='device_add'),
path('devices/add/', views.DeviceEditView.as_view(), name='device_add'),
path('devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'),
path('devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'),
path('devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'),
@@ -179,7 +193,7 @@ urlpatterns = [
path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
# Console ports
@@ -187,12 +201,15 @@ urlpatterns = [
path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
path('console-ports/import/', views.ConsolePortBulkImportView.as_view(), name='consoleport_import'),
path('console-ports/edit/', views.ConsolePortBulkEditView.as_view(), name='consoleport_bulk_edit'),
# TODO: Bulk rename, disconnect views for ConsolePorts
path('console-ports/rename/', views.ConsolePortBulkRenameView.as_view(), name='consoleport_bulk_rename'),
path('console-ports/disconnect/', views.ConsolePortBulkDisconnectView.as_view(), name='consoleport_bulk_disconnect'),
path('console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('console-ports/<int:pk>/', views.ConsolePortView.as_view(), name='consoleport'),
path('console-ports/<int:pk>/edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'),
path('console-ports/<int:pk>/delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'),
path('console-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}),
path('console-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}),
path('console-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}),
path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'),
# Console server ports
@@ -203,10 +220,12 @@ urlpatterns = [
path('console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'),
path('console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'),
path('console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('console-server-ports/<int:pk>/', views.ConsoleServerPortView.as_view(), name='consoleserverport'),
path('console-server-ports/<int:pk>/edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'),
path('console-server-ports/<int:pk>/delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'),
path('console-server-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}),
path('console-server-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}),
path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'),
# Power ports
@@ -214,12 +233,15 @@ urlpatterns = [
path('power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'),
path('power-ports/import/', views.PowerPortBulkImportView.as_view(), name='powerport_import'),
path('power-ports/edit/', views.PowerPortBulkEditView.as_view(), name='powerport_bulk_edit'),
# TODO: Bulk rename, disconnect views for PowerPorts
path('power-ports/rename/', views.PowerPortBulkRenameView.as_view(), name='powerport_bulk_rename'),
path('power-ports/disconnect/', views.PowerPortBulkDisconnectView.as_view(), name='powerport_bulk_disconnect'),
path('power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('power-ports/<int:pk>/', views.PowerPortView.as_view(), name='powerport'),
path('power-ports/<int:pk>/edit/', views.PowerPortEditView.as_view(), name='powerport_edit'),
path('power-ports/<int:pk>/delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'),
path('power-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}),
path('power-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}),
path('power-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}),
path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'),
# Power outlets
@@ -230,10 +252,12 @@ urlpatterns = [
path('power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'),
path('power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'),
path('power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('power-outlets/<int:pk>/', views.PowerOutletView.as_view(), name='poweroutlet'),
path('power-outlets/<int:pk>/edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'),
path('power-outlets/<int:pk>/delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'),
path('power-outlets/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:pk>/trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}),
path('power-outlets/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}),
path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'),
# Interfaces
@@ -244,12 +268,12 @@ urlpatterns = [
path('interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'),
path('interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'),
path('interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('interfaces/<int:pk>/', views.InterfaceView.as_view(), name='interface'),
path('interfaces/<int:pk>/edit/', views.InterfaceEditView.as_view(), name='interface_edit'),
path('interfaces/<int:pk>/delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'),
path('interfaces/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}),
path('interfaces/<int:pk>/trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}),
path('interfaces/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}),
path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'),
# Front ports
@@ -260,10 +284,12 @@ urlpatterns = [
path('front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'),
path('front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'),
path('front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
path('front-ports/<int:pk>/', views.FrontPortView.as_view(), name='frontport'),
path('front-ports/<int:pk>/edit/', views.FrontPortEditView.as_view(), name='frontport_edit'),
path('front-ports/<int:pk>/delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'),
path('front-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}),
path('front-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}),
path('front-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}),
# path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'),
# Rear ports
@@ -274,10 +300,12 @@ urlpatterns = [
path('rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'),
path('rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'),
path('rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('rear-ports/<int:pk>/', views.RearPortView.as_view(), name='rearport'),
path('rear-ports/<int:pk>/edit/', views.RearPortEditView.as_view(), name='rearport_edit'),
path('rear-ports/<int:pk>/delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'),
path('rear-ports/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}),
path('rear-ports/<int:pk>/trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}),
path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
# Device bays
@@ -287,8 +315,10 @@ urlpatterns = [
path('device-bays/edit/', views.DeviceBayBulkEditView.as_view(), name='devicebay_bulk_edit'),
path('device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'),
path('device-bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'),
path('device-bays/<int:pk>/', views.DeviceBayView.as_view(), name='devicebay'),
path('device-bays/<int:pk>/edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'),
path('device-bays/<int:pk>/delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'),
path('device-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicebay_changelog', kwargs={'model': DeviceBay}),
path('device-bays/<int:pk>/populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'),
path('device-bays/<int:pk>/depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'),
path('devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'),
@@ -298,10 +328,13 @@ urlpatterns = [
path('inventory-items/add/', views.InventoryItemCreateView.as_view(), name='inventoryitem_add'),
path('inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'),
path('inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'),
# TODO: Bulk rename view for InventoryItems
path('inventory-items/rename/', views.InventoryItemBulkRenameView.as_view(), name='inventoryitem_bulk_rename'),
path('inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'),
path('inventory-items/<int:pk>/', views.InventoryItemView.as_view(), name='inventoryitem'),
path('inventory-items/<int:pk>/edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'),
path('inventory-items/<int:pk>/delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'),
path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
# Cables
path('cables/', views.CableListView.as_view(), name='cable_list'),
@@ -321,6 +354,7 @@ urlpatterns = [
# Virtual chassis
path('virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'),
path('virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'),
path('virtual-chassis/import/', views.VirtualChassisBulkImportView.as_view(), name='virtualchassis_import'),
path('virtual-chassis/edit/', views.VirtualChassisBulkEditView.as_view(), name='virtualchassis_bulk_edit'),
path('virtual-chassis/delete/', views.VirtualChassisBulkDeleteView.as_view(), name='virtualchassis_bulk_delete'),
path('virtual-chassis/<int:pk>/', views.VirtualChassisView.as_view(), name='virtualchassis'),
@@ -332,7 +366,7 @@ urlpatterns = [
# Power panels
path('power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'),
path('power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'),
path('power-panels/add/', views.PowerPanelEditView.as_view(), name='powerpanel_add'),
path('power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'),
path('power-panels/edit/', views.PowerPanelBulkEditView.as_view(), name='powerpanel_bulk_edit'),
path('power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'),
@@ -343,7 +377,7 @@ urlpatterns = [
# Power feeds
path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'),
path('power-feeds/add/', views.PowerFeedCreateView.as_view(), name='powerfeed_add'),
path('power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'),
path('power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'),
path('power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'),
path('power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'),

File diff suppressed because it is too large Load Diff

View File

@@ -2,7 +2,7 @@ from django import forms
from django.contrib import admin
from utilities.forms import LaxURLField
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, Webhook
from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, JobResult, Webhook
from .reports import get_report
@@ -228,27 +228,18 @@ class ExportTemplateAdmin(admin.ModelAdmin):
# Reports
#
@admin.register(ReportResult)
class ReportResultAdmin(admin.ModelAdmin):
@admin.register(JobResult)
class JobResultAdmin(admin.ModelAdmin):
list_display = [
'report', 'active', 'created', 'user', 'passing',
'obj_type', 'name', 'created', 'completed', 'user', 'status',
]
fields = [
'report', 'user', 'passing', 'data',
'obj_type', 'name', 'created', 'completed', 'user', 'status', 'data', 'job_id'
]
list_filter = [
'failed',
'status',
]
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

View File

@@ -1,13 +1,14 @@
from rest_framework import serializers
from extras import models
from utilities.api import WritableNestedSerializer
from extras import choices, models
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import ChoiceField, WritableNestedSerializer
__all__ = [
'NestedConfigContextSerializer',
'NestedExportTemplateSerializer',
'NestedGraphSerializer',
'NestedReportResultSerializer',
'NestedJobResultSerializer',
'NestedTagSerializer',
]
@@ -38,20 +39,19 @@ class NestedGraphSerializer(WritableNestedSerializer):
class NestedTagSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = models.Tag
fields = ['id', 'url', 'name', 'slug', 'color', 'tagged_items']
fields = ['id', 'url', 'name', 'slug', 'color']
class NestedReportResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='report',
lookup_url_kwarg='pk'
class NestedJobResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
status = ChoiceField(choices=choices.JobResultStatusChoices)
user = NestedUserSerializer(
read_only=True
)
class Meta:
model = models.ReportResult
fields = ['url', 'created', 'user', 'failed']
model = models.JobResult
fields = ['url', 'created', 'completed', 'user', 'status']

View File

@@ -9,9 +9,8 @@ from dcim.api.nested_serializers import (
)
from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site
from extras.choices import *
from extras.constants import *
from extras.models import (
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
)
from extras.utils import FeatureQuery
from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
@@ -31,13 +30,14 @@ from .nested_serializers import *
#
class GraphSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:graph-detail')
type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('graphs').get_query()),
)
class Meta:
model = Graph
fields = ['id', 'type', 'weight', 'name', 'template_language', 'source', 'link']
fields = ['id', 'url', 'type', 'weight', 'name', 'template_language', 'source', 'link']
class RenderedGraphSerializer(serializers.ModelSerializer):
@@ -67,6 +67,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
#
class ExportTemplateSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:exporttemplate-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.filter(FeatureQuery('export_templates').get_query()),
)
@@ -78,7 +79,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
class Meta:
model = ExportTemplate
fields = [
'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'id', 'url', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'file_extension',
]
@@ -88,11 +89,34 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
#
class TagSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail')
tagged_items = serializers.IntegerField(read_only=True)
class Meta:
model = Tag
fields = ['id', 'name', 'slug', 'color', 'description', 'tagged_items']
fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items']
class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data):
tags = validated_data.pop('tags', [])
instance = super().create(validated_data)
return self._save_tags(instance, tags)
def update(self, instance, validated_data):
tags = validated_data.pop('tags', [])
instance = super().update(instance, validated_data)
return self._save_tags(instance, tags)
def _save_tags(self, instance, tags):
if tags:
instance.tags.set(*[t.name for t in tags])
return instance
#
@@ -100,6 +124,7 @@ class TagSerializer(ValidatedModelSerializer):
#
class ImageAttachmentSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:imageattachment-detail')
content_type = ContentTypeField(
queryset=ContentType.objects.all()
)
@@ -108,7 +133,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
class Meta:
model = ImageAttachment
fields = [
'id', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', 'created',
'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width',
'created',
]
def validate(self, data):
@@ -147,6 +173,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
#
class ConfigContextSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:configcontext-detail')
regions = SerializedPKRelatedField(
queryset=Region.objects.all(),
serializer=NestedRegionSerializer,
@@ -205,8 +232,29 @@ class ConfigContextSerializer(ValidatedModelSerializer):
class Meta:
model = ConfigContext
fields = [
'id', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data',
'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms',
'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated',
]
#
# Job Results
#
class JobResultSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail')
user = NestedUserSerializer(
read_only=True
)
status = ChoiceField(choices=JobResultStatusChoices, read_only=True)
obj_type = ContentTypeField(
read_only=True
)
class Meta:
model = JobResult
fields = [
'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id',
]
@@ -214,23 +262,22 @@ class ConfigContextSerializer(ValidatedModelSerializer):
# Reports
#
class ReportResultSerializer(serializers.ModelSerializer):
class Meta:
model = ReportResult
fields = ['created', 'user', 'failed', 'data']
class ReportSerializer(serializers.Serializer):
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:report-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(max_length=255)
description = serializers.CharField(max_length=255, required=False)
test_methods = serializers.ListField(child=serializers.CharField(max_length=255))
result = NestedReportResultSerializer()
result = NestedJobResultSerializer()
class ReportDetailSerializer(ReportSerializer):
result = ReportResultSerializer()
result = JobResultSerializer()
#
@@ -238,19 +285,17 @@ class ReportDetailSerializer(ReportSerializer):
#
class ScriptSerializer(serializers.Serializer):
id = serializers.SerializerMethodField(read_only=True)
name = serializers.SerializerMethodField(read_only=True)
description = serializers.SerializerMethodField(read_only=True)
url = serializers.HyperlinkedIdentityField(
view_name='extras-api:script-detail',
lookup_field='full_name',
lookup_url_kwarg='pk'
)
id = serializers.CharField(read_only=True, source="full_name")
module = serializers.CharField(max_length=255)
name = serializers.CharField(read_only=True)
description = serializers.CharField(read_only=True)
vars = serializers.SerializerMethodField(read_only=True)
def get_id(self, instance):
return '{}.{}'.format(instance.__module__, instance.__name__)
def get_name(self, instance):
return getattr(instance.Meta, 'name', instance.__name__)
def get_description(self, instance):
return getattr(instance.Meta, 'description', '')
result = NestedJobResultSerializer()
def get_vars(self, instance):
return {
@@ -258,6 +303,10 @@ class ScriptSerializer(serializers.Serializer):
}
class ScriptDetailSerializer(ScriptSerializer):
result = JobResultSerializer()
class ScriptInputSerializer(serializers.Serializer):
data = serializers.JSONField()
commit = serializers.BooleanField()
@@ -268,7 +317,7 @@ class ScriptLogMessageSerializer(serializers.Serializer):
message = serializers.SerializerMethodField(read_only=True)
def get_status(self, instance):
return LOG_LEVEL_CODES.get(instance[0])
return instance[0]
def get_message(self, instance):
return instance[1]
@@ -284,6 +333,7 @@ class ScriptOutputSerializer(serializers.Serializer):
#
class ObjectChangeSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail')
user = NestedUserSerializer(
read_only=True
)
@@ -301,8 +351,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
class Meta:
model = ObjectChange
fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'changed_object', 'object_data',
'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type',
'changed_object_id', 'changed_object', 'object_data',
]
@swagger_serializer_method(serializer_or_field=serializers.DictField)

View File

@@ -41,5 +41,8 @@ router.register('scripts', views.ScriptViewSet, basename='script')
# Change logging
router.register('object-changes', views.ObjectChangeViewSet)
# Job Results
router.register('job-results', views.JobResultViewSet)
app_name = 'extras-api'
urlpatterns = router.urls

View File

@@ -3,20 +3,25 @@ from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count
from django.http import Http404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet
from rq import Worker
from extras import filters
from extras.choices import JobResultStatusChoices
from extras.models import (
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, JobResult, Tag,
)
from extras.reports import get_report, get_reports
from extras.reports import get_report, get_reports, run_report
from extras.scripts import get_script, get_scripts, run_script
from utilities.api import IsAuthenticatedOrLoginNotRequired, ModelViewSet
from utilities.exceptions import RQWorkerNotRunningException
from utilities.metadata import ContentTypeMetadata
from utilities.utils import copy_safe_request
from . import serializers
@@ -112,8 +117,8 @@ class ExportTemplateViewSet(ModelViewSet):
class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(
tagged_items=Count('extras_taggeditem_items', distinct=True)
)
tagged_items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilterSet
@@ -169,13 +174,21 @@ class ReportViewSet(ViewSet):
Compile all reports and their related results (if any). Result data is deferred in the list view.
"""
report_list = []
report_content_type = ContentType.objects.get(app_label='extras', model='report')
results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data')
}
# Iterate through all available Reports.
for module_name, reports in get_reports():
for report in reports:
# Attach the relevant ReportResult (if any) to each Report.
report.result = ReportResult.objects.filter(report=report.full_name).defer('data').first()
# Attach the relevant JobResult (if any) to each Report.
report.result = results.get(report.full_name, None)
report_list.append(report)
serializer = serializers.ReportSerializer(report_list, many=True, context={
@@ -189,29 +202,46 @@ class ReportViewSet(ViewSet):
Retrieve a single Report identified as "<module>.<report>".
"""
# Retrieve the Report and ReportResult, if any.
# Retrieve the Report and JobResult, if any.
report = self._retrieve_report(pk)
report.result = ReportResult.objects.filter(report=report.full_name).first()
report_content_type = ContentType.objects.get(app_label='extras', model='report')
report.result = JobResult.objects.filter(
obj_type=report_content_type,
name=report.full_name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).first()
serializer = serializers.ReportDetailSerializer(report)
serializer = serializers.ReportDetailSerializer(report, context={
'request': request
})
return Response(serializer.data)
@action(detail=True, methods=['post'])
def run(self, request, pk):
"""
Run a Report and create a new ReportResult, overwriting any previous result for the Report.
Run a Report identified as "<module>.<script>" and return the pending JobResult as the result
"""
# Check that the user has permission to run reports.
if not request.user.has_perm('extras.add_reportresult'):
if not request.user.has_perm('extras.run_script'):
raise PermissionDenied("This user does not have permission to run reports.")
# Retrieve and run the Report. This will create a new ReportResult.
report = self._retrieve_report(pk)
report.run()
# Check that at least one RQ worker is running
if not Worker.count(get_connection('default')):
raise RQWorkerNotRunningException()
serializer = serializers.ReportDetailSerializer(report)
# Retrieve and run the Report. This will create a new JobResult.
report = self._retrieve_report(pk)
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
request.user
)
report.result = job_result
serializer = serializers.ReportDetailSerializer(report, context={'request': request})
return Response(serializer.data)
@@ -235,34 +265,68 @@ class ScriptViewSet(ViewSet):
def list(self, request):
script_content_type = ContentType.objects.get(app_label='extras', model='script')
results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data').order_by('created')
}
flat_list = []
for script_list in get_scripts().values():
flat_list.extend(script_list.values())
# Attach JobResult objects to each script (if any)
for script in flat_list:
script.result = results.get(script.full_name, None)
serializer = serializers.ScriptSerializer(flat_list, many=True, context={'request': request})
return Response(serializer.data)
def retrieve(self, request, pk):
script = self._get_script(pk)
serializer = serializers.ScriptSerializer(script, context={'request': request})
script_content_type = ContentType.objects.get(app_label='extras', model='script')
script.result = JobResult.objects.filter(
obj_type=script_content_type,
name=script.full_name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).first()
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
def post(self, request, pk):
"""
Run a Script identified as "<module>.<script>".
Run a Script identified as "<module>.<script>" and return the pending JobResult as the result
"""
script = self._get_script(pk)()
input_serializer = serializers.ScriptInputSerializer(data=request.data)
# Check that at least one RQ worker is running
if not Worker.count(get_connection('default')):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
data = input_serializer.data['data']
commit = input_serializer.data['commit']
script.output, execution_time = run_script(script, data, request, commit)
output_serializer = serializers.ScriptOutputSerializer(script)
return Response(output_serializer.data)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job(
run_script,
script.full_name,
script_content_type,
request.user,
data=data,
request=copy_safe_request(request),
commit=commit
)
script.result = job_result
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
return Response(serializer.data)
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@@ -279,3 +343,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
queryset = ObjectChange.objects.prefetch_related('user')
serializer_class = serializers.ObjectChangeSerializer
filterset_class = filters.ObjectChangeFilterSet
#
# Job Results
#
class JobResultViewSet(ReadOnlyModelViewSet):
"""
Retrieve a list of job results
"""
queryset = JobResult.objects.prefetch_related('user')
serializer_class = serializers.JobResultSerializer
filterset_class = filters.JobResultFilterSet

View File

@@ -23,15 +23,6 @@ class CustomFieldTypeChoices(ChoiceSet):
(TYPE_SELECT, 'Selection'),
)
LEGACY_MAP = {
TYPE_TEXT: 100,
TYPE_INTEGER: 200,
TYPE_BOOLEAN: 300,
TYPE_DATE: 400,
TYPE_URL: 500,
TYPE_SELECT: 600,
}
class CustomFieldFilterLogicChoices(ChoiceSet):
@@ -45,12 +36,6 @@ class CustomFieldFilterLogicChoices(ChoiceSet):
(FILTER_EXACT, 'Exact'),
)
LEGACY_MAP = {
FILTER_DISABLED: 0,
FILTER_LOOSE: 1,
FILTER_EXACT: 2,
}
#
# CustomLinks
@@ -93,12 +78,6 @@ class ObjectChangeActionChoices(ChoiceSet):
(ACTION_DELETE, 'Deleted'),
)
LEGACY_MAP = {
ACTION_CREATE: 1,
ACTION_UPDATE: 2,
ACTION_DELETE: 3,
}
#
# ExportTemplates
@@ -114,10 +93,61 @@ class TemplateLanguageChoices(ChoiceSet):
(LANGUAGE_JINJA2, 'Jinja2'),
)
LEGACY_MAP = {
LANGUAGE_DJANGO: 10,
LANGUAGE_JINJA2: 20,
}
#
# Log Levels for Reports and Scripts
#
class LogLevelChoices(ChoiceSet):
LOG_DEFAULT = 'default'
LOG_SUCCESS = 'success'
LOG_INFO = 'info'
LOG_WARNING = 'warning'
LOG_FAILURE = 'failure'
CHOICES = (
(LOG_DEFAULT, 'Default'),
(LOG_SUCCESS, 'Success'),
(LOG_INFO, 'Info'),
(LOG_WARNING, 'Warning'),
(LOG_FAILURE, 'Failure'),
)
CLASS_MAP = (
(LOG_DEFAULT, 'default'),
(LOG_SUCCESS, 'success'),
(LOG_INFO, 'info'),
(LOG_WARNING, 'warning'),
(LOG_FAILURE, 'danger'),
)
#
# Job results
#
class JobResultStatusChoices(ChoiceSet):
STATUS_PENDING = 'pending'
STATUS_RUNNING = 'running'
STATUS_COMPLETED = 'completed'
STATUS_ERRORED = 'errored'
STATUS_FAILED = 'failed'
CHOICES = (
(STATUS_PENDING, 'Pending'),
(STATUS_RUNNING, 'Running'),
(STATUS_COMPLETED, 'Completed'),
(STATUS_ERRORED, 'Errored'),
(STATUS_FAILED, 'Failed'),
)
TERMINAL_STATE_CHOICES = (
STATUS_COMPLETED,
STATUS_ERRORED,
STATUS_FAILED,
)
#

View File

@@ -1,17 +1,3 @@
# Report logging levels
LOG_DEFAULT = 0
LOG_SUCCESS = 10
LOG_INFO = 20
LOG_WARNING = 30
LOG_FAILURE = 40
LOG_LEVEL_CODES = {
LOG_DEFAULT: 'default',
LOG_SUCCESS: 'success',
LOG_INFO: 'info',
LOG_WARNING: 'warning',
LOG_FAILURE: 'failure',
}
# Webhook content types
HTTP_CONTENT_TYPE_JSON = 'application/json'
@@ -19,7 +5,8 @@ HTTP_CONTENT_TYPE_JSON = 'application/json'
EXTRAS_FEATURES = [
'custom_fields',
'custom_links',
'graphs',
'export_templates',
'graphs',
'job_results',
'webhooks'
]

View File

@@ -7,7 +7,7 @@ from tenancy.models import Tenant, TenantGroup
from utilities.filters import BaseFilterSet
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag
from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, JobResult, Tag
__all__ = (
@@ -287,3 +287,33 @@ class CreatedUpdatedFilterSet(django_filters.FilterSet):
field_name='last_updated',
lookup_expr='lte'
)
#
# Job Results
#
class JobResultFilterSet(BaseFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
created = django_filters.DateTimeFilter()
completed = django_filters.DateTimeFilter()
status = django_filters.MultipleChoiceFilter(
choices=JobResultStatusChoices,
null_value=None
)
class Meta:
model = JobResult
fields = [
'id', 'created', 'completed', 'status', 'user', 'obj_type', 'name'
]
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(user__username__icontains=value)
)

View File

@@ -1,15 +1,14 @@
from django import forms
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField as TagField_
from django.utils.safestring import mark_safe
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,
ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField,
StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES,
StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@@ -142,15 +141,6 @@ class CustomFieldFilterForm(forms.Form):
# Tags
#
class TagField(TagField_):
def widget_attrs(self, widget):
# Apply the "tagfield" CSS class to trigger the special API-based selection widget for tags
return {
'class': 'tagfield'
}
class TagForm(BootstrapMixin, forms.ModelForm):
slug = SlugField()
@@ -161,6 +151,17 @@ class TagForm(BootstrapMixin, forms.ModelForm):
]
class TagCSVForm(CSVModelForm):
slug = SlugField()
class Meta:
model = Tag
fields = Tag.csv_headers
help_texts = {
'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
}
class AddRemoveTagsForm(forms.Form):
def __init__(self, *args, **kwargs):
@@ -209,10 +210,9 @@ class TagBulkEditForm(BootstrapMixin, BulkEditForm):
#
class ConfigContextForm(BootstrapMixin, forms.ModelForm):
regions = TreeNodeMultipleChoiceField(
regions = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
widget=StaticSelect2Multiple()
required=False
)
sites = DynamicModelMultipleChoiceField(
queryset=Site.objects.all(),
@@ -410,11 +410,13 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False,
widget=StaticSelect2()
)
# TODO: Convert to DynamicModelMultipleChoiceField once we have an API endpoint for users
user = forms.ModelChoiceField(
queryset=User.objects.order_by('username'),
user = DynamicModelMultipleChoiceField(
queryset=User.objects.all(),
required=False,
widget=StaticSelect2()
widget=APISelectMultiple(
api_url='/api/users/users/',
display_field='username'
)
)
changed_object_type = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'),

View File

@@ -16,7 +16,6 @@ class Migration(migrations.Migration):
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'permissions': (('run_script', 'Can run script'),),
'managed': False,
},
),

View File

@@ -0,0 +1,22 @@
# Generated by Django 3.0.7 on 2020-06-23 02:28
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0042_customfield_manager'),
]
operations = [
migrations.CreateModel(
name='Report',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
],
options={
'managed': False,
},
)
]

View File

@@ -0,0 +1,75 @@
import uuid
import django.contrib.postgres.fields.jsonb
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import extras.utils
from extras.choices import JobResultStatusChoices
def convert_job_results(apps, schema_editor):
"""
Convert ReportResult objects to JobResult objects
"""
Report = apps.get_model('extras', 'Report')
ReportResult = apps.get_model('extras', 'ReportResult')
JobResult = apps.get_model('extras', 'JobResult')
ContentType = apps.get_model('contenttypes', 'ContentType')
report_content_type = ContentType.objects.get_for_model(Report)
job_results = []
for report_result in ReportResult.objects.all():
if report_result.failed:
status = JobResultStatusChoices.STATUS_FAILED
else:
status = JobResultStatusChoices.STATUS_COMPLETED
job_results.append(
JobResult(
name=report_result.report,
obj_type=report_content_type,
created=report_result.created,
completed=report_result.created,
user=report_result.user,
status=status,
data=report_result.data,
job_id=uuid.uuid4()
)
)
JobResult.objects.bulk_create(job_results)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('extras', '0043_report'),
]
operations = [
migrations.CreateModel(
name='JobResult',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=255)),
('created', models.DateTimeField(auto_now_add=True)),
('completed', models.DateTimeField(blank=True, null=True)),
('status', models.CharField(default='pending', max_length=30)),
('data', django.contrib.postgres.fields.jsonb.JSONField(blank=True, null=True)),
('job_id', models.UUIDField(unique=True)),
('obj_type', models.ForeignKey(limit_choices_to=extras.utils.FeatureQuery('job_results'), on_delete=django.db.models.deletion.CASCADE, related_name='job_results', to='contenttypes.ContentType')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['obj_type', 'name', '-created'],
},
),
migrations.RunPython(
code=convert_job_results
),
migrations.DeleteModel(
name='ReportResult'
)
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 3.0.6 on 2020-07-09 20:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0044_jobresult'),
]
operations = [
migrations.AddField(
model_name='configcontext',
name='created',
field=models.DateField(auto_now_add=True, null=True),
),
migrations.AddField(
model_name='configcontext',
name='last_updated',
field=models.DateTimeField(auto_now=True, null=True),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 3.1b1 on 2020-07-16 16:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0045_configcontext_changelog'),
]
operations = [
migrations.AlterField(
model_name='configcontext',
name='data',
field=models.JSONField(),
),
migrations.AlterField(
model_name='jobresult',
name='data',
field=models.JSONField(blank=True, null=True),
),
migrations.AlterField(
model_name='objectchange',
name='object_data',
field=models.JSONField(editable=False),
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 3.1rc1 on 2020-07-23 18:22
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('extras', '0046_update_jsonfield'),
]
operations = [
migrations.AlterModelOptions(
name='tag',
options={'ordering': ['name']},
),
]

View File

@@ -1,11 +1,13 @@
from .change_logging import ChangeLoggedModel, ObjectChange
from .customfields import CustomField, CustomFieldChoice, CustomFieldModel, CustomFieldValue
from .models import (
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult,
Script, Webhook,
ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, Graph, ImageAttachment, JobResult, Report, Script,
Webhook,
)
from .tags import Tag, TaggedItem
__all__ = (
'ChangeLoggedModel',
'ConfigContext',
'ConfigContextModel',
'CustomField',
@@ -16,8 +18,9 @@ __all__ = (
'ExportTemplate',
'Graph',
'ImageAttachment',
'JobResult',
'ObjectChange',
'ReportResult',
'Report',
'Script',
'Tag',
'TaggedItem',

View File

@@ -0,0 +1,154 @@
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.urls import reverse
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object
from extras.choices import *
#
# Change logging
#
class ChangeLoggedModel(models.Model):
"""
An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be
null to facilitate adding these fields to existing instances via a database migration.
"""
created = models.DateField(
auto_now_add=True,
blank=True,
null=True
)
last_updated = models.DateTimeField(
auto_now=True,
blank=True,
null=True
)
class Meta:
abstract = True
def to_objectchange(self, action):
"""
Return a new ObjectChange representing a change made to this object. This will typically be called automatically
by extras.middleware.ChangeLoggingMiddleware.
"""
return ObjectChange(
changed_object=self,
object_repr=str(self),
action=action,
object_data=serialize_object(self)
)
class ObjectChange(models.Model):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
parent device. This will ensure changes made to component models appear in the parent model's changelog.
"""
time = models.DateTimeField(
auto_now_add=True,
editable=False,
db_index=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='changes',
blank=True,
null=True
)
user_name = models.CharField(
max_length=150,
editable=False
)
request_id = models.UUIDField(
editable=False
)
action = models.CharField(
max_length=50,
choices=ObjectChangeActionChoices
)
changed_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
changed_object_id = models.PositiveIntegerField()
changed_object = GenericForeignKey(
ct_field='changed_object_type',
fk_field='changed_object_id'
)
related_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
related_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
related_object = GenericForeignKey(
ct_field='related_object_type',
fk_field='related_object_id'
)
object_repr = models.CharField(
max_length=200,
editable=False
)
object_data = models.JSONField(
editable=False
)
objects = RestrictedQuerySet.as_manager()
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
]
class Meta:
ordering = ['-time']
def __str__(self):
return '{} {} {} by {}'.format(
self.changed_object_type,
self.object_repr,
self.get_action_display().lower(),
self.user_name
)
def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings
if not self.user_name:
self.user_name = self.user.username
if not self.object_repr:
self.object_repr = str(self.changed_object)
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
def to_csv(self):
return (
self.time,
self.user,
self.user_name,
self.request_id,
self.get_action_display(),
self.changed_object_type,
self.changed_object_id,
self.related_object_type,
self.related_object_id,
self.object_repr,
self.object_data,
)

View File

@@ -1,22 +1,25 @@
import json
import uuid
from collections import OrderedDict
from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import JSONField
from django.core.validators import ValidationError
from django.db import models
from django.http import HttpResponse
from django.template import Template, Context
from django.urls import reverse
from django.utils import timezone
from rest_framework.utils.encoders import JSONEncoder
from utilities.utils import deepmerge, render_jinja2
from extras.choices import *
from extras.constants import *
from extras.models import ChangeLoggedModel
from extras.querysets import ConfigContextQuerySet
from extras.utils import FeatureQuery, image_upload
from extras.utils import extras_features, FeatureQuery, image_upload
from utilities.querysets import RestrictedQuerySet
from utilities.utils import deepmerge, render_jinja2
#
@@ -231,6 +234,8 @@ class Graph(models.Model):
verbose_name='Link URL'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('type', 'weight', 'name', 'pk') # (type, weight, name) may be non-unique
@@ -298,6 +303,8 @@ class ExportTemplate(models.Model):
help_text='Extension to append to the rendered filename'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ['content_type', 'name']
unique_together = [
@@ -426,7 +433,7 @@ class ImageAttachment(models.Model):
# Config contexts
#
class ConfigContext(models.Model):
class ConfigContext(ChangeLoggedModel):
"""
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@@ -491,7 +498,7 @@ class ConfigContext(models.Model):
related_name='+',
blank=True
)
data = JSONField()
data = models.JSONField()
objects = ConfigContextQuerySet.as_manager()
@@ -518,7 +525,7 @@ class ConfigContextModel(models.Model):
A model which includes local configuration context data. This local data will override any inherited data from
ConfigContexts.
"""
local_context_data = JSONField(
local_context_data = models.JSONField(
blank=True,
null=True,
)
@@ -557,32 +564,56 @@ class ConfigContextModel(models.Model):
# Custom scripts
#
@extras_features('job_results')
class Script(models.Model):
"""
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
"""
class Meta:
managed = False
permissions = (
('run_script', 'Can run script'),
)
#
# Report results
# Reports
#
class ReportResult(models.Model):
@extras_features('job_results')
class Report(models.Model):
"""
Dummy model used to generate permissions for reports. Does not exist in the database.
"""
class Meta:
managed = False
#
# Job results
#
class JobResult(models.Model):
"""
This model stores the results from running a user-defined report.
"""
report = models.CharField(
max_length=255,
unique=True
name = models.CharField(
max_length=255
)
obj_type = models.ForeignKey(
to=ContentType,
related_name='job_results',
verbose_name='Object types',
limit_choices_to=FeatureQuery('job_results'),
help_text="The object type to which this job result applies.",
on_delete=models.CASCADE,
)
created = models.DateTimeField(
auto_now_add=True
)
completed = models.DateTimeField(
null=True,
blank=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
@@ -590,126 +621,65 @@ class ReportResult(models.Model):
blank=True,
null=True
)
failed = models.BooleanField()
data = JSONField()
status = models.CharField(
max_length=30,
choices=JobResultStatusChoices,
default=JobResultStatusChoices.STATUS_PENDING
)
data = models.JSONField(
null=True,
blank=True
)
job_id = models.UUIDField(
unique=True
)
class Meta:
ordering = ['report']
ordering = ['obj_type', 'name', '-created']
def __str__(self):
return "{} {} at {}".format(
self.report,
"passed" if not self.failed else "failed",
self.created
return str(self.job_id)
@property
def duration(self):
if not self.completed:
return None
duration = self.completed - self.created
minutes, seconds = divmod(duration.total_seconds(), 60)
return f"{int(minutes)} minutes, {seconds:.2f} seconds"
def set_status(self, status):
"""
Helper method to change the status of the job result and save. If the target status is terminal, the
completion time is also set.
"""
self.status = status
if status in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
self.completed = timezone.now()
self.save()
@classmethod
def enqueue_job(cls, func, name, obj_type, user, *args, **kwargs):
"""
Create a JobResult instance and enqueue a job using the given callable
func: The callable object to be enqueued for execution
name: Name for the JobResult instance
obj_type: ContentType to link to the JobResult instance obj_type
user: User object to link to the JobResult instance
args: additional args passed to the callable
kwargs: additional kargs passed to the callable
"""
job_result = cls.objects.create(
name=name,
obj_type=obj_type,
user=user,
job_id=uuid.uuid4()
)
func.delay(*args, job_id=str(job_result.job_id), job_result=job_result, **kwargs)
#
# Change logging
#
class ObjectChange(models.Model):
"""
Record a change to an object and the user account associated with that change. A change record may optionally
indicate an object related to the one being changed. For example, a change to an interface may also indicate the
parent device. This will ensure changes made to component models appear in the parent model's changelog.
"""
time = models.DateTimeField(
auto_now_add=True,
editable=False,
db_index=True
)
user = models.ForeignKey(
to=User,
on_delete=models.SET_NULL,
related_name='changes',
blank=True,
null=True
)
user_name = models.CharField(
max_length=150,
editable=False
)
request_id = models.UUIDField(
editable=False
)
action = models.CharField(
max_length=50,
choices=ObjectChangeActionChoices
)
changed_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+'
)
changed_object_id = models.PositiveIntegerField()
changed_object = GenericForeignKey(
ct_field='changed_object_type',
fk_field='changed_object_id'
)
related_object_type = models.ForeignKey(
to=ContentType,
on_delete=models.PROTECT,
related_name='+',
blank=True,
null=True
)
related_object_id = models.PositiveIntegerField(
blank=True,
null=True
)
related_object = GenericForeignKey(
ct_field='related_object_type',
fk_field='related_object_id'
)
object_repr = models.CharField(
max_length=200,
editable=False
)
object_data = JSONField(
editable=False
)
csv_headers = [
'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id',
'related_object_type', 'related_object_id', 'object_repr', 'object_data',
]
class Meta:
ordering = ['-time']
def __str__(self):
return '{} {} {} by {}'.format(
self.changed_object_type,
self.object_repr,
self.get_action_display().lower(),
self.user_name
)
def save(self, *args, **kwargs):
# Record the user's name and the object's representation as static strings
if not self.user_name:
self.user_name = self.user.username
if not self.object_repr:
self.object_repr = str(self.changed_object)
return super().save(*args, **kwargs)
def get_absolute_url(self):
return reverse('extras:objectchange', args=[self.pk])
def to_csv(self):
return (
self.time,
self.user,
self.user_name,
self.request_id,
self.get_action_display(),
self.changed_object_type,
self.changed_object_id,
self.related_object_type,
self.related_object_id,
self.object_repr,
self.object_data,
)
return job_result

View File

@@ -1,11 +1,11 @@
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase
from extras.models import ChangeLoggedModel
from utilities.choices import ColorChoices
from utilities.fields import ColorField
from utilities.models import ChangeLoggedModel
from utilities.querysets import RestrictedQuerySet
#
@@ -21,8 +21,12 @@ class Tag(TagBase, ChangeLoggedModel):
blank=True,
)
def get_absolute_url(self):
return reverse('extras:tag', args=[self.slug])
objects = RestrictedQuerySet.as_manager()
csv_headers = ['name', 'slug', 'color', 'description']
class Meta:
ordering = ['name']
def slugify(self, tag, i=None):
# Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
@@ -31,6 +35,14 @@ class Tag(TagBase, ChangeLoggedModel):
slug += "_%d" % i
return slug
def to_csv(self):
return (
self.name,
self.slug,
self.color,
self.description
)
class TaggedItem(GenericTaggedItemBase):
tag = models.ForeignKey(

View File

@@ -2,6 +2,8 @@ from collections import OrderedDict
from django.db.models import Q, QuerySet
from utilities.querysets import RestrictedQuerySet
class CustomFieldQueryset:
"""
@@ -19,7 +21,7 @@ class CustomFieldQueryset:
yield obj
class ConfigContextQuerySet(QuerySet):
class ConfigContextQuerySet(RestrictedQuerySet):
def get_for_object(self, obj):
"""

View File

@@ -5,10 +5,15 @@ import pkgutil
from collections import OrderedDict
from django.conf import settings
from django.db.models import Q
from django.utils import timezone
from django_rq import job
from .constants import *
from .models import ReportResult
from .choices import JobResultStatusChoices, LogLevelChoices
from .models import JobResult
logger = logging.getLogger(__name__)
def is_report(obj):
@@ -60,6 +65,32 @@ def get_reports():
return module_list
@job('default')
def run_report(job_result, *args, **kwargs):
"""
Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
method for queueing into the background processor.
"""
module_name, report_name = job_result.name.split('.', 1)
report = get_report(module_name, report_name)
try:
report.run(job_result)
except Exception as e:
print(e)
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
logging.error(f"Error during execution of report {job_result.name}")
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=job_result.obj_type,
name=job_result.name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).exclude(
pk=job_result.pk
).delete()
class Report(object):
"""
NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
@@ -115,22 +146,29 @@ class Report(object):
return self.__module__
@property
def name(self):
def class_name(self):
return self.__class__.__name__
@property
def full_name(self):
return '.'.join([self.__module__, self.__class__.__name__])
def name(self):
"""
Override this attribute to set a custom display name.
"""
return self.class_name
def _log(self, obj, message, level=LOG_DEFAULT):
@property
def full_name(self):
return f'{self.module}.{self.class_name}'
def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
"""
Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
"""
if level not in LOG_LEVEL_CODES:
if level not in LogLevelChoices.as_dict():
raise Exception("Unknown logging level: {}".format(level))
self._results[self.active_test]['log'].append((
timezone.now().isoformat(),
LOG_LEVEL_CODES.get(level),
level,
str(obj) if obj else None,
obj.get_absolute_url() if getattr(obj, 'get_absolute_url', None) else None,
message,
@@ -140,7 +178,7 @@ class Report(object):
"""
Log a message which is not associated with a particular object.
"""
self._log(None, message, level=LOG_DEFAULT)
self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
self.logger.info(message)
def log_success(self, obj, message=None):
@@ -148,7 +186,7 @@ class Report(object):
Record a successful test against an object. Logging a message is optional.
"""
if message:
self._log(obj, message, level=LOG_SUCCESS)
self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
self._results[self.active_test]['success'] += 1
self.logger.info(f"Success | {obj}: {message}")
@@ -156,7 +194,7 @@ class Report(object):
"""
Log an informational message.
"""
self._log(obj, message, level=LOG_INFO)
self._log(obj, message, level=LogLevelChoices.LOG_INFO)
self._results[self.active_test]['info'] += 1
self.logger.info(f"Info | {obj}: {message}")
@@ -164,7 +202,7 @@ class Report(object):
"""
Log a warning.
"""
self._log(obj, message, level=LOG_WARNING)
self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
self._results[self.active_test]['warning'] += 1
self.logger.info(f"Warning | {obj}: {message}")
@@ -172,32 +210,34 @@ class Report(object):
"""
Log a failure. Calling this method will automatically mark the report as failed.
"""
self._log(obj, message, level=LOG_FAILURE)
self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
self._results[self.active_test]['failure'] += 1
self.logger.info(f"Failure | {obj}: {message}")
self.failed = True
def run(self):
def run(self, job_result):
"""
Run the report and return its results. Each test method will be executed in order.
Run the report and save its results. Each test method will be executed in order.
"""
self.logger.info(f"Running report")
job_result.status = JobResultStatusChoices.STATUS_RUNNING
job_result.save()
for method_name in self.test_methods:
self.active_test = method_name
test_method = getattr(self, method_name)
test_method()
# Delete any previous ReportResult and create a new one to record the result.
ReportResult.objects.filter(report=self.full_name).delete()
result = ReportResult(report=self.full_name, failed=self.failed, data=self._results)
result.save()
self.result = result
if self.failed:
self.logger.warning("Report failed")
job_result.status = JobResultStatusChoices.STATUS_FAILED
else:
self.logger.info("Report completed successfully")
job_result.status = JobResultStatusChoices.STATUS_COMPLETED
job_result.data = self._results
job_result.completed = timezone.now()
job_result.save()
# Perform any post-run tasks
self.post_run()

View File

@@ -3,7 +3,6 @@ import json
import logging
import os
import pkgutil
import time
import traceback
from collections import OrderedDict
@@ -12,12 +11,14 @@ from django import forms
from django.conf import settings
from django.core.validators import RegexValidator
from django.db import transaction
from mptt.forms import TreeNodeChoiceField, TreeNodeMultipleChoiceField
from mptt.models import MPTTModel
from django.utils.functional import classproperty
from django_rq import job
from extras.api.serializers import ScriptOutputSerializer
from extras.choices import JobResultStatusChoices, LogLevelChoices
from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from .constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from utilities.exceptions import AbortTransaction
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from .forms import ScriptForm
@@ -177,10 +178,6 @@ class ObjectVar(ScriptVariable):
# Queryset for field choices
self.field_attrs['queryset'] = queryset
# Update form field for MPTT (nested) objects
if issubclass(queryset.model, MPTTModel):
self.form_field = TreeNodeChoiceField
class MultiObjectVar(ScriptVariable):
"""
@@ -194,10 +191,6 @@ class MultiObjectVar(ScriptVariable):
# Queryset for field choices
self.field_attrs['queryset'] = queryset
# Update form field for MPTT (nested) objects
if issubclass(queryset.model, MPTTModel):
self.form_field = TreeNodeMultipleChoiceField
class FileVar(ScriptVariable):
"""
@@ -267,8 +260,20 @@ class BaseScript:
self.source = inspect.getsource(self.__class__)
def __str__(self):
return self.name
@classproperty
def name(self):
return getattr(self.Meta, 'name', self.__class__.__name__)
@classproperty
def full_name(self):
return '.'.join([self.__module__, self.__name__])
@classproperty
def description(self):
return getattr(self.Meta, 'description', '')
@classmethod
def module(cls):
return cls.__module__
@@ -306,23 +311,23 @@ class BaseScript:
def log_debug(self, message):
self.logger.log(logging.DEBUG, message)
self.log.append((LOG_DEFAULT, message))
self.log.append((LogLevelChoices.LOG_DEFAULT, message))
def log_success(self, message):
self.logger.log(logging.INFO, message) # No syslog equivalent for SUCCESS
self.log.append((LOG_SUCCESS, message))
self.log.append((LogLevelChoices.LOG_SUCCESS, message))
def log_info(self, message):
self.logger.log(logging.INFO, message)
self.log.append((LOG_INFO, message))
self.log.append((LogLevelChoices.LOG_INFO, message))
def log_warning(self, message):
self.logger.log(logging.WARNING, message)
self.log.append((LOG_WARNING, message))
self.log.append((LogLevelChoices.LOG_WARNING, message))
def log_failure(self, message):
self.logger.log(logging.ERROR, message)
self.log.append((LOG_FAILURE, message))
self.log.append((LogLevelChoices.LOG_FAILURE, message))
# Convenience functions
@@ -375,17 +380,21 @@ def is_variable(obj):
return isinstance(obj, ScriptVariable)
def run_script(script, data, request, commit=True):
@job('default')
def run_script(data, request, commit=True, *args, **kwargs):
"""
A wrapper for calling Script.run(). This performs error handling and provides a hook for committing changes. It
exists outside of the Script class to ensure it cannot be overridden by a script author.
"""
output = None
start_time = None
end_time = None
job_result = kwargs.pop('job_result')
module, script_name = job_result.name.split('.', 1)
script_name = script.__class__.__name__
logger = logging.getLogger(f"netbox.scripts.{script.module()}.{script_name}")
script = get_script(module, script_name)()
job_result.status = JobResultStatusChoices.STATUS_RUNNING
job_result.save()
logger = logging.getLogger(f"netbox.scripts.{module}.{script_name}")
logger.info(f"Running script (commit={commit})")
# Add files to form data
@@ -405,13 +414,14 @@ def run_script(script, data, request, commit=True):
try:
with transaction.atomic():
start_time = time.time()
output = script.run(**kwargs)
end_time = time.time()
script.output = script.run(**kwargs)
if not commit:
raise AbortTransaction()
except AbortTransaction:
pass
except Exception as e:
stacktrace = traceback.format_exc()
script.log_failure(
@@ -419,7 +429,13 @@ def run_script(script, data, request, commit=True):
)
logger.error(f"Exception raised during script execution: {e}")
commit = False
job_result.set_status(JobResultStatusChoices.STATUS_ERRORED)
finally:
if job_result.status != JobResultStatusChoices.STATUS_ERRORED:
job_result.data = ScriptOutputSerializer(script).data
job_result.set_status(JobResultStatusChoices.STATUS_COMPLETED)
if not commit:
# Delete all pending changelog entries
purge_changelog.send(Script)
@@ -427,14 +443,16 @@ def run_script(script, data, request, commit=True):
"Database changes have been reverted automatically."
)
# Calculate execution time
if end_time is not None:
execution_time = end_time - start_time
logger.info(f"Script completed in {execution_time:.4f} seconds")
else:
execution_time = None
logger.info(f"Script completed in {job_result.duration}")
return output, execution_time
# Delete any previous terminal state results
JobResult.objects.filter(
obj_type=job_result.obj_type,
name=job_result.name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).exclude(
pk=job_result.pk
).delete()
def get_scripts(use_names=False):

View File

@@ -1,21 +1,8 @@
import django_tables2 as tables
from django_tables2.utils import Accessor
from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, ToggleColumn
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
TAG_ACTIONS = """
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
<i class="fa fa-history"></i>
</a>
{% if perms.taggit.change_tag %}
<a href="{% url 'extras:tag_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
{% if perms.taggit.delete_tag %}
<a href="{% url 'extras:tag_delete' slug=record.slug %}" class="btn btn-xs btn-danger"><i class="glyphicon glyphicon-trash" aria-hidden="true"></i></a>
{% endif %}
"""
TAGGED_ITEM = """
{% if value.get_absolute_url %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
@@ -64,16 +51,8 @@ OBJECTCHANGE_REQUEST_ID = """
class TagTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(
viewname='extras:tag',
args=[Accessor('slug')]
)
actions = tables.TemplateColumn(
template_code=TAG_ACTIONS,
attrs={'td': {'class': 'text-right noprint'}},
verbose_name=''
)
color = ColorColumn()
actions = ButtonsColumn(Tag, pk_field='slug')
class Meta(BaseTable.Meta):
model = Tag

View File

@@ -1,6 +1,6 @@
from django import template
from extras.constants import LOG_DEFAULT, LOG_FAILURE, LOG_INFO, LOG_SUCCESS, LOG_WARNING
from extras.choices import LogLevelChoices
register = template.Library()
@@ -11,27 +11,7 @@ def log_level(level):
"""
Display a label indicating a syslog severity (e.g. info, warning, etc.).
"""
levels = {
LOG_DEFAULT: {
'name': 'Default',
'class': 'default'
},
LOG_SUCCESS: {
'name': 'Success',
'class': 'success',
},
LOG_INFO: {
'name': 'Info',
'class': 'info'
},
LOG_WARNING: {
'name': 'Warning',
'class': 'warning'
},
LOG_FAILURE: {
'name': 'Failure',
'class': 'danger'
}
return {
'name': LogLevelChoices.as_dict()[level],
'class': dict(LogLevelChoices.CLASS_MAP)[level]
}
return levels[level]

View File

@@ -1,17 +1,24 @@
import datetime
from unittest import skipIf
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils import timezone
from django_rq.queues import get_connection
from rest_framework import status
from rq import Worker
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site
from extras.api.views import ScriptViewSet
from extras.api.views import ReportViewSet, ScriptViewSet
from extras.models import ConfigContext, Graph, ExportTemplate, Tag
from extras.reports import Report
from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
from utilities.testing import APITestCase, APIViewTestCases
rq_worker_running = Worker.count(get_connection('default'))
class AppTest(APITestCase):
def test_root(self):
@@ -102,7 +109,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase):
class TagTest(APIViewTestCases.APIViewTestCase):
model = Tag
brief_fields = ['color', 'id', 'name', 'slug', 'tagged_items', 'url']
brief_fields = ['color', 'id', 'name', 'slug', 'url']
create_data = [
{
'name': 'Tag 4',
@@ -207,6 +214,39 @@ class ConfigContextTest(APIViewTestCases.APIViewTestCase):
self.assertEqual(rendered_context['bar'], 456)
class ReportTest(APITestCase):
class TestReport(Report):
def test_foo(self):
self.log_success(None, "Report completed")
def get_test_report(self, *args):
return self.TestReport()
def setUp(self):
super().setUp()
# Monkey-patch the API viewset's _get_script method to return our test script above
ReportViewSet._retrieve_report = self.get_test_report
def test_get_report(self):
url = reverse('extras-api:report-detail', kwargs={'pk': None})
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.TestReport.__name__)
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_report(self):
self.add_permissions('extras.run_script')
url = reverse('extras-api:report-run', kwargs={'pk': None})
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['result']['status']['value'], 'pending')
class ScriptTest(APITestCase):
class TestScript(Script):
@@ -246,6 +286,7 @@ class ScriptTest(APITestCase):
self.assertEqual(response.data['vars']['var2'], 'IntegerVar')
self.assertEqual(response.data['vars']['var3'], 'BooleanVar')
@skipIf(not rq_worker_running, "RQ worker not running")
def test_run_script(self):
script_data = {
@@ -263,13 +304,7 @@ class ScriptTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['log'][0]['status'], 'info')
self.assertEqual(response.data['log'][0]['message'], script_data['var1'])
self.assertEqual(response.data['log'][1]['status'], 'success')
self.assertEqual(response.data['log'][1]['message'], script_data['var2'])
self.assertEqual(response.data['log'][2]['status'], 'failure')
self.assertEqual(response.data['log'][2]['message'], script_data['var3'])
self.assertEqual(response.data['output'], 'Script complete')
self.assertEqual(response.data['result']['status']['value'], 'pending')
class CreatedUpdatedFilterTest(APITestCase):
@@ -295,6 +330,7 @@ class CreatedUpdatedFilterTest(APITestCase):
)
def test_get_rack_created(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created=2001-02-03'.format(url), **self.header)
@@ -302,6 +338,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_created_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__gte=2001-02-04'.format(url), **self.header)
@@ -309,6 +346,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_created_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?created__lte=2001-02-04'.format(url), **self.header)
@@ -316,6 +354,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated=2001-02-03%2001:02:03.000004'.format(url), **self.header)
@@ -323,6 +362,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack2.pk)
def test_get_rack_last_updated_gte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__gte=2001-02-04%2001:02:03.000004'.format(url), **self.header)
@@ -330,6 +370,7 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], self.rack1.pk)
def test_get_rack_last_updated_lte(self):
self.add_permissions('dcim.view_rack')
url = reverse('dcim-api:rack-list')
response = self.client.get('{}?last_updated__lte=2001-02-04%2001:02:03.000004'.format(url), **self.header)

View File

@@ -4,7 +4,6 @@ from rest_framework import status
from dcim.models import Site
from extras.choices import *
from extras.constants import *
from extras.models import CustomField, CustomFieldValue, ObjectChange
from utilities.testing import APITestCase
@@ -12,7 +11,6 @@ from utilities.testing import APITestCase
class ChangeLogTest(APITestCase):
def setUp(self):
super().setUp()
# Create a custom field on the Site model
@@ -26,21 +24,17 @@ class ChangeLogTest(APITestCase):
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')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -52,10 +46,8 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.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):
site = Site(name='Test Site 1', slug='test-site-1')
site.save()
@@ -65,14 +57,11 @@ class ChangeLogTest(APITestCase):
'custom_fields': {
'my_field': 'DEF'
},
'tags': [
'abc', 'xyz'
],
}
self.assertEqual(ObjectChange.objects.count(), 0)
self.add_permissions('dcim.change_site')
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)
@@ -84,27 +73,23 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.changed_object, site)
self.assertEqual(oc.action, ObjectChangeActionChoices.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.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)
self.add_permissions('dcim.delete_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.delete(url, **self.header)
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)
self.assertEqual(Site.objects.count(), 0)
@@ -113,4 +98,3 @@ class ChangeLogTest(APITestCase):
self.assertEqual(oc.object_repr, site.name)
self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'})
self.assertListEqual(sorted(oc.object_data['tags']), ['bar', 'foo'])

View File

@@ -1,7 +1,6 @@
from datetime import date
from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from django.urls import reverse
from rest_framework import status
@@ -9,7 +8,7 @@ from dcim.forms import SiteCSVForm
from dcim.models import Site
from extras.choices import *
from extras.models import CustomField, CustomFieldValue, CustomFieldChoice
from utilities.testing import APITestCase, create_test_user
from utilities.testing import APITestCase, TestCase
from virtualization.models import VirtualMachine
@@ -183,8 +182,9 @@ class CustomFieldAPITest(APITestCase):
Validate that custom fields are present on an object even if it has no values defined.
"""
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk})
response = self.client.get(url, **self.header)
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[0].name)
self.assertEqual(response.data['custom_fields'], {
'text_field': None,
@@ -202,10 +202,10 @@ class CustomFieldAPITest(APITestCase):
site2_cfvs = {
cfv.field.name: cfv.value for cfv in self.sites[1].custom_field_values.all()
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
response = self.client.get(url, **self.header)
self.add_permissions('dcim.view_site')
response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.sites[1].name)
self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field'])
self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field'])
@@ -222,8 +222,9 @@ class CustomFieldAPITest(APITestCase):
'name': 'Site 3',
'slug': 'site-3',
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -264,8 +265,9 @@ class CustomFieldAPITest(APITestCase):
'choice_field': self.cf_select_choice2.pk,
},
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
@@ -310,8 +312,9 @@ class CustomFieldAPITest(APITestCase):
'slug': 'site-5',
},
)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
@@ -368,8 +371,9 @@ class CustomFieldAPITest(APITestCase):
'custom_fields': custom_field_data,
},
)
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(len(response.data), len(data))
@@ -411,8 +415,9 @@ class CustomFieldAPITest(APITestCase):
'number_field': 1234,
},
}
url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -470,17 +475,10 @@ class CustomFieldChoiceAPITest(APITestCase):
class CustomFieldImportTest(TestCase):
def setUp(self):
user = create_test_user(
permissions=[
'dcim.view_site',
'dcim.add_site',
]
)
self.client = Client()
self.client.force_login(user)
user_permissions = (
'dcim.view_site',
'dcim.add_site',
)
@classmethod
def setUpTestData(cls):

View File

@@ -9,45 +9,53 @@ class TaggedItemTest(APITestCase):
"""
Test the application of Tags to and item (a Site, for example) upon creation (POST) and modification (PATCH).
"""
def setUp(self):
super().setUp()
def test_create_tagged_item(self):
tags = self.create_tags("Foo", "Bar", "Baz")
data = {
'name': 'Test Site',
'slug': 'test-site',
'tags': ['Foo', 'Bar', 'Baz']
'tags': [t.pk for t in tags]
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
self.assertListEqual(
sorted([t['id'] for t in response.data['tags']]),
sorted(data['tags'])
)
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "Baz"])
)
def test_update_tagged_item(self):
site = Site.objects.create(
name='Test Site',
slug='test-site'
)
site.tags.add('Foo', 'Bar', 'Baz')
site.tags.add("Foo", "Bar", "Baz")
self.create_tags("New Tag")
data = {
'tags': ['Foo', 'Bar', 'New Tag']
'tags': [
{"name": "Foo"},
{"name": "Bar"},
{"name": "New Tag"},
]
}
self.add_permissions('dcim.change_site')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
response = self.client.patch(url, data, format='json', **self.header)
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(sorted(response.data['tags']), sorted(data['tags']))
self.assertListEqual(
sorted([t['name'] for t in response.data['tags']]),
sorted([t['name'] for t in data['tags']])
)
site = Site.objects.get(pk=response.data['id'])
tags = [tag.name for tag in site.tags.all()]
self.assertEqual(sorted(tags), sorted(data['tags']))
self.assertListEqual(
sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"])
)

View File

@@ -10,13 +10,9 @@ from extras.models import ConfigContext, ObjectChange, Tag
from utilities.testing import ViewTestCases, TestCase
class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Tag
# Disable inapplicable tests
test_create_object = None
test_import_objects = None
@classmethod
def setUpTestData(cls):
@@ -33,21 +29,30 @@ class TagTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'comments': 'Some comments',
}
cls.csv_data = (
"name,slug,color,description",
"Tag 4,tag-4,ff0000,Fourth tag",
"Tag 5,tag-5,00ff00,Fifth tag",
"Tag 6,tag-6,0000ff,Sixth tag",
)
cls.bulk_edit_data = {
'color': '00ff00',
}
class ConfigContextTestCase(ViewTestCases.PrimaryObjectViewTestCase):
# TODO: Change base class to PrimaryObjectViewTestCase
# Blocked by absence of standard create/edit, bulk create views
class ConfigContextTestCase(
ViewTestCases.GetObjectViewTestCase,
ViewTestCases.GetObjectChangelogViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkEditObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = ConfigContext
# Disable inapplicable tests
test_import_objects = None
# TODO: Resolve model discrepancies when creating/editing ConfigContexts
test_create_object = None
test_edit_object = None
@classmethod
def setUpTestData(cls):
@@ -108,7 +113,7 @@ class ObjectChangeTestCase(TestCase):
url = reverse('extras:objectchange_list')
params = {
"user": User.objects.first(),
"user": User.objects.first().pk,
}
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))

View File

@@ -42,13 +42,13 @@ class WebhookTest(APITestCase):
webhook.obj_type.set([site_ct])
def test_enqueue_webhook_create(self):
# Create an object via the REST API
data = {
'name': 'Test Site',
'slug': 'test-site',
}
url = reverse('dcim-api:site-list')
self.add_permissions('dcim.add_site')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(Site.objects.count(), 1)
@@ -62,14 +62,13 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE)
def test_enqueue_webhook_update(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Update an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1')
data = {
'comments': 'Updated the site',
}
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.change_site')
response = self.client.patch(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
@@ -82,11 +81,10 @@ class WebhookTest(APITestCase):
self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE)
def test_enqueue_webhook_delete(self):
site = Site.objects.create(name='Site 1', slug='site-1')
# Delete an object via the REST API
site = Site.objects.create(name='Site 1', slug='site-1')
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
self.add_permissions('dcim.delete_site')
response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

View File

@@ -1,7 +1,7 @@
from django.urls import path
from extras import views
from extras.models import Tag
from extras.models import ConfigContext, Tag
app_name = 'extras'
@@ -9,21 +9,23 @@ urlpatterns = [
# Tags
path('tags/', views.TagListView.as_view(), name='tag_list'),
path('tags/add/', views.TagEditView.as_view(), name='tag_add'),
path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
path('tags/<str:slug>/', views.TagView.as_view(), name='tag'),
path('tags/<str:slug>/edit/', views.TagEditView.as_view(), name='tag_edit'),
path('tags/<str:slug>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
path('tags/<str:slug>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),
# Config contexts
path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'),
path('config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'),
path('config-contexts/add/', views.ConfigContextEditView.as_view(), name='configcontext_add'),
path('config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
path('config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
path('config-contexts/<int:pk>/', views.ConfigContextView.as_view(), name='configcontext'),
path('config-contexts/<int:pk>/edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'),
path('config-contexts/<int:pk>/delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
path('config-contexts/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='configcontext_changelog', kwargs={'model': ConfigContext}),
# Image attachments
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
@@ -35,11 +37,12 @@ urlpatterns = [
# Reports
path('reports/', views.ReportListView.as_view(), name='report_list'),
path('reports/<str:name>/', views.ReportView.as_view(), name='report'),
path('reports/<str:name>/run/', views.ReportRunView.as_view(), name='report_run'),
path('reports/<str:module>.<str:name>/', views.ReportView.as_view(), name='report'),
path('reports/results/<int:job_result_pk>/', views.ReportResultView.as_view(), name='report_result'),
# Scripts
path('scripts/', views.ScriptListView.as_view(), name='script_list'),
path('scripts/<str:module>/<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
]

View File

@@ -1,125 +1,105 @@
from django import template
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.db.models import Count, Prefetch, Q
from django.http import Http404, HttpResponseForbidden
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.safestring import mark_safe
from django.views.generic import View
from django_rq.queues import get_connection
from django_tables2 import RequestConfig
from rq import Worker
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator
from utilities.utils import shallow_compare_dict
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters, forms
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
from .reports import get_report, get_reports
from utilities.utils import copy_safe_request, shallow_compare_dict
from utilities.views import (
BulkDeleteView, BulkEditView, BulkImportView, ObjectView, ObjectDeleteView, ObjectEditView, ObjectListView,
ContentTypePermissionRequiredMixin,
)
from virtualization.models import Cluster, ClusterGroup
from . import filters, forms, tables
from .choices import JobResultStatusChoices
from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag
from .reports import get_report, get_reports, run_report
from .scripts import get_scripts, run_script
from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable
#
# Tags
#
class TagListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_tag'
class TagListView(ObjectListView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
filterset = filters.TagFilterSet
filterset_form = forms.TagFilterForm
table = TagTable
action_buttons = ()
table = tables.TagTable
class TagView(PermissionRequiredMixin, View):
permission_required = 'extras.view_tag'
def get(self, request, slug):
tag = get_object_or_404(Tag, slug=slug)
tagged_items = TaggedItem.objects.filter(
tag=tag
).prefetch_related(
'content_type', 'content_object'
)
# Generate a table of all items tagged with this Tag
items_table = TaggedItemTable(tagged_items)
paginate = {
'paginator_class': EnhancedPaginator,
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
}
RequestConfig(request, paginate).configure(items_table)
return render(request, 'extras/tag.html', {
'tag': tag,
'items_count': tagged_items.count(),
'items_table': items_table,
})
class TagEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_tag'
model = Tag
class TagEditView(ObjectEditView):
queryset = Tag.objects.all()
model_form = forms.TagForm
default_return_url = 'extras:tag_list'
template_name = 'extras/tag_edit.html'
class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_tag'
model = Tag
default_return_url = 'extras:tag_list'
class TagDeleteView(ObjectDeleteView):
queryset = Tag.objects.all()
class TagBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_tag'
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items', distinct=True)
).order_by(
'name'
)
table = TagTable
form = forms.TagBulkEditForm
default_return_url = 'extras:tag_list'
class TagBulkImportView(BulkImportView):
queryset = Tag.objects.all()
model_form = forms.TagCSVForm
table = tables.TagTable
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_tag'
class TagBulkEditView(BulkEditView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(
'name'
)
table = TagTable
default_return_url = 'extras:tag_list'
).order_by(*Tag._meta.ordering)
table = tables.TagTable
form = forms.TagBulkEditForm
class TagBulkDeleteView(BulkDeleteView):
queryset = Tag.objects.annotate(
items=Count('extras_taggeditem_items')
).order_by(*Tag._meta.ordering)
table = tables.TagTable
#
# Config contexts
#
class ConfigContextListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_configcontext'
class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm
table = ConfigContextTable
table = tables.ConfigContextTable
action_buttons = ('add',)
class ConfigContextView(PermissionRequiredMixin, View):
permission_required = 'extras.view_configcontext'
class ConfigContextView(ObjectView):
queryset = ConfigContext.objects.all()
def get(self, request, pk):
configcontext = get_object_or_404(ConfigContext, pk=pk)
# Extend queryset to prefetch related objects
self.queryset = self.queryset.prefetch_related(
Prefetch('regions', queryset=Region.objects.restrict(request.user)),
Prefetch('sites', queryset=Site.objects.restrict(request.user)),
Prefetch('roles', queryset=DeviceRole.objects.restrict(request.user)),
Prefetch('platforms', queryset=Platform.objects.restrict(request.user)),
Prefetch('clusters', queryset=Cluster.objects.restrict(request.user)),
Prefetch('cluster_groups', queryset=ClusterGroup.objects.restrict(request.user)),
Prefetch('tenants', queryset=Tenant.objects.restrict(request.user)),
Prefetch('tenant_groups', queryset=TenantGroup.objects.restrict(request.user)),
)
configcontext = get_object_or_404(self.queryset, pk=pk)
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
@@ -137,49 +117,36 @@ class ConfigContextView(PermissionRequiredMixin, View):
})
class ConfigContextCreateView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.add_configcontext'
model = ConfigContext
class ConfigContextEditView(ObjectEditView):
queryset = ConfigContext.objects.all()
model_form = forms.ConfigContextForm
default_return_url = 'extras:configcontext_list'
template_name = 'extras/configcontext_edit.html'
class ConfigContextEditView(ConfigContextCreateView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(PermissionRequiredMixin, BulkEditView):
permission_required = 'extras.change_configcontext'
class ConfigContextBulkEditView(BulkEditView):
queryset = ConfigContext.objects.all()
filterset = filters.ConfigContextFilterSet
table = ConfigContextTable
table = tables.ConfigContextTable
form = forms.ConfigContextBulkEditForm
default_return_url = 'extras:configcontext_list'
class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_configcontext'
model = ConfigContext
default_return_url = 'extras:configcontext_list'
class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'extras.delete_configcontext'
class ConfigContextDeleteView(ObjectDeleteView):
queryset = ConfigContext.objects.all()
table = ConfigContextTable
default_return_url = 'extras:configcontext_list'
class ObjectConfigContextView(View):
object_class = None
class ConfigContextBulkDeleteView(BulkDeleteView):
queryset = ConfigContext.objects.all()
table = tables.ConfigContextTable
class ObjectConfigContextView(ObjectView):
base_template = None
def get(self, request, pk):
obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj)
model_name = self.object_class._meta.model_name
obj = get_object_or_404(self.queryset, pk=pk)
source_contexts = ConfigContext.objects.restrict(request.user, 'view').get_for_object(obj)
model_name = self.queryset.model._meta.model_name
# Determine user's preferred output format
if request.GET.get('format') in ['json', 'yaml']:
@@ -206,30 +173,33 @@ class ObjectConfigContextView(View):
# Change logging
#
class ObjectChangeListView(PermissionRequiredMixin, ObjectListView):
permission_required = 'extras.view_objectchange'
class ObjectChangeListView(ObjectListView):
queryset = ObjectChange.objects.prefetch_related('user', 'changed_object_type')
filterset = filters.ObjectChangeFilterSet
filterset_form = forms.ObjectChangeFilterForm
table = ObjectChangeTable
table = tables.ObjectChangeTable
template_name = 'extras/objectchange_list.html'
action_buttons = ('export',)
class ObjectChangeView(PermissionRequiredMixin, View):
permission_required = 'extras.view_objectchange'
class ObjectChangeView(ObjectView):
queryset = ObjectChange.objects.all()
def get(self, request, pk):
objectchange = get_object_or_404(ObjectChange, pk=pk)
objectchange = get_object_or_404(self.queryset, pk=pk)
related_changes = ObjectChange.objects.filter(request_id=objectchange.request_id).exclude(pk=objectchange.pk)
related_changes_table = ObjectChangeTable(
related_changes = ObjectChange.objects.restrict(request.user, 'view').filter(
request_id=objectchange.request_id
).exclude(
pk=objectchange.pk
)
related_changes_table = tables.ObjectChangeTable(
data=related_changes[:50],
orderable=False
)
objectchanges = ObjectChange.objects.filter(
objectchanges = ObjectChange.objects.restrict(request.user, 'view').filter(
changed_object_type=objectchange.changed_object_type,
changed_object_id=objectchange.changed_object_id,
)
@@ -266,18 +236,21 @@ class ObjectChangeLogView(View):
def get(self, request, model, **kwargs):
# Get object my model and kwargs (e.g. slug='foo')
obj = get_object_or_404(model, **kwargs)
# Handle QuerySet restriction of parent object if needed
if hasattr(model.objects, 'restrict'):
obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs)
else:
obj = get_object_or_404(model, **kwargs)
# Gather all changes for this object (and its related objects)
content_type = ContentType.objects.get_for_model(model)
objectchanges = ObjectChange.objects.prefetch_related(
objectchanges = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
'user', 'changed_object_type'
).filter(
Q(changed_object_type=content_type, changed_object_id=obj.pk) |
Q(related_object_type=content_type, related_object_id=obj.pk)
)
objectchanges_table = ObjectChangeTable(
objectchanges_table = tables.ObjectChangeTable(
data=objectchanges,
orderable=False
)
@@ -300,6 +273,7 @@ class ObjectChangeLogView(View):
return render(request, 'extras/object_changelog.html', {
object_var: obj,
'instance': obj, # We'll eventually standardize on 'instance` for the object variable name
'table': objectchanges_table,
'base_template': base_template,
'active_tab': 'changelog',
@@ -310,9 +284,8 @@ class ObjectChangeLogView(View):
# Image attachments
#
class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
permission_required = 'extras.change_imageattachment'
model = ImageAttachment
class ImageAttachmentEditView(ObjectEditView):
queryset = ImageAttachment.objects.all()
model_form = forms.ImageAttachmentForm
def alter_obj(self, imageattachment, request, args, kwargs):
@@ -326,9 +299,8 @@ class ImageAttachmentEditView(PermissionRequiredMixin, ObjectEditView):
return imageattachment.parent.get_absolute_url()
class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'extras.delete_imageattachment'
model = ImageAttachment
class ImageAttachmentDeleteView(ObjectDeleteView):
queryset = ImageAttachment.objects.all()
def get_return_url(self, request, imageattachment):
return imageattachment.parent.get_absolute_url()
@@ -338,16 +310,24 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView):
# Reports
#
class ReportListView(PermissionRequiredMixin, View):
class ReportListView(ContentTypePermissionRequiredMixin, View):
"""
Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each.
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
"""
permission_required = 'extras.view_reportresult'
def get_required_permission(self):
return 'extras.view_reportresult'
def get(self, request):
reports = get_reports()
results = {r.report: r for r in ReportResult.objects.all()}
report_content_type = ContentType.objects.get(app_label='extras', model='report')
results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=report_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data')
}
ret = []
for module, report_list in reports:
@@ -362,83 +342,152 @@ class ReportListView(PermissionRequiredMixin, View):
})
class ReportView(PermissionRequiredMixin, View):
"""
Display a single Report and its associated ReportResult (if any).
"""
permission_required = 'extras.view_reportresult'
def get(self, request, name):
# Retrieve the Report by "<module>.<report>"
module_name, report_name = name.split('.')
report = get_report(module_name, report_name)
class GetReportMixin:
def _get_report(self, name, module=None):
if module is None:
module, name = name.split('.', 1)
report = get_report(module, name)
if report is None:
raise Http404
# Attach the ReportResult (if any)
report.result = ReportResult.objects.filter(report=report.full_name).first()
return report
class ReportView(GetReportMixin, ContentTypePermissionRequiredMixin, View):
"""
Display a single Report and its associated JobResult (if any).
"""
def get_required_permission(self):
return 'extras.view_reportresult'
def get(self, request, module, name):
report = self._get_report(name, module)
report_content_type = ContentType.objects.get(app_label='extras', model='report')
report.result = JobResult.objects.filter(
obj_type=report_content_type,
name=report.full_name,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).first()
return render(request, 'extras/report.html', {
'report': report,
'run_form': ConfirmationForm(),
})
def post(self, request, module, name):
class ReportRunView(PermissionRequiredMixin, View):
"""
Run a Report and record a new ReportResult.
"""
permission_required = 'extras.add_reportresult'
# Permissions check
if not request.user.has_perm('extras.run_report'):
return HttpResponseForbidden()
def post(self, request, name):
report = self._get_report(name, module)
form = ConfirmationForm(request.POST)
# Retrieve the Report by "<module>.<report>"
module_name, report_name = name.split('.')
report = get_report(module_name, report_name)
if report is None:
# Allow execution only if RQ worker process is running
if not Worker.count(get_connection('default')):
messages.error(request, "Unable to run report: RQ worker process not running.")
elif form.is_valid():
# Run the Report. A new JobResult is created.
report_content_type = ContentType.objects.get(app_label='extras', model='report')
job_result = JobResult.enqueue_job(
run_report,
report.full_name,
report_content_type,
request.user
)
return redirect('extras:report_result', job_result_pk=job_result.pk)
return render(request, 'extras/report.html', {
'report': report,
'run_form': form,
})
class ReportResultView(ContentTypePermissionRequiredMixin, GetReportMixin, View):
def get_required_permission(self):
return 'extras.view_report'
def get(self, request, job_result_pk):
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
report_content_type = ContentType.objects.get(app_label='extras', model='report')
if result.obj_type != report_content_type:
raise Http404
form = ConfirmationForm(request.POST)
if form.is_valid():
report = self._get_report(result.name)
# Run the Report. A new ReportResult is created.
report.run()
result = 'failed' if report.failed else 'passed'
msg = "Ran report {} ({})".format(report.full_name, result)
messages.success(request, mark_safe(msg))
return redirect('extras:report', name=report.full_name)
return render(request, 'extras/report_result.html', {
'report': report,
'result': result,
'class_name': report.name,
'run_form': ConfirmationForm(),
})
#
# Scripts
#
class ScriptListView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def get(self, request):
return render(request, 'extras/script_list.html', {
'scripts': get_scripts(use_names=True),
})
class ScriptView(PermissionRequiredMixin, View):
permission_required = 'extras.view_script'
def _get_script(self, module, name):
class GetScriptMixin:
def _get_script(self, name, module=None):
if module is None:
module, name = name.split('.', 1)
scripts = get_scripts()
try:
return scripts[module][name]()
except KeyError:
raise Http404
class ScriptListView(ContentTypePermissionRequiredMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request):
scripts = get_scripts(use_names=True)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
results = {
r.name: r
for r in JobResult.objects.filter(
obj_type=script_content_type,
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).defer('data')
}
for _scripts in scripts.values():
for script in _scripts.values():
script.result = results.get(script.full_name)
return render(request, 'extras/script_list.html', {
'scripts': scripts,
})
class ScriptView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, module, name):
script = self._get_script(module, name)
script = self._get_script(name, module)
form = script.as_form(initial=request.GET)
# Look for a pending JobResult (use the latest one by creation timestamp)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
script.result = JobResult.objects.filter(
obj_type=script_content_type,
name=script.full_name,
).exclude(
status__in=JobResultStatusChoices.TERMINAL_STATE_CHOICES
).first()
return render(request, 'extras/script.html', {
'module': module,
'script': script,
@@ -451,19 +500,51 @@ class ScriptView(PermissionRequiredMixin, View):
if not request.user.has_perm('extras.run_script'):
return HttpResponseForbidden()
script = self._get_script(module, name)
script = self._get_script(name, module)
form = script.as_form(request.POST, request.FILES)
output = None
execution_time = None
if form.is_valid():
# Allow execution only if RQ worker process is running
if not Worker.count(get_connection('default')):
messages.error(request, "Unable to run script: RQ worker process not running.")
elif form.is_valid():
commit = form.cleaned_data.pop('_commit')
output, execution_time = run_script(script, form.cleaned_data, request, commit)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
job_result = JobResult.enqueue_job(
run_script,
script.full_name,
script_content_type,
request.user,
data=form.cleaned_data,
request=copy_safe_request(request),
commit=commit
)
return redirect('extras:script_result', job_result_pk=job_result.pk)
return render(request, 'extras/script.html', {
'module': module,
'script': script,
'form': form,
'output': output,
'execution_time': execution_time,
})
class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View):
def get_required_permission(self):
return 'extras.view_script'
def get(self, request, job_result_pk):
result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk)
script_content_type = ContentType.objects.get(app_label='extras', model='script')
if result.obj_type != script_content_type:
raise Http404
script = self._get_script(result.name)
return render(request, 'extras/script_result.html', {
'script': script,
'result': result,
'class_name': script.__class__.__name__
})

View File

@@ -1,18 +1,22 @@
from collections import OrderedDict
from django.contrib.contenttypes.models import ContentType
from drf_yasg.utils import swagger_serializer_method
from rest_framework import serializers
from rest_framework.reverse import reverse
from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer
from extras.api.serializers import TaggedObjectSerializer
from ipam.choices import *
from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
from tenancy.api.nested_serializers import NestedTenantSerializer
from utilities.api import (
ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer,
get_serializer_for_model,
)
from virtualization.api.nested_serializers import NestedVirtualMachineSerializer
from .nested_serializers import *
@@ -22,17 +26,17 @@ from .nested_serializers import *
# VRFs
#
class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail')
tenant = NestedTenantSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
ipaddress_count = serializers.IntegerField(read_only=True)
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VRF
fields = [
'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields',
'created', 'last_updated', 'ipaddress_count', 'prefix_count',
'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name',
'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count',
]
@@ -41,22 +45,23 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RIRSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail')
aggregate_count = serializers.IntegerField(read_only=True)
class Meta:
model = RIR
fields = ['id', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
fields = ['id', 'url', 'name', 'slug', 'is_private', 'description', 'aggregate_count']
class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
rir = NestedRIRSerializer()
tags = TagListSerializerField(required=False)
class Meta:
model = Aggregate
fields = [
'id', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
'id', 'url', 'family', 'prefix', 'rir', 'date_added', 'description', 'tags', 'custom_fields', 'created',
'last_updated',
]
read_only_fields = ['family']
@@ -67,21 +72,23 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer):
#
class RoleSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail')
prefix_count = serializers.IntegerField(read_only=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = Role
fields = ['id', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count']
fields = ['id', 'url', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count']
class VLANGroupSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
site = NestedSiteSerializer(required=False, allow_null=True)
vlan_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLANGroup
fields = ['id', 'name', 'slug', 'site', 'description', 'vlan_count']
fields = ['id', 'url', 'name', 'slug', 'site', 'description', 'vlan_count']
validators = []
def validate(self, data):
@@ -98,20 +105,20 @@ class VLANGroupSerializer(ValidatedModelSerializer):
return data
class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail')
site = NestedSiteSerializer(required=False, allow_null=True)
group = NestedVLANGroupSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=VLANStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
prefix_count = serializers.IntegerField(read_only=True)
class Meta:
model = VLAN
fields = [
'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name',
'custom_fields', 'created', 'last_updated', 'prefix_count',
'id', 'url', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags',
'display_name', 'custom_fields', 'created', 'last_updated', 'prefix_count',
]
validators = []
@@ -133,7 +140,8 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
# Prefixes
#
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
@@ -141,13 +149,12 @@ class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
vlan = NestedVLANSerializer(required=False, allow_null=True)
status = ChoiceField(choices=PrefixStatusChoices, required=False)
role = NestedRoleSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False)
class Meta:
model = Prefix
fields = [
'id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description',
'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool',
'description', 'tags', 'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']
@@ -203,48 +210,38 @@ class AvailablePrefixSerializer(serializers.Serializer):
# IP addresses
#
class IPAddressInterfaceSerializer(WritableNestedSerializer):
"""
Nested representation of an Interface which may belong to a Device *or* a VirtualMachine.
"""
url = serializers.SerializerMethodField() # We're imitating a HyperlinkedIdentityField here
device = NestedDeviceSerializer(read_only=True)
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
class Meta:
model = Interface
fields = [
'id', 'url', 'device', 'virtual_machine', 'name',
]
def get_url(self, obj):
"""
Return a link to the Interface via either the DCIM API if the parent is a Device, or via the virtualization API
if the parent is a VirtualMachine.
"""
url_name = 'dcim-api:interface-detail' if obj.device else 'virtualization-api:interface-detail'
return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request'])
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPAddressStatusChoices, required=False)
role = ChoiceField(choices=IPAddressRoleChoices, allow_blank=True, required=False)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
assigned_object_type = ContentTypeField(
queryset=ContentType.objects.filter(IPADDRESS_ASSIGNMENT_MODELS),
required=False
)
assigned_object = serializers.SerializerMethodField(read_only=True)
nat_inside = NestedIPAddressSerializer(required=False, allow_null=True)
nat_outside = NestedIPAddressSerializer(read_only=True)
tags = TagListSerializerField(required=False)
class Meta:
model = IPAddress
fields = [
'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside',
'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type',
'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]
read_only_fields = ['family']
@swagger_serializer_method(serializer_or_field=serializers.DictField)
def get_assigned_object(self, obj):
if obj.assigned_object is None:
return None
serializer = get_serializer_for_model(obj.assigned_object, prefix='Nested')
context = {'request': self.context['request']}
return serializer(obj.assigned_object, context=context).data
class AvailableIPSerializer(serializers.Serializer):
"""
@@ -270,7 +267,8 @@ class AvailableIPSerializer(serializers.Serializer):
# Services
#
class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True)
virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True)
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
@@ -280,11 +278,10 @@ class ServiceSerializer(TaggitSerializer, CustomFieldModelSerializer):
required=False,
many=True
)
tags = TagListSerializerField(required=False)
class Meta:
model = Service
fields = [
'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
'id', 'url', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'tags',
'custom_fields', 'created', 'last_updated',
]

View File

@@ -1,11 +1,10 @@
from django.conf import settings
from django.db.models import Count
from django.db.models import Count, Prefetch
from django.shortcuts import get_object_or_404
from django_pglocks import advisory_lock
from drf_yasg.utils import swagger_auto_schema
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response
from extras.api.views import CustomFieldModelViewSet
@@ -25,7 +24,7 @@ class VRFViewSet(CustomFieldModelViewSet):
queryset = VRF.objects.prefetch_related('tenant').prefetch_related('tags').annotate(
ipaddress_count=get_subquery(IPAddress, 'vrf'),
prefix_count=get_subquery(Prefix, 'vrf')
)
).order_by(*VRF._meta.ordering)
serializer_class = serializers.VRFSerializer
filterset_class = filters.VRFFilterSet
@@ -37,7 +36,7 @@ class VRFViewSet(CustomFieldModelViewSet):
class RIRViewSet(ModelViewSet):
queryset = RIR.objects.annotate(
aggregate_count=Count('aggregates')
)
).order_by(*RIR._meta.ordering)
serializer_class = serializers.RIRSerializer
filterset_class = filters.RIRFilterSet
@@ -60,7 +59,7 @@ class RoleViewSet(ModelViewSet):
queryset = Role.objects.annotate(
prefix_count=get_subquery(Prefix, 'role'),
vlan_count=get_subquery(VLAN, 'role')
)
).order_by(*Role._meta.ordering)
serializer_class = serializers.RoleSerializer
filterset_class = filters.RoleFilterSet
@@ -90,7 +89,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(Prefix, pk=pk)
prefix = get_object_or_404(self.queryset, pk=pk)
available_prefixes = prefix.get_available_prefixes()
if request.method == 'POST':
@@ -169,7 +168,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
The advisory lock decorator uses a PostgreSQL advisory lock to prevent this API from being
invoked in parallel, which results in a race condition where multiple insertions can occur.
"""
prefix = get_object_or_404(Prefix, pk=pk)
prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk)
# Create the next available IP within the prefix
if request.method == 'POST':
@@ -239,8 +238,7 @@ class PrefixViewSet(CustomFieldModelViewSet):
class IPAddressViewSet(CustomFieldModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'interface__device__device_type', 'interface__virtual_machine',
'nat_outside', 'tags',
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags',
)
serializer_class = serializers.IPAddressSerializer
filterset_class = filters.IPAddressFilterSet
@@ -253,7 +251,7 @@ class IPAddressViewSet(CustomFieldModelViewSet):
class VLANGroupViewSet(ModelViewSet):
queryset = VLANGroup.objects.prefetch_related('site').annotate(
vlan_count=Count('vlans')
)
).order_by(*VLANGroup._meta.ordering)
serializer_class = serializers.VLANGroupSerializer
filterset_class = filters.VLANGroupFilterSet
@@ -267,7 +265,7 @@ class VLANViewSet(CustomFieldModelViewSet):
'site', 'group', 'tenant', 'role', 'tags'
).annotate(
prefix_count=get_subquery(Prefix, 'vlan')
)
).order_by(*VLAN._meta.ordering)
serializer_class = serializers.VLANSerializer
filterset_class = filters.VLANFilterSet
@@ -277,6 +275,8 @@ class VLANViewSet(CustomFieldModelViewSet):
#
class ServiceViewSet(ModelViewSet):
queryset = Service.objects.prefetch_related('device').prefetch_related('tags')
queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses'
)
serializer_class = serializers.ServiceSerializer
filterset_class = filters.ServiceFilterSet

View File

@@ -30,13 +30,6 @@ class PrefixStatusChoices(ChoiceSet):
(STATUS_DEPRECATED, 'Deprecated'),
)
LEGACY_MAP = {
STATUS_CONTAINER: 0,
STATUS_ACTIVE: 1,
STATUS_RESERVED: 2,
STATUS_DEPRECATED: 3,
}
#
# IPAddresses
@@ -56,13 +49,6 @@ class IPAddressStatusChoices(ChoiceSet):
(STATUS_DHCP, 'DHCP'),
)
LEGACY_MAP = {
STATUS_ACTIVE: 1,
STATUS_RESERVED: 2,
STATUS_DEPRECATED: 3,
STATUS_DHCP: 5,
}
class IPAddressRoleChoices(ChoiceSet):
@@ -86,17 +72,6 @@ class IPAddressRoleChoices(ChoiceSet):
(ROLE_CARP, 'CARP'),
)
LEGACY_MAP = {
ROLE_LOOPBACK: 10,
ROLE_SECONDARY: 20,
ROLE_ANYCAST: 30,
ROLE_VIP: 40,
ROLE_VRRP: 41,
ROLE_HSRP: 42,
ROLE_GLBP: 43,
ROLE_CARP: 44,
}
#
# VLANs
@@ -114,12 +89,6 @@ class VLANStatusChoices(ChoiceSet):
(STATUS_DEPRECATED, 'Deprecated'),
)
LEGACY_MAP = {
STATUS_ACTIVE: 1,
STATUS_RESERVED: 2,
STATUS_DEPRECATED: 3,
}
#
# Services
@@ -134,8 +103,3 @@ class ServiceProtocolChoices(ChoiceSet):
(PROTOCOL_TCP, 'TCP'),
(PROTOCOL_UDP, 'UDP'),
)
LEGACY_MAP = {
PROTOCOL_TCP: 6,
PROTOCOL_UDP: 17,
}

View File

@@ -1,3 +1,5 @@
from django.db.models import Q
from .choices import IPAddressRoleChoices
# BGP ASN bounds
@@ -29,6 +31,11 @@ PREFIX_LENGTH_MAX = 127 # IPv6
# IPAddresses
#
IPADDRESS_ASSIGNMENT_MODELS = Q(
Q(app_label='dcim', model='interface') |
Q(app_label='virtualization', model='vminterface')
)
IPADDRESS_MASK_LENGTH_MIN = 1
IPADDRESS_MASK_LENGTH_MAX = 128 # IPv6

View File

@@ -11,7 +11,7 @@ from utilities.filters import (
BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, TagFilter,
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -309,27 +309,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
field_name='pk',
label='Device (ID)',
)
virtual_machine_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine',
queryset=VirtualMachine.objects.all(),
label='Virtual machine (ID)',
)
virtual_machine = django_filters.ModelMultipleChoiceFilter(
field_name='interface__virtual_machine__name',
queryset=VirtualMachine.objects.all(),
to_field_name='name',
virtual_machine = MultiValueCharFilter(
method='filter_virtual_machine',
field_name='name',
label='Virtual machine (name)',
)
virtual_machine_id = MultiValueNumberFilter(
method='filter_virtual_machine',
field_name='pk',
label='Virtual machine (ID)',
)
interface = django_filters.ModelMultipleChoiceFilter(
field_name='interface__name',
queryset=Interface.objects.all(),
to_field_name='name',
label='Interface (ID)',
label='Interface (name)',
)
interface_id = django_filters.ModelMultipleChoiceFilter(
field_name='interface',
queryset=Interface.objects.all(),
label='Interface (ID)',
)
vminterface = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface__name',
queryset=VMInterface.objects.all(),
to_field_name='name',
label='VM interface (name)',
)
vminterface_id = django_filters.ModelMultipleChoiceFilter(
field_name='vminterface',
queryset=VMInterface.objects.all(),
label='VM interface (ID)',
)
assigned_to_interface = django_filters.BooleanFilter(
method='_assigned_to_interface',
label='Is assigned to an interface',
@@ -379,17 +390,29 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet,
return queryset.filter(address__net_mask_length=value)
def filter_device(self, queryset, name, value):
try:
devices = Device.objects.prefetch_related('device_type').filter(**{'{}__in'.format(name): value})
vc_interface_ids = []
for device in devices:
vc_interface_ids.extend([i['id'] for i in device.vc_interfaces.values('id')])
return queryset.filter(interface_id__in=vc_interface_ids)
except Device.DoesNotExist:
devices = Device.objects.filter(**{'{}__in'.format(name): value})
if not devices.exists():
return queryset.none()
interface_ids = []
for device in devices:
interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(
interface__in=interface_ids
)
def filter_virtual_machine(self, queryset, name, value):
virtual_machines = VirtualMachine.objects.filter(**{'{}__in'.format(name): value})
if not virtual_machines.exists():
return queryset.none()
interface_ids = []
for vm in virtual_machines:
interface_ids.extend(vm.interfaces.values_list('id', flat=True))
return queryset.filter(
vminterface__in=interface_ids
)
def _assigned_to_interface(self, queryset, name, value):
return queryset.exclude(interface__isnull=value)
return queryset.exclude(assigned_object_id__isnull=value)
class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):

View File

@@ -1,11 +1,12 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
from dcim.models import Device, Interface, Rack, Region, Site
from extras.forms import (
AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
TagField,
)
from extras.models import Tag
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
@@ -14,7 +15,7 @@ from utilities.forms import (
ExpandableIPAddressField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
@@ -33,7 +34,8 @@ IPADDRESS_MASK_LENGTH_CHOICES = add_blank_choice([
#
class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@@ -141,7 +143,8 @@ class AggregateForm(BootstrapMixin, CustomFieldModelForm):
rir = DynamicModelChoiceField(
queryset=RIR.objects.all()
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@@ -292,7 +295,10 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(),
required=False
)
tags = TagField(required=False)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = Prefix
@@ -517,10 +523,33 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
#
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
interface = forms.ModelChoiceField(
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
widget=APISelect(
filter_for={
'interface': 'device_id'
}
)
)
interface = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
widget=APISelect(
filter_for={
'vminterface': 'virtual_machine_id'
}
)
)
vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
label='Interface'
)
vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(),
required=False,
@@ -584,15 +613,16 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
required=False,
label='Make this the primary IP for the device/VM'
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = IPAddress
fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent',
'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags',
'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'primary_for_parent', 'nat_site', 'nat_rack',
'nat_inside', 'tenant_group', 'tenant', 'tags',
]
widgets = {
'status': StaticSelect2(),
@@ -604,32 +634,26 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
# Initialize helper selectors
instance = kwargs.get('instance')
initial = kwargs.get('initial', {}).copy()
if instance and instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
if instance:
if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.device
initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VMInterface:
initial['virtual_machine'] = instance.assigned_object.virtual_machine
initial['vminterface'] = instance.assigned_object
if instance.nat_inside and instance.nat_inside.device is not None:
initial['nat_site'] = instance.nat_inside.device.site
initial['nat_rack'] = instance.nat_inside.device.rack
initial['nat_device'] = instance.nat_inside.device
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
self.fields['vrf'].empty_label = 'Global'
# Limit interface selections to those belonging to the parent device/VM
if self.instance and self.instance.interface:
self.fields['interface'].queryset = Interface.objects.filter(
device=self.instance.interface.device, virtual_machine=self.instance.interface.virtual_machine
).prefetch_related(
'device__primary_ip4',
'device__primary_ip6',
'virtual_machine__primary_ip4',
'virtual_machine__primary_ip6',
) # We prefetch the primary address fields to ensure cache invalidation does not balk on the save()
else:
self.fields['interface'].choices = []
# Initialize primary_for_parent if IP address is already assigned
if self.instance.pk and self.instance.interface is not None:
parent = self.instance.interface.parent
if self.instance.pk and self.instance.assigned_object:
parent = self.instance.assigned_object.parent
if (
self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or
self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk
@@ -639,32 +663,39 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
# Primary IP assignment is only available if an interface has been assigned.
if self.cleaned_data.get('primary_for_parent') and not self.cleaned_data.get('interface'):
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if self.cleaned_data.get('primary_for_parent') and not interface:
self.add_error(
'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs."
)
def save(self, *args, **kwargs):
# Set assigned object
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
if interface:
self.instance.assigned_object = interface
ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
if self.cleaned_data['primary_for_parent']:
parent = self.cleaned_data['interface'].parent
if interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4:
parent.primary_ip4 = ipaddress
interface.parent.primary_ip4 = ipaddress
else:
parent.primary_ip6 = ipaddress
parent.save()
elif self.cleaned_data['interface']:
parent = self.cleaned_data['interface'].parent
if ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.primary_ip4 = None
parent.save()
elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.primary_ip6 = None
parent.save()
interface.primary_ip6 = ipaddress
interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
interface.parent.primary_ip4 = None
interface.parent.save()
return ipaddress
@@ -681,7 +712,8 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
required=False,
label='VRF'
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@@ -735,7 +767,7 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
help_text='Parent VM of assigned interface (if any)'
)
interface = CSVModelChoiceField(
queryset=Interface.objects.all(),
queryset=Interface.objects.none(), # Can also refer to VMInterface
required=False,
to_field_name='name',
help_text='Assigned interface'
@@ -754,21 +786,17 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
if data:
# Limit interface queryset by assigned device or virtual machine
# Limit interface queryset by assigned device
if data.get('device'):
params = {
f"device__{self.fields['device'].to_field_name}": data.get('device')
}
self.fields['interface'].queryset = Interface.objects.filter(
**{f"device__{self.fields['device'].to_field_name}": data['device']}
)
# Limit interface queryset by assigned device
elif data.get('virtual_machine'):
params = {
f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data.get('virtual_machine')
}
else:
params = {
'device': None,
'virtual_machine': None,
}
self.fields['interface'].queryset = self.fields['interface'].queryset.filter(**params)
self.fields['interface'].queryset = VMInterface.objects.filter(
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
)
def clean(self):
super().clean()
@@ -783,6 +811,10 @@ class IPAddressCSVForm(CustomFieldModelCSVForm):
def save(self, *args, **kwargs):
# Set interface assignment
if self.cleaned_data['interface']:
self.instance.assigned_object = self.cleaned_data['interface']
ipaddress = super().save(*args, **kwargs)
# Set as primary for device/VM
@@ -993,7 +1025,10 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
queryset=Role.objects.all(),
required=False
)
tags = TagField(required=False)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
class Meta:
model = VLAN
@@ -1165,7 +1200,8 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
min_value=SERVICE_PORT_MIN,
max_value=SERVICE_PORT_MAX
)
tags = TagField(
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
@@ -1188,13 +1224,12 @@ class ServiceForm(BootstrapMixin, CustomFieldModelForm):
# Limit IP address choices to those assigned to interfaces of the parent device/VM
if self.instance.device:
vc_interface_ids = [i['id'] for i in self.instance.device.vc_interfaces.values('id')]
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface_id__in=vc_interface_ids
interface__in=self.instance.device.vc_interfaces.values_list('id', flat=True)
)
elif self.instance.virtual_machine:
self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
interface__virtual_machine=self.instance.virtual_machine
vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
)
else:
self.fields['ipaddresses'].choices = []

View File

@@ -1,9 +1,10 @@
from django.db import models
from django.db.models import Manager
from ipam.lookups import Host, Inet
from utilities.querysets import RestrictedQuerySet
class IPAddressManager(models.Manager):
class IPAddressManager(Manager.from_queryset(RestrictedQuerySet)):
def get_queryset(self):
"""
@@ -13,5 +14,4 @@ class IPAddressManager(models.Manager):
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128.
"""
qs = super().get_queryset()
return qs.order_by(Inet(Host('address')))
return super().get_queryset().order_by(Inet(Host('address')))

View File

@@ -0,0 +1,40 @@
from django.db import migrations, models
import django.db.models.deletion
def set_assigned_object_type(apps, schema_editor):
ContentType = apps.get_model('contenttypes', 'ContentType')
IPAddress = apps.get_model('ipam', 'IPAddress')
device_ct = ContentType.objects.get(app_label='dcim', model='interface').pk
IPAddress.objects.update(assigned_object_type=device_ct)
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('ipam', '0036_standardize_description'),
]
operations = [
migrations.RenameField(
model_name='ipaddress',
old_name='interface',
new_name='assigned_object_id',
),
migrations.AlterField(
model_name='ipaddress',
name='assigned_object_id',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='ipaddress',
name='assigned_object_type',
field=models.ForeignKey(blank=True, limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType'),
preserve_default=False,
),
migrations.RunPython(
code=set_assigned_object_type
),
]

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