Compare commits

...

87 Commits

Author SHA1 Message Date
Jeremy Stretch
125975832b Merge pull request #2478 from digitalocean/develop
Release v2.4.5
2018-10-02 15:29:13 -04:00
Jeremy Stretch
20fed375d1 Release v2.4.5 2018-10-02 15:24:42 -04:00
Jeremy Stretch
fc1b3d6927 Fixes #2471: Fix ReadTheDocs theme 2018-10-02 11:51:53 -04:00
Jeremy Stretch
aed2a3cd1b Closes #2438: API optimizations for tagged objects 2018-09-28 16:44:05 -04:00
Jeremy Stretch
15babeb584 Fixes #2414: Tags field missing from device/VM component creation forms 2018-09-28 16:26:08 -04:00
Jeremy Stretch
020b5ea870 Fixes #2470: Log the creation of device/VM components as object changes 2018-09-28 16:04:51 -04:00
Jeremy Stretch
2ee5b2344e Changelog and misc cleanup 2018-09-28 14:21:49 -04:00
Jeremy Stretch
7616bcad3d Merge pull request #2445 from digitalocean/local-config-context
Local config context
2018-09-28 14:03:28 -04:00
John Anderson
f76ce980e3 remove templates no longer needed for local config context 2018-09-26 10:30:34 -04:00
Jeremy Stretch
9440ac7640 Fixes #2455: Ignore unique address enforcement for IPs with a shared/virtual role 2018-09-24 16:59:33 -04:00
Jeremy Stretch
0e18997c79 Merge pull request #2446 from hellerve/patch-1
docs: typo fix in devices
2018-09-19 09:47:46 -04:00
Veit Heller
95464772ac docs: typo fix in devices 2018-09-19 10:57:09 +02:00
Jeremy Stretch
b4445dfdf8 Fixes #2442: Nullify "next" link in API when limit=0 is passed 2018-09-18 13:59:50 -04:00
John Anderson
fb5dca2711 Merge branch 'develop' of github.com:digitalocean/netbox into local-config-context 2018-09-18 12:16:07 -04:00
Jeremy Stretch
6cdff955dc Fixes #2444: Improve validation of interface MAC addresses 2018-09-18 12:02:59 -04:00
John Anderson
4039753b2f refactored UI for local config context 2018-09-18 11:52:12 -04:00
Jeremy Stretch
9df33cef8b Fixes #2443: Enforce JSON object format when creating config contexts 2018-09-18 11:46:22 -04:00
John Anderson
e3e9211e8a PEP8 fix 2018-09-16 00:30:51 -04:00
John Anderson
0da113b723 implemnted #2392 - local config context for devices and VMs 2018-09-16 00:25:20 -04:00
John Anderson
e965adad7c changelog for #2432 2018-09-15 17:25:50 -04:00
John Anderson
57b225b680 fixes #2423 - interface connection links 2018-09-15 17:23:58 -04:00
Jeremy Stretch
b97597c645 Merge pull request #2421 from sieben/docs_community
Add content about related projects
2018-09-13 12:29:17 -04:00
Rémy Léone
162828da90 Add a page related to community related projects 2018-09-13 17:54:13 +02:00
Jeremy Stretch
292647da14 Closes #2402: Order and format JSON data in form fields 2018-09-13 11:31:34 -04:00
Jeremy Stretch
3a88e43103 Fixes #2406: Remove hard-coded limit of 1000 objects from API-populated form fields 2018-09-13 11:21:40 -04:00
Jeremy Stretch
010765e131 Post-release version bump 2018-08-22 11:55:51 -04:00
Jeremy Stretch
bcf22831e2 Merge pull request #2387 from digitalocean/develop
Release v2.4.4
2018-08-22 11:53:56 -04:00
Jeremy Stretch
cde6e9757b Release v2.4.4 2018-08-22 11:51:15 -04:00
Jeremy Stretch
f2d9a3e0a1 Added note about CHANGELOG to release checklist 2018-08-22 11:50:25 -04:00
Jeremy Stretch
b917e8d3b0 #2376: Add libapache2-mod-wsgi-py3 to CentOS installation section 2018-08-22 11:46:13 -04:00
Jeremy Stretch
3b26ce6501 Merge pull request #2386 from digitalocean/revert-2376-patch-1
Revert "Add missing library"
2018-08-22 11:44:31 -04:00
Jeremy Stretch
1b2d3bf08b Revert "Add missing library" 2018-08-22 11:44:07 -04:00
Jeremy Stretch
492bc9f86e Merge pull request #2376 from craig/patch-1
Add missing library
2018-08-22 11:43:46 -04:00
Jeremy Stretch
a457a73826 Merge pull request #2382 from consentfactory/develop
Fixed typo for supervisorctl
2018-08-22 11:41:12 -04:00
Jeremy Stretch
ac36339491 Closes #2168: Added Extreme SummitStack interface form factors 2018-08-22 11:33:43 -04:00
Jeremy Stretch
dbbf7ab664 Fixes #2353: Handle DoesNotExist exception when deleting a device with connected interfaces 2018-08-22 10:35:56 -04:00
Jeremy Stretch
66400a98f1 Fixes #2354: Increased maximum MTU for interfaces to 65536 bytes 2018-08-22 10:25:07 -04:00
Jeremy Stretch
aa50e2e385 Fixes #2378: Corrected "edit" link for virtual machine interfaces 2018-08-22 10:06:01 -04:00
Jimmy Taylor
118b8db209 Fixed typo for supervisorctl 2018-08-21 08:28:23 -06:00
Craig
967feb6931 Add missing library
WSGIPassAuthorization fails if libapache2-mod-wsgi-py3 is missing
2018-08-21 00:41:29 +02:00
Jeremy Stretch
e1e41a768a Fixes #2369: Corrected time zone validation on site API serializer 2018-08-20 16:53:23 -04:00
Jeremy Stretch
c333af33dc Fixes #2370: Redirect to parent device after deleting device bays 2018-08-20 14:40:19 -04:00
Jeremy Stretch
9e5b482b1d Fixes #2374: Fix toggling display of IP addresses in virtual machine interfaces list 2018-08-20 13:49:15 -04:00
John Anderson
771747147c #2254 changelog entry 2018-08-17 18:41:58 -04:00
John Anderson
bc49979243 added rack group search #2254 2018-08-17 18:37:48 -04:00
Jeremy Stretch
d46b3e2446 #2368: Append changelog 2018-08-17 14:32:51 -04:00
Jeremy Stretch
2804d89c5e Fixes #2368: Record change in device changelog when altering cluster assignment 2018-08-17 14:26:50 -04:00
Jeremy Stretch
fd32a71131 Rename changelog 2018-08-16 16:29:12 -04:00
Jeremy Stretch
1556fd0e92 Added a release changelog 2018-08-16 16:27:41 -04:00
Jeremy Stretch
5dce7c4e48 Closes #2356: Include cluster site as read-only field in VirtualMachine serializer 2018-08-16 11:57:20 -04:00
Jeremy Stretch
4bfc32ec99 Closes #2355: Added item count to inventory tab on device view 2018-08-16 10:20:22 -04:00
Jeremy Stretch
ff65f7fd7b Fixes #2362: Implemented custom admin site to properly handle BASE_PATH 2018-08-16 09:44:00 -04:00
Jeremy Stretch
cd2aee3053 Post-release version bump 2018-08-09 16:41:11 -04:00
Jeremy Stretch
f224ad2959 Merge pull request #2346 from digitalocean/develop
Release v2.4.3
2018-08-09 16:39:45 -04:00
Jeremy Stretch
9d9318f38a Corrected typo 2018-08-09 16:37:58 -04:00
Jeremy Stretch
f43d861b50 Release v2.4.3 2018-08-09 16:36:23 -04:00
Jeremy Stretch
17714b0c12 Fixes #2342: IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM 2018-08-09 16:34:17 -04:00
Jeremy Stretch
9914576eaa Fixes #2344: AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site 2018-08-09 15:46:18 -04:00
Jeremy Stretch
bf8eff11ea Closes #2333: Added search filters for ConfigContexts 2018-08-09 12:22:34 -04:00
Jeremy Stretch
a6c78b99c4 Fixes #2340: API requires manufacturer field when creating/updating an inventory item 2018-08-09 09:34:54 -04:00
Jeremy Stretch
6a56ffc650 Fixes #2337: Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError 2018-08-08 16:16:49 -04:00
Jeremy Stretch
05059606c5 Fixes #2336: Bulk deleting power outlets and console server ports from a device redirects to home page 2018-08-08 15:22:26 -04:00
Jeremy Stretch
a2ff21fab9 Fixes #2334: TypeError raised when WritableNestedSerializer receives a non-integer value 2018-08-08 15:09:30 -04:00
Jeremy Stretch
134370f48d Fixes #2335: API requires group field when creating/updating a rack 2018-08-08 14:58:16 -04:00
Jeremy Stretch
c7fa610842 Post-release version bump 2018-08-08 09:19:33 -04:00
Jeremy Stretch
242cb7c7cb Merge pull request #2332 from digitalocean/develop
Release v2.4.2
2018-08-08 09:16:50 -04:00
Jeremy Stretch
edb49c7f0a Release v2.4.2 2018-08-08 09:12:10 -04:00
Jeremy Stretch
3e0a7e7f8a Added tip about exlcuding the changelog when exporting the database 2018-08-08 09:04:48 -04:00
Jeremy Stretch
cfab9a6a0a Fixes #2330: Incorrect tab link in VRF changelog view 2018-08-08 08:49:23 -04:00
Jeremy Stretch
91b5f6d799 Fixes #2323: DoesNotExist raised when deleting devices or virtual machines 2018-08-07 17:30:26 -04:00
Jeremy Stretch
d5488ca7da Fixes #2322: Webhooks firing on non-enabled event types 2018-08-07 15:41:31 -04:00
Jeremy Stretch
f9911bff0d Added a "view all" link to the changelog panel 2018-08-07 15:19:01 -04:00
Jeremy Stretch
d5239191fe Fixes #2320: TypeError when dispatching a webhook with a secret key configured 2018-08-07 14:19:46 -04:00
Jeremy Stretch
db7148350e Fixes #2321: Allow explicitly setting a null value on nullable ChoiceFields 2018-08-07 14:05:07 -04:00
Jeremy Stretch
c51c20a301 Fixes #2319: Extend ChoiceField to properly handle true/false choice keys 2018-08-07 13:48:29 -04:00
Jeremy Stretch
f4485dc72a Restore reports directory 2018-08-07 13:47:36 -04:00
Jeremy Stretch
f59682a7c9 Fixes #2318: ImportError when viewing a report 2018-08-07 12:10:14 -04:00
Jeremy Stretch
507a023f41 Post-release version bump 2018-08-07 09:26:17 -04:00
Jeremy Stretch
ea7386b04b Merge pull request #2316 from digitalocean/develop
Release v2.4.1
2018-08-07 09:25:10 -04:00
Jeremy Stretch
81479ac177 Release v2.4.1 2018-08-07 09:23:49 -04:00
Jeremy Stretch
c7acddbc5c Fixes #2312: Running a report yields a ValueError exception 2018-08-07 09:12:05 -04:00
Jeremy Stretch
1905516536 Fixes #2314: Serialized representation of object in change log does not incldue assigned tags 2018-08-07 08:52:57 -04:00
Jeremy Stretch
64f34d9cd7 Fixes #2311: Redirect to parent after editing interface from device/VM view 2018-08-07 08:46:41 -04:00
Jeremy Stretch
98bdb0cb3c Fixes #2310: False validation error on certain nested serializers 2018-08-06 17:40:45 -04:00
Jeremy Stretch
bba88b2be4 Fixes #2303: Always redirect to parent object when bulk editing/deleting components 2018-08-06 14:14:40 -04:00
Jeremy Stretch
12dfd4b6e0 Fixes #2308: Custom fields panel absent from object view in UI 2018-08-06 13:32:52 -04:00
Jeremy Stretch
209e721219 Post-release version bump 2018-08-06 12:45:46 -04:00
75 changed files with 2394 additions and 264 deletions

1675
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -42,3 +42,18 @@ and run `upgrade.sh`.
* [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine)) * [Docker container](https://github.com/ninech/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle)) * [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae)) * [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
# Related projects
## Supported SDK
- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox.
## Community SDK
- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2.
## Ansible Inventory
- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox.

View File

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

View File

@@ -7,10 +7,18 @@ NetBox uses [PostgreSQL](https://www.postgresql.org/) for its database, so gener
## Export the Database ## Export the Database
Use the `pg_dump` utility to export the entire database to a file:
```no-highlight ```no-highlight
pg_dump netbox > netbox.sql pg_dump netbox > netbox.sql
``` ```
When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data.
```no-highlight
pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql
```
## Load an Exported Database ## Load an Exported Database
!!! warning !!! warning

View File

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

View File

@@ -99,7 +99,7 @@ Device bays represent the ability of a device to house child devices. For exampl
# Platforms # Platforms
A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15.
The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration. The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration.

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
site_name: NetBox site_name: NetBox
theme: readthedocs
repo_url: https://github.com/digitalocean/netbox repo_url: https://github.com/digitalocean/netbox
pages: pages:

View File

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

View File

@@ -120,10 +120,10 @@ class NestedRackRoleSerializer(WritableNestedSerializer):
class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackSerializer(TaggitSerializer, CustomFieldModelSerializer):
site = NestedSiteSerializer() site = NestedSiteSerializer()
group = NestedRackGroupSerializer(required=False, allow_null=True) group = NestedRackGroupSerializer(required=False, allow_null=True, default=None)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
role = NestedRackRoleSerializer(required=False, allow_null=True) role = NestedRackRoleSerializer(required=False, allow_null=True)
type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False) type = ChoiceField(choices=RACK_TYPE_CHOICES, required=False, allow_null=True)
width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@@ -223,7 +223,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer):
class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer()
interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False) interface_ordering = ChoiceField(choices=IFACE_ORDERING_CHOICES, required=False)
subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False) subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True)
instance_count = serializers.IntegerField(source='instances.count', read_only=True) instance_count = serializers.IntegerField(source='instances.count', read_only=True)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
@@ -362,7 +362,7 @@ class NestedPlatformSerializer(WritableNestedSerializer):
# #
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class DeviceIPAddressSerializer(serializers.ModelSerializer): class DeviceIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
@@ -371,7 +371,7 @@ class DeviceIPAddressSerializer(serializers.ModelSerializer):
# Cannot import virtualization.api.NestedClusterSerializer due to circular dependency # Cannot import virtualization.api.NestedClusterSerializer due to circular dependency
class NestedClusterSerializer(serializers.ModelSerializer): class NestedClusterSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail')
class Meta: class Meta:
@@ -380,7 +380,7 @@ class NestedClusterSerializer(serializers.ModelSerializer):
# Cannot import NestedVirtualChassisSerializer due to circular dependency # Cannot import NestedVirtualChassisSerializer due to circular dependency
class DeviceVirtualChassisSerializer(serializers.ModelSerializer): class DeviceVirtualChassisSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail')
master = NestedDeviceSerializer() master = NestedDeviceSerializer()
@@ -396,7 +396,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
platform = NestedPlatformSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True)
site = NestedSiteSerializer() site = NestedSiteSerializer()
rack = NestedRackSerializer(required=False, allow_null=True) rack = NestedRackSerializer(required=False, allow_null=True)
face = ChoiceField(choices=RACK_FACE_CHOICES, required=False) face = ChoiceField(choices=RACK_FACE_CHOICES, required=False, allow_null=True)
status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False) status = ChoiceField(choices=DEVICE_STATUS_CHOICES, required=False)
primary_ip = DeviceIPAddressSerializer(read_only=True) primary_ip = DeviceIPAddressSerializer(read_only=True)
primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True) primary_ip4 = DeviceIPAddressSerializer(required=False, allow_null=True)
@@ -412,7 +412,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', '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', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'created',
'last_updated', 'last_updated', 'local_context_data',
] ]
validators = [] validators = []
@@ -448,7 +448,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
'id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', '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', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6',
'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'custom_fields',
'config_context', 'created', 'last_updated', 'config_context', 'created', 'last_updated', 'local_context_data',
] ]
def get_config_context(self, obj): def get_config_context(self, obj):
@@ -576,7 +576,7 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
is_connected = serializers.SerializerMethodField(read_only=True) is_connected = serializers.SerializerMethodField(read_only=True)
interface_connection = serializers.SerializerMethodField(read_only=True) interface_connection = serializers.SerializerMethodField(read_only=True)
circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True) circuit_termination = InterfaceCircuitTerminationSerializer(read_only=True)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@@ -666,7 +666,7 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
# Provide a default value to satisfy UniqueTogetherValidator # Provide a default value to satisfy UniqueTogetherValidator
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
manufacturer = NestedManufacturerSerializer() manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)
class Meta: class Meta:

View File

@@ -3,7 +3,7 @@ from __future__ import unicode_literals
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.http import HttpResponseBadRequest, HttpResponseForbidden from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
from drf_yasg.openapi import Parameter from drf_yasg.openapi import Parameter
@@ -60,7 +60,7 @@ class RegionViewSet(ModelViewSet):
# #
class SiteViewSet(CustomFieldModelViewSet): class SiteViewSet(CustomFieldModelViewSet):
queryset = Site.objects.select_related('region', 'tenant') queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags')
serializer_class = serializers.SiteSerializer serializer_class = serializers.SiteSerializer
filter_class = filters.SiteFilter filter_class = filters.SiteFilter
@@ -100,7 +100,7 @@ class RackRoleViewSet(ModelViewSet):
# #
class RackViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet):
queryset = Rack.objects.select_related('site', 'group__site', 'tenant') queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags')
serializer_class = serializers.RackSerializer serializer_class = serializers.RackSerializer
filter_class = filters.RackFilter filter_class = filters.RackFilter
@@ -154,7 +154,7 @@ class ManufacturerViewSet(ModelViewSet):
# #
class DeviceTypeViewSet(CustomFieldModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet):
queryset = DeviceType.objects.select_related('manufacturer') queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags')
serializer_class = serializers.DeviceTypeSerializer serializer_class = serializers.DeviceTypeSerializer
filter_class = filters.DeviceTypeFilter filter_class = filters.DeviceTypeFilter
@@ -228,7 +228,7 @@ class DeviceViewSet(CustomFieldModelViewSet):
'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay',
'virtual_chassis__master', 'virtual_chassis__master',
).prefetch_related( ).prefetch_related(
'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags',
) )
filter_class = filters.DeviceFilter filter_class = filters.DeviceFilter
@@ -315,31 +315,31 @@ class DeviceViewSet(CustomFieldModelViewSet):
# #
class ConsolePortViewSet(ModelViewSet): class ConsolePortViewSet(ModelViewSet):
queryset = ConsolePort.objects.select_related('device', 'cs_port__device') queryset = ConsolePort.objects.select_related('device', 'cs_port__device').prefetch_related('tags')
serializer_class = serializers.ConsolePortSerializer serializer_class = serializers.ConsolePortSerializer
filter_class = filters.ConsolePortFilter filter_class = filters.ConsolePortFilter
class ConsoleServerPortViewSet(ModelViewSet): class ConsoleServerPortViewSet(ModelViewSet):
queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device') queryset = ConsoleServerPort.objects.select_related('device', 'connected_console__device').prefetch_related('tags')
serializer_class = serializers.ConsoleServerPortSerializer serializer_class = serializers.ConsoleServerPortSerializer
filter_class = filters.ConsoleServerPortFilter filter_class = filters.ConsoleServerPortFilter
class PowerPortViewSet(ModelViewSet): class PowerPortViewSet(ModelViewSet):
queryset = PowerPort.objects.select_related('device', 'power_outlet__device') queryset = PowerPort.objects.select_related('device', 'power_outlet__device').prefetch_related('tags')
serializer_class = serializers.PowerPortSerializer serializer_class = serializers.PowerPortSerializer
filter_class = filters.PowerPortFilter filter_class = filters.PowerPortFilter
class PowerOutletViewSet(ModelViewSet): class PowerOutletViewSet(ModelViewSet):
queryset = PowerOutlet.objects.select_related('device', 'connected_port__device') queryset = PowerOutlet.objects.select_related('device', 'connected_port__device').prefetch_related('tags')
serializer_class = serializers.PowerOutletSerializer serializer_class = serializers.PowerOutletSerializer
filter_class = filters.PowerOutletFilter filter_class = filters.PowerOutletFilter
class InterfaceViewSet(ModelViewSet): class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.select_related('device') queryset = Interface.objects.select_related('device').prefetch_related('tags')
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filter_class = filters.InterfaceFilter filter_class = filters.InterfaceFilter
@@ -355,13 +355,13 @@ class InterfaceViewSet(ModelViewSet):
class DeviceBayViewSet(ModelViewSet): class DeviceBayViewSet(ModelViewSet):
queryset = DeviceBay.objects.select_related('installed_device') queryset = DeviceBay.objects.select_related('installed_device').prefetch_related('tags')
serializer_class = serializers.DeviceBaySerializer serializer_class = serializers.DeviceBaySerializer
filter_class = filters.DeviceBayFilter filter_class = filters.DeviceBayFilter
class InventoryItemViewSet(ModelViewSet): class InventoryItemViewSet(ModelViewSet):
queryset = InventoryItem.objects.select_related('device', 'manufacturer') queryset = InventoryItem.objects.select_related('device', 'manufacturer').prefetch_related('tags')
serializer_class = serializers.InventoryItemSerializer serializer_class = serializers.InventoryItemSerializer
filter_class = filters.InventoryItemFilter filter_class = filters.InventoryItemFilter
@@ -393,7 +393,7 @@ class InterfaceConnectionViewSet(ModelViewSet):
# #
class VirtualChassisViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet):
queryset = VirtualChassis.objects.all() queryset = VirtualChassis.objects.prefetch_related('tags')
serializer_class = serializers.VirtualChassisSerializer serializer_class = serializers.VirtualChassisSerializer

View File

@@ -93,6 +93,11 @@ IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100 IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150 IFACE_FF_FLEXSTACK_PLUS = 5150
IFACE_FF_JUNIPER_VCP = 5200 IFACE_FF_JUNIPER_VCP = 5200
IFACE_FF_SUMMITSTACK = 5300
IFACE_FF_SUMMITSTACK128 = 5310
IFACE_FF_SUMMITSTACK256 = 5320
IFACE_FF_SUMMITSTACK512 = 5330
# Other # Other
IFACE_FF_OTHER = 32767 IFACE_FF_OTHER = 32767
@@ -168,6 +173,10 @@ IFACE_FF_CHOICES = [
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
[IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
[IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
[IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
[IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
] ]
], ],
[ [

View File

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

View File

@@ -112,6 +112,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackGroupFilter(django_filters.FilterSet): class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@@ -127,6 +131,15 @@ class RackGroupFilter(django_filters.FilterSet):
model = RackGroup model = RackGroup
fields = ['site_id', 'name', 'slug'] fields = ['site_id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(slug__icontains=value)
)
return queryset.filter(qs_filter)
class RackRoleFilter(django_filters.FilterSet): class RackRoleFilter(django_filters.FilterSet):

View File

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

View File

@@ -18,7 +18,7 @@ from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, AnnotatedMultipleChoiceField, APISelect, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm,
BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField,
FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField, FlexibleModelChoiceField, JSONField, Livesearch, SelectWithDisabled, SelectWithPK, SmallTextarea, SlugField,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from .constants import ( from .constants import (
@@ -27,7 +27,6 @@ from .constants import (
RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, RACK_WIDTH_19IN, RACK_WIDTH_23IN, SITE_STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES, SUBDEVICE_ROLE_PARENT, SUBDEVICE_ROLE_CHOICES,
) )
from .formfields import MACAddressFormField
from .models import ( from .models import (
DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate,
Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, InventoryItem,
@@ -823,16 +822,19 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
comments = CommentField() comments = CommentField()
tags = TagField(required=False) tags = TagField(required=False)
local_context_data = JSONField(required=False)
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face',
'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags',
'local_context_data'
] ]
help_texts = { help_texts = {
'device_role': "The function this device serves", 'device_role': "The function this device serves",
'serial': "Chassis serial number", 'serial': "Chassis serial number",
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context"
} }
widgets = { widgets = {
'face': forms.Select(attrs={'filter-for': 'position'}), 'face': forms.Select(attrs={'filter-for': 'position'}),
@@ -1192,6 +1194,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
class ConsolePortCreateForm(ComponentForm): class ConsolePortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class ConsoleConnectionCSVForm(forms.ModelForm): class ConsoleConnectionCSVForm(forms.ModelForm):
@@ -1362,6 +1365,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
class ConsoleServerPortCreateForm(ComponentForm): class ConsoleServerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -1459,6 +1463,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
class PowerPortCreateForm(ComponentForm): class PowerPortCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PowerConnectionCSVForm(forms.ModelForm): class PowerConnectionCSVForm(forms.ModelForm):
@@ -1629,6 +1634,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
class PowerOutletCreateForm(ComponentForm): class PowerOutletCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form):
@@ -1795,7 +1801,7 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
# Compile VLAN choices # Compile VLAN choices
vlan_choices = [] vlan_choices = []
# Add global VLANs # Add non-grouped global VLANs
global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans) global_vlans = VLAN.objects.filter(site=None, group=None).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'Global', [(vlan.pk, vlan) for vlan in global_vlans]) 'Global', [(vlan.pk, vlan) for vlan in global_vlans])
@@ -1808,16 +1814,15 @@ class InterfaceAssignVLANsForm(BootstrapMixin, forms.ModelForm):
(group.name, [(vlan.pk, vlan) for vlan in global_group_vlans]) (group.name, [(vlan.pk, vlan) for vlan in global_group_vlans])
) )
parent = self.instance.parent site = getattr(self.instance.parent, 'site', None)
if parent is not None: if site is not None:
# Add site VLANs # Add non-grouped site VLANs
if parent.site: site_vlans = VLAN.objects.filter(site=site, group=None).exclude(pk__in=assigned_vlans)
site_vlans = VLAN.objects.filter(site=parent.site, group=None).exclude(pk__in=assigned_vlans) vlan_choices.append((site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
vlan_choices.append((parent.site.name, [(vlan.pk, vlan) for vlan in site_vlans]))
# Add grouped site VLANs # Add grouped site VLANs
for group in VLANGroup.objects.filter(site=parent.site): for group in VLANGroup.objects.filter(site=site):
site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans) site_group_vlans = VLAN.objects.filter(group=group).exclude(pk__in=assigned_vlans)
vlan_choices.append(( vlan_choices.append((
'{} / {}'.format(group.site.name, group.name), '{} / {}'.format(group.site.name, group.name),
@@ -1855,7 +1860,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
enabled = forms.BooleanField(required=False) enabled = forms.BooleanField(required=False)
lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG') lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address') mac_address = forms.CharField(required=False, label='MAC Address')
mgmt_only = forms.BooleanField( mgmt_only = forms.BooleanField(
required=False, required=False,
label='OOB Management', label='OOB Management',
@@ -1863,6 +1868,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form):
) )
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False) mode = forms.ChoiceField(choices=add_blank_choice(IFACE_MODE_CHOICES), required=False)
tags = TagField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -2100,6 +2106,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
class DeviceBayCreateForm(ComponentForm): class DeviceBayCreateForm(ComponentForm):
name_pattern = ExpandableNameField(label='Name') name_pattern = ExpandableNameField(label='Name')
tags = TagField(required=False)
class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class PopulateDeviceBayForm(BootstrapMixin, forms.Form):

View File

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

View File

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

View File

@@ -7,10 +7,10 @@ from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.contrib.postgres.fields import ArrayField, JSONField from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Q, ObjectDoesNotExist from django.db.models import Count, Q
from django.urls import reverse from django.urls import reverse
from django.utils.encoding import python_2_unicode_compatible from django.utils.encoding import python_2_unicode_compatible
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
@@ -1809,9 +1809,10 @@ class Interface(ComponentModel):
blank=True, blank=True,
verbose_name='MAC Address' verbose_name='MAC Address'
) )
mtu = models.PositiveSmallIntegerField( mtu = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU' verbose_name='MTU'
) )
mgmt_only = models.BooleanField( mgmt_only = models.BooleanField(
@@ -1933,11 +1934,20 @@ class Interface(ComponentModel):
""" """
Include the connected Interface (if any). Include the connected Interface (if any).
""" """
# It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve
# the component parent will raise DoesNotExist. For more discussion, see
# https://github.com/digitalocean/netbox/issues/2323
try:
parent_obj = self.get_component_parent()
except ObjectDoesNotExist:
parent_obj = None
ObjectChange( ObjectChange(
user=user, user=user,
request_id=request_id, request_id=request_id,
changed_object=self, changed_object=self,
related_object=self.get_component_parent(), related_object=parent_obj,
action=action, action=action,
object_data=serialize_object(self, extra={ object_data=serialize_object(self, extra={
'connected_interface': self.connected_interface.pk if self.connection else None, 'connected_interface': self.connected_interface.pk if self.connection else None,
@@ -2062,6 +2072,7 @@ class InterfaceConnection(models.Model):
(self.interface_a, self.interface_b), (self.interface_a, self.interface_b),
(self.interface_b, self.interface_a), (self.interface_b, self.interface_a),
) )
for interface, peer_interface in interfaces: for interface, peer_interface in interfaces:
if action == OBJECTCHANGE_ACTION_DELETE: if action == OBJECTCHANGE_ACTION_DELETE:
connection_data = { connection_data = {
@@ -2072,11 +2083,17 @@ class InterfaceConnection(models.Model):
'connected_interface': peer_interface.pk, 'connected_interface': peer_interface.pk,
'connection_status': self.connection_status 'connection_status': self.connection_status
} }
try:
parent_obj = interface.parent
except ObjectDoesNotExist:
parent_obj = None
ObjectChange( ObjectChange(
user=user, user=user,
request_id=request_id, request_id=request_id,
changed_object=interface, changed_object=interface,
related_object=interface.parent, related_object=parent_obj,
action=OBJECTCHANGE_ACTION_UPDATE, action=OBJECTCHANGE_ACTION_UPDATE,
object_data=serialize_object(interface, extra=connection_data) object_data=serialize_object(interface, extra=connection_data)
).save() ).save()

View File

@@ -614,10 +614,12 @@ class PowerConnectionTable(BaseTable):
class InterfaceConnectionTable(BaseTable): class InterfaceConnectionTable(BaseTable):
device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'),
args=[Accessor('interface_a.device.pk')], verbose_name='Device A') args=[Accessor('interface_a.device.pk')], verbose_name='Device A')
interface_a = tables.Column(verbose_name='Interface A') interface_a = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_a'),
args=[Accessor('interface_a.pk')], verbose_name='Interface A')
device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'),
args=[Accessor('interface_b.device.pk')], verbose_name='Device B') args=[Accessor('interface_b.device.pk')], verbose_name='Device B')
interface_b = tables.Column(verbose_name='Interface B') interface_b = tables.LinkColumn('dcim:interface', accessor=Accessor('interface_b'),
args=[Accessor('interface_b.pk')], verbose_name='Interface B')
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InterfaceConnection model = InterfaceConnection

View File

@@ -1,6 +1,7 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from dcim.constants import ( from dcim.constants import (
@@ -13,9 +14,10 @@ from dcim.models import (
InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup,
RackReservation, RackRole, Region, Site, VirtualChassis, RackReservation, RackRole, Region, Site, VirtualChassis,
) )
from ipam.models import VLAN from ipam.models import IPAddress, VLAN
from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE
from utilities.testing import APITestCase from utilities.testing import APITestCase
from virtualization.models import Cluster, ClusterType
class RegionTest(APITestCase): class RegionTest(APITestCase):
@@ -1680,14 +1682,28 @@ class DeviceTest(APITestCase):
self.devicerole2 = DeviceRole.objects.create( self.devicerole2 = DeviceRole.objects.create(
name='Test Device Role 2', slug='test-device-role-2', color='00ff00' name='Test Device Role 2', slug='test-device-role-2', color='00ff00'
) )
cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='test-cluster-type-1')
self.cluster1 = Cluster.objects.create(name='Test Cluster 1', type=cluster_type)
self.device1 = Device.objects.create( self.device1 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 1', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 1',
site=self.site1,
cluster=self.cluster1
) )
self.device2 = Device.objects.create( self.device2 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 2', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 2',
site=self.site1,
cluster=self.cluster1
) )
self.device3 = Device.objects.create( self.device3 = Device.objects.create(
device_type=self.devicetype1, device_role=self.devicerole1, name='Test Device 3', site=self.site1 device_type=self.devicetype1,
device_role=self.devicerole1,
name='Test Device 3',
site=self.site1,
cluster=self.cluster1
) )
def test_get_device(self): def test_get_device(self):
@@ -1696,6 +1712,8 @@ class DeviceTest(APITestCase):
response = self.client.get(url, **self.header) response = self.client.get(url, **self.header)
self.assertEqual(response.data['name'], self.device1.name) self.assertEqual(response.data['name'], self.device1.name)
self.assertEqual(response.data['device_role']['id'], self.devicerole1.pk)
self.assertEqual(response.data['cluster']['id'], self.cluster1.pk)
def test_list_devices(self): def test_list_devices(self):
@@ -1711,6 +1729,7 @@ class DeviceTest(APITestCase):
'device_role': self.devicerole1.pk, 'device_role': self.devicerole1.pk,
'name': 'Test Device 4', 'name': 'Test Device 4',
'site': self.site1.pk, 'site': self.site1.pk,
'cluster': self.cluster1.pk,
} }
url = reverse('dcim-api:device-list') url = reverse('dcim-api:device-list')
@@ -1722,7 +1741,8 @@ class DeviceTest(APITestCase):
self.assertEqual(device4.device_type_id, data['device_type']) self.assertEqual(device4.device_type_id, data['device_type'])
self.assertEqual(device4.device_role_id, data['device_role']) self.assertEqual(device4.device_role_id, data['device_role'])
self.assertEqual(device4.name, data['name']) self.assertEqual(device4.name, data['name'])
self.assertEqual(device4.site_id, data['site']) self.assertEqual(device4.site.pk, data['site'])
self.assertEqual(device4.cluster.pk, data['cluster'])
def test_create_device_bulk(self): def test_create_device_bulk(self):
@@ -1758,11 +1778,17 @@ class DeviceTest(APITestCase):
def test_update_device(self): def test_update_device(self):
interface = Interface.objects.create(name='Test Interface 1', device=self.device1)
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
data = { data = {
'device_type': self.devicetype2.pk, 'device_type': self.devicetype2.pk,
'device_role': self.devicerole2.pk, 'device_role': self.devicerole2.pk,
'name': 'Test Device X', 'name': 'Test Device X',
'site': self.site2.pk, 'site': self.site2.pk,
'primary_ip4': ip4_address.pk,
'primary_ip6': ip6_address.pk,
} }
url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk}) url = reverse('dcim-api:device-detail', kwargs={'pk': self.device1.pk})
@@ -1774,7 +1800,9 @@ class DeviceTest(APITestCase):
self.assertEqual(device1.device_type_id, data['device_type']) self.assertEqual(device1.device_type_id, data['device_type'])
self.assertEqual(device1.device_role_id, data['device_role']) self.assertEqual(device1.device_role_id, data['device_role'])
self.assertEqual(device1.name, data['name']) self.assertEqual(device1.name, data['name'])
self.assertEqual(device1.site_id, data['site']) self.assertEqual(device1.site.pk, data['site'])
self.assertEqual(device1.primary_ip4.pk, data['primary_ip4'])
self.assertEqual(device1.primary_ip6.pk, data['primary_ip6'])
def test_delete_device(self): def test_delete_device(self):

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,12 @@ from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField from taggit.forms import TagField
from taggit.models import Tag from taggit.models import Tag
from dcim.models import Region from dcim.models import DeviceRole, Platform, Region, Site
from utilities.forms import add_blank_choice, BootstrapMixin, BulkEditForm, LaxURLField, JSONField, SlugField from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField,
JSONField, SlugField,
)
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,
OBJECTCHANGE_ACTION_CHOICES, OBJECTCHANGE_ACTION_CHOICES,
@@ -223,6 +227,37 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm):
] ]
class ConfigContextFilterForm(BootstrapMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.all(),
to_field_name='slug'
)
site = FilterChoiceField(
queryset=Site.objects.all(),
to_field_name='slug'
)
role = FilterChoiceField(
queryset=DeviceRole.objects.all(),
to_field_name='slug'
)
platform = FilterChoiceField(
queryset=Platform.objects.all(),
to_field_name='slug'
)
tenant_group = FilterChoiceField(
queryset=TenantGroup.objects.all(),
to_field_name='slug'
)
tenant = FilterChoiceField(
queryset=Tenant.objects.all(),
to_field_name='slug'
)
# #
# Image attachments # Image attachments
# #

View File

@@ -700,9 +700,22 @@ class ConfigContext(models.Model):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('extras:configcontext', kwargs={'pk': self.pk}) return reverse('extras:configcontext', kwargs={'pk': self.pk})
def clean(self):
# Verify that JSON data is provided as an object
if type(self.data) is not dict:
raise ValidationError(
{'data': 'JSON data must be in object form. Example: {"foo": 123}'}
)
class ConfigContextModel(models.Model): class ConfigContextModel(models.Model):
local_context_data = JSONField(
blank=True,
null=True,
)
class Meta: class Meta:
abstract = True abstract = True
@@ -716,6 +729,10 @@ class ConfigContextModel(models.Model):
for context in ConfigContext.objects.get_for_object(self): for context in ConfigContext.objects.get_for_object(self):
data.update(context.data) data.update(context.data)
# If the object has local config context data defined, that data overwrites all rendered data
if self.local_context_data is not None:
data.update(self.local_context_data)
return data return data

View File

@@ -1,9 +1,10 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from collections import OrderedDict
import importlib import importlib
import inspect import inspect
import pkgutil import pkgutil
from collections import OrderedDict import sys
from django.conf import settings from django.conf import settings
from django.utils import timezone from django.utils import timezone
@@ -16,19 +17,36 @@ def is_report(obj):
""" """
Returns True if the given object is a Report. Returns True if the given object is a Report.
""" """
if obj in Report.__subclasses__(): return obj in Report.__subclasses__()
return True
return False
def get_report(module_name, report_name): def get_report(module_name, report_name):
""" """
Return a specific report from within a module. Return a specific report from within a module.
""" """
module = importlib.import_module('reports.{}'.format(module_name)) file_path = '{}/{}.py'.format(settings.REPORTS_ROOT, module_name)
# Python 3.5+
if sys.version_info >= (3, 5):
spec = importlib.util.spec_from_file_location(module_name, file_path)
module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(module)
except FileNotFoundError:
return None
# Python 2.7
else:
import imp
try:
module = imp.load_source(module_name, file_path)
except IOError:
return None
report = getattr(module, report_name, None) report = getattr(module, report_name, None)
if report is None: if report is None:
return None return None
return report() return report()

View File

@@ -72,15 +72,10 @@ class ConfigContextTable(BaseTable):
is_active = BooleanColumn( is_active = BooleanColumn(
verbose_name='Active' verbose_name='Active'
) )
actions = tables.TemplateColumn(
template_code=CONFIGCONTEXT_ACTIONS,
attrs={'td': {'class': 'text-right'}},
verbose_name=''
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ConfigContext model = ConfigContext
fields = ('pk', 'name', 'weight', 'is_active', 'description', 'actions') fields = ('pk', 'name', 'weight', 'is_active', 'description')
class ObjectChangeTable(BaseTable): class ObjectChangeTable(BaseTable):

View File

@@ -14,7 +14,7 @@ from taggit.models import Tag
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView from utilities.views import BulkDeleteView, ObjectDeleteView, ObjectEditView, ObjectListView
from . import filters from . import filters
from .forms import ConfigContextForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm from .forms import ConfigContextForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagForm
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult
from .reports import get_report, get_reports from .reports import get_report, get_reports
from .tables import ConfigContextTable, ObjectChangeTable, TagTable from .tables import ConfigContextTable, ObjectChangeTable, TagTable
@@ -56,6 +56,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
class ConfigContextListView(ObjectListView): class ConfigContextListView(ObjectListView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
filter = filters.ConfigContextFilter
filter_form = ConfigContextFilterForm
table = ConfigContextTable table = ConfigContextTable
template_name = 'extras/configcontext_list.html' template_name = 'extras/configcontext_list.html'
@@ -104,9 +106,11 @@ class ObjectConfigContextView(View):
obj = get_object_or_404(self.object_class, pk=pk) obj = get_object_or_404(self.object_class, pk=pk)
source_contexts = ConfigContext.objects.get_for_object(obj) source_contexts = ConfigContext.objects.get_for_object(obj)
model_name = self.object_class._meta.model_name
return render(request, 'extras/object_configcontext.html', { return render(request, 'extras/object_configcontext.html', {
self.object_class._meta.model_name: obj, model_name: obj,
'obj': obj,
'rendered_context': obj.get_config_context(), 'rendered_context': obj.get_config_context(),
'source_contexts': source_contexts, 'source_contexts': source_contexts,
'base_template': self.base_template, 'base_template': self.base_template,

View File

@@ -2,7 +2,6 @@ import datetime
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
from extras.models import Webhook from extras.models import Webhook
from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from extras.constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE
@@ -18,23 +17,16 @@ def enqueue_webhooks(instance, action):
if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS: if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS:
return return
type_create = action == OBJECTCHANGE_ACTION_CREATE # Retrieve any applicable Webhooks
type_update = action == OBJECTCHANGE_ACTION_UPDATE action_flag = {
type_delete = action == OBJECTCHANGE_ACTION_DELETE OBJECTCHANGE_ACTION_CREATE: 'type_create',
OBJECTCHANGE_ACTION_UPDATE: 'type_update',
# Find assigned webhooks OBJECTCHANGE_ACTION_DELETE: 'type_delete',
}[action]
obj_type = ContentType.objects.get_for_model(instance.__class__) obj_type = ContentType.objects.get_for_model(instance.__class__)
webhooks = Webhook.objects.filter( webhooks = Webhook.objects.filter(obj_type=obj_type, enabled=True, **{action_flag: True})
Q(enabled=True) &
(
Q(type_create=type_create) |
Q(type_update=type_update) |
Q(type_delete=type_delete)
) &
Q(obj_type=obj_type)
)
if webhooks: if webhooks.exists():
# Get the Model's API serializer class and serialize the object # Get the Model's API serializer class and serialize the object
serializer_class = get_serializer_for_model(instance.__class__) serializer_class = get_serializer_for_model(instance.__class__)
serializer_context = { serializer_context = {

View File

@@ -37,8 +37,12 @@ def process_webhook(webhook, data, model_class, event, timestamp):
prepared_request = requests.Request(**params).prepare() prepared_request = requests.Request(**params).prepare()
if webhook.secret != '': if webhook.secret != '':
# sign the request with the secret # Sign the request with a hash of the secret key and its content.
hmac_prep = hmac.new(bytearray(webhook.secret, 'utf8'), prepared_request.body, digestmod=hashlib.sha512) hmac_prep = hmac.new(
key=webhook.secret.encode('utf8'),
msg=prepared_request.body.encode('utf8'),
digestmod=hashlib.sha512
)
prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest() prepared_request.headers['X-Hook-Signature'] = hmac_prep.hexdigest()
with requests.Session() as session: with requests.Session() as session:

View File

@@ -258,7 +258,7 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)
role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False) role = ChoiceField(choices=IPADDRESS_ROLE_CHOICES, required=False, allow_null=True)
interface = IPAddressInterfaceSerializer(required=False, allow_null=True) interface = IPAddressInterfaceSerializer(required=False, allow_null=True)
tags = TagListSerializerField(required=False) tags = TagListSerializerField(required=False)

View File

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

View File

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

View File

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

View File

@@ -494,7 +494,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_prefix(self): def test_create_single_available_prefix(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), is_pool=True) vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/28'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk}) url = reverse('ipam-api:prefix-available-prefixes', kwargs={'pk': prefix.pk})
# Create four available prefixes with individual requests # Create four available prefixes with individual requests
@@ -512,6 +513,7 @@ class PrefixTest(APITestCase):
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['prefix'], prefixes_to_be_created[i]) self.assertEqual(response.data['prefix'], prefixes_to_be_created[i])
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more prefix # Try to create one more prefix
@@ -562,7 +564,8 @@ class PrefixTest(APITestCase):
def test_create_single_available_ip(self): def test_create_single_available_ip(self):
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), is_pool=True) vrf = VRF.objects.create(name='Test VRF 1', rd='1234')
prefix = Prefix.objects.create(prefix=IPNetwork('192.0.2.0/30'), vrf=vrf, is_pool=True)
url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk}) url = reverse('ipam-api:prefix-available-ips', kwargs={'pk': prefix.pk})
# Create all four available IPs with individual requests # Create all four available IPs with individual requests
@@ -572,6 +575,7 @@ class PrefixTest(APITestCase):
} }
response = self.client.post(url, data, format='json', **self.header) response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED) self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['vrf']['id'], vrf.pk)
self.assertEqual(response.data['description'], data['description']) self.assertEqual(response.data['description'], data['description'])
# Try to create one more IP # Try to create one more IP

View File

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

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

@@ -0,0 +1,30 @@
from django.conf import settings
from django.contrib.admin import AdminSite
from django.contrib.auth.models import Group, User
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from taggit.admin import TagAdmin
from taggit.models import Tag
class NetBoxAdminSite(AdminSite):
"""
Custom admin site
"""
site_header = 'NetBox Administration'
site_title = 'NetBox'
site_url = '/{}'.format(settings.BASE_PATH)
admin_site = NetBoxAdminSite(name='admin')
# Register external models
admin_site.register(Group, GroupAdmin)
admin_site.register(User, UserAdmin)
admin_site.register(Tag, TagAdmin)
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.4.0' VERSION = '2.4.5'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@@ -272,7 +272,6 @@ RQ_QUEUES = {
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
} }
} }
RQ_SHOW_ADMIN_LINK = True
# drf_yasg settings for Swagger # drf_yasg settings for Swagger
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -54,7 +54,9 @@
<a href="{% url 'dcim:device' pk=device.pk %}">Device</a> <a href="{% url 'dcim:device' pk=device.pk %}">Device</a>
</li> </li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a> <a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventory_items.count }}</span>
</a>
</li> </li>
{% if perms.dcim.napalm_read %} {% if perms.dcim.napalm_read %}
{% if device.status != 1 %} {% if device.status != 1 %}
@@ -445,7 +447,7 @@
<div class="col-md-12"> <div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %} {% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %} {% if perms.dcim.delete_devicebay %}
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@@ -481,7 +483,7 @@
</button> </button>
{% endif %} {% endif %}
{% if device_bays and perms.dcim.delete_devicebay %} {% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button> </button>
{% endif %} {% endif %}
@@ -553,7 +555,7 @@
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'dcim:interface_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
@@ -573,7 +575,7 @@
{% endif %} {% endif %}
{% if cs_ports or device.device_type.is_console_server %} {% if cs_ports or device.device_type.is_console_server %}
{% if perms.dcim.delete_consoleserverport %} {% if perms.dcim.delete_consoleserverport %}
<form method="post" action="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@@ -606,12 +608,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if cs_ports and perms.dcim.delete_consoleserverport %} {% if cs_ports and perms.dcim.delete_consoleserverport %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
@@ -631,7 +633,7 @@
{% endif %} {% endif %}
{% if power_outlets or device.device_type.is_pdu %} {% if power_outlets or device.device_type.is_pdu %}
{% if perms.dcim.delete_poweroutlet %} {% if perms.dcim.delete_poweroutlet %}
<form method="post" action="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@@ -664,12 +666,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={{ device.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect <span class="glyphicon glyphicon-resize-full" aria-hidden="true"></span> Disconnect
</button> </button>
{% endif %} {% endif %}
{% if power_outlets and perms.dcim.delete_poweroutlet %} {% if power_outlets and perms.dcim.delete_poweroutlet %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}

View File

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

View File

@@ -9,12 +9,12 @@
<div class="panel-footer"> <div class="panel-footer">
{% if table.rows %} {% if table.rows %}
{% if edit_url %} {% if edit_url %}
<button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}" class="btn btn-xs btn-warning"> <button type="submit" name="_edit" formaction="{% url edit_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-warning">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
</button> </button>
{% endif %} {% endif %}
{% if delete_url %} {% if delete_url %}
<button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}" class="btn btn-xs btn-danger"> <button type="submit" name="_delete" formaction="{% url delete_url pk=devicetype.pk %}?return_url={{ devicetype.get_absolute_url }}" class="btn btn-xs btn-danger">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
</button> </button>
{% endif %} {% endif %}

View File

@@ -44,7 +44,7 @@
<a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a> <a href="{% url 'dcim:device' pk=connected_iface.device.pk %}">{{ connected_iface.device }}</a>
</td> </td>
<td> <td>
<span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span> <a href="{% url 'dcim:interface' pk=connected_iface.pk %}"><span title="{{ connected_iface.get_form_factor_display }}">{{ connected_iface }}</span></a>
</td> </td>
{% endwith %} {% endwith %}
{% elif iface.circuit_termination %} {% elif iface.circuit_termination %}
@@ -111,7 +111,7 @@
</a> </a>
{% endif %} {% endif %}
{% endif %} {% endif %}
<a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}" class="btn btn-info btn-xs" title="Edit interface"> <a href="{% if iface.device_id %}{% url 'dcim:interface_edit' pk=iface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=iface.pk %}{% endif %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs" title="Edit interface">
<i class="glyphicon glyphicon-pencil" aria-hidden="true"></i> <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -194,10 +194,13 @@
</small> </small>
</div> </div>
{% endwith %} {% endwith %}
{% empty %} {% if forloop.last %}
<div class="list-group-item"> <div class="list-group-item text-right">
Welcome to NetBox! {% if perms.add_site %} <a href="{% url 'dcim:site_add' %}">Add a site</a> to get started.{% endif %} <a href="{% url 'extras:objectchange_list' %}">View All Changes</a>
</div> </div>
{% endif %}
{% empty %}
<div class="list-group-item text-muted">No change history found</div>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
{% with custom_fields=obj.custom_fields %} {% with custom_fields=obj.get_custom_fields %}
{% if custom_fields %} {% if custom_fields %}
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

View File

@@ -40,7 +40,7 @@
{% include 'inc/created_updated.html' with obj=vrf %} {% include 'inc/created_updated.html' with obj=vrf %}
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ aggregate.get_absolute_url }}">VRF</a> <a href="{{ vrf.get_absolute_url }}">VRF</a>
</li> </li>
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a> <a href="{% url 'ipam:vrf_changelog' pk=vrf.pk %}">Changelog</a>

View File

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

View File

@@ -282,12 +282,12 @@
<button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs"> <button type="submit" name="_rename" formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Rename
</button> </button>
<button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}" class="btn btn-warning btn-xs"> <button type="submit" name="_edit" formaction="{% url 'virtualization:interface_bulk_edit' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-warning btn-xs">
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit
</button> </button>
{% endif %} {% endif %}
{% if interfaces and perms.dcim.delete_interface %} {% if interfaces and perms.dcim.delete_interface %}
<button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}" class="btn btn-danger btn-xs"> <button type="submit" name="_delete" formaction="{% url 'virtualization:interface_bulk_delete' pk=virtualmachine.pk %}?return_url={{ virtualmachine.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete
</button> </button>
{% endif %} {% endif %}
@@ -315,9 +315,9 @@
$('button.toggle-ips').click(function() { $('button.toggle-ips').click(function() {
var selected = $(this).attr('selected'); var selected = $(this).attr('selected');
if (selected) { if (selected) {
$('#interfaces_table tr.ipaddress').hide(); $('#interfaces_table tr.ipaddresses').hide();
} else { } else {
$('#interfaces_table tr.ipaddress').show(); $('#interfaces_table tr.ipaddresses').show();
} }
$(this).attr('selected', !selected); $(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');

View File

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

View File

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

View File

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

View File

@@ -74,6 +74,12 @@ class ChoiceField(Field):
return {'value': obj, 'label': self._choices[obj]} return {'value': obj, 'label': self._choices[obj]}
def to_internal_value(self, data): def to_internal_value(self, data):
# Hotwiring boolean values
if hasattr(data, 'lower'):
if data.lower() == 'true':
return True
if data.lower() == 'false':
return False
return data return data
@@ -102,10 +108,9 @@ class TimeZoneField(Field):
def to_internal_value(self, data): def to_internal_value(self, data):
if not data: if not data:
return "" return ""
try: if data not in pytz.common_timezones:
return pytz.timezone(str(data)) raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
except pytz.exceptions.UnknownTimeZoneError: return pytz.timezone(data)
raise ValidationError('Invalid time zone "{}"'.format(data))
class SerializedPKRelatedField(PrimaryKeyRelatedField): class SerializedPKRelatedField(PrimaryKeyRelatedField):
@@ -164,7 +169,9 @@ class WritableNestedSerializer(ModelSerializer):
if data is None: if data is None:
return None return None
try: try:
return self.Meta.model.objects.get(pk=data) return self.Meta.model.objects.get(pk=int(data))
except (TypeError, ValueError):
raise ValidationError("Primary key must be an integer")
except ObjectDoesNotExist: except ObjectDoesNotExist:
raise ValidationError("Invalid ID") raise ValidationError("Invalid ID")

View File

@@ -2,11 +2,12 @@ from __future__ import unicode_literals
import csv import csv
from io import StringIO from io import StringIO
import json
import re import re
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.postgres.forms import JSONField as _JSONField from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
from django.db.models import Count from django.db.models import Count
from django.urls import reverse_lazy from django.urls import reverse_lazy
from mptt.forms import TreeNodeMultipleChoiceField from mptt.forms import TreeNodeMultipleChoiceField
@@ -556,9 +557,11 @@ class JSONField(_JSONField):
self.widget.attrs['placeholder'] = '' self.widget.attrs['placeholder'] = ''
def prepare_value(self, value): def prepare_value(self, value):
if isinstance(value, InvalidJSONInput):
return value
if value is None: if value is None:
return '' return ''
return super(JSONField, self).prepare_value(value) return json.dumps(value, sort_keys=True, indent=4)
# #

View File

@@ -101,8 +101,8 @@ def serialize_object(obj, extra=None):
} }
# Include any tags # Include any tags
# if hasattr(obj, 'tags'): if hasattr(obj, 'tags'):
# data['tags'] = [tag.name for tag in obj.tags.all()] data['tags'] = [tag.name for tag in obj.tags.all()]
# Append any extra data # Append any extra data
if extra is not None: if extra is not None:

View File

@@ -710,22 +710,17 @@ class ComponentCreateView(View):
if form.is_valid(): if form.is_valid():
new_components = [] new_components = []
data = deepcopy(form.cleaned_data) data = deepcopy(request.POST)
data[self.parent_field] = parent.pk
for name in form.cleaned_data['name_pattern']: for name in form.cleaned_data['name_pattern']:
component_data = {
self.parent_field: parent.pk, # Initialize the individual component form
'name': name, data['name'] = name
} component_form = self.model_form(data)
# Replace objects with their primary key to keep component_form.clean() happy
for k, v in data.items():
if hasattr(v, 'pk'):
component_data[k] = v.pk
else:
component_data[k] = v
component_form = self.model_form(component_data)
if component_form.is_valid(): if component_form.is_valid():
new_components.append(component_form.save(commit=False)) new_components.append(component_form)
else: else:
for field, errors in component_form.errors.as_data().items(): for field, errors in component_form.errors.as_data().items():
# Assign errors on the child form's name field to name_pattern on the parent form # Assign errors on the child form's name field to name_pattern on the parent form
@@ -735,26 +730,10 @@ class ComponentCreateView(View):
form.add_error(field, '{}: {}'.format(name, ', '.join(e))) form.add_error(field, '{}: {}'.format(name, ', '.join(e)))
if not form.errors: if not form.errors:
self.model.objects.bulk_create(new_components)
# ManyToMany relations are bulk created via the through model # Create the new components
m2m_fields = [field for field in component_form.fields if type(component_form.fields[field]) in M2M_FIELD_TYPES] for component_form in new_components:
if m2m_fields: component_form.save()
for field in m2m_fields:
field_links = []
for new_component in new_components:
for related_obj in component_form.cleaned_data[field]:
# The through model columns are the id's of our M2M relation objects
through_kwargs = {}
new_component_column = new_component.__class__.__name__ + '_id'
related_obj_column = related_obj.__class__.__name__ + '_id'
through_kwargs.update({
new_component_column.lower(): new_component.id,
related_obj_column.lower(): related_obj.id
})
field_link = getattr(self.model, field).through(**through_kwargs)
field_links.append(field_link)
getattr(self.model, field).through.objects.bulk_create(field_links)
messages.success(request, "Added {} {} to {}.".format( messages.success(request, "Added {} {} to {}.".format(
len(new_components), self.model._meta.verbose_name_plural, parent len(new_components), self.model._meta.verbose_name_plural, parent

View File

@@ -82,7 +82,7 @@ class NestedClusterSerializer(WritableNestedSerializer):
# #
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency # Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class VirtualMachineIPAddressSerializer(serializers.ModelSerializer): class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta: class Meta:
@@ -92,6 +92,7 @@ class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@@ -104,8 +105,9 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'local_context_data',
] ]
@@ -116,6 +118,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated',
'local_context_data',
] ]
def get_config_context(self, obj): def get_config_context(self, obj):
@@ -146,7 +149,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer):
class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer):
virtual_machine = NestedVirtualMachineSerializer() virtual_machine = NestedVirtualMachineSerializer()
form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False)
mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True)
untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True) untagged_vlan = InterfaceVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField( tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),

View File

@@ -35,7 +35,7 @@ class ClusterGroupViewSet(ModelViewSet):
class ClusterViewSet(CustomFieldModelViewSet): class ClusterViewSet(CustomFieldModelViewSet):
queryset = Cluster.objects.select_related('type', 'group') queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags')
serializer_class = serializers.ClusterSerializer serializer_class = serializers.ClusterSerializer
filter_class = filters.ClusterFilter filter_class = filters.ClusterFilter
@@ -45,7 +45,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
# #
class VirtualMachineViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.select_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6'
).prefetch_related('tags')
filter_class = filters.VirtualMachineFilter filter_class = filters.VirtualMachineFilter
def get_serializer_class(self): def get_serializer_class(self):
@@ -58,6 +60,8 @@ class VirtualMachineViewSet(CustomFieldModelViewSet):
class InterfaceViewSet(ModelViewSet): class InterfaceViewSet(ModelViewSet):
queryset = Interface.objects.filter(virtual_machine__isnull=False).select_related('virtual_machine') queryset = Interface.objects.filter(
virtual_machine__isnull=False
).select_related('virtual_machine').prefetch_related('tags')
serializer_class = serializers.InterfaceSerializer serializer_class = serializers.InterfaceSerializer
filter_class = filters.InterfaceFilter filter_class = filters.InterfaceFilter

View File

@@ -8,7 +8,6 @@ from taggit.forms import TagField
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL
from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.forms import INTERFACE_MODE_HELP_TEXT
from dcim.formfields import MACAddressFormField
from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site
from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm
from ipam.models import IPAddress from ipam.models import IPAddress
@@ -17,7 +16,8 @@ from tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm,
ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, SlugField, SmallTextarea, add_blank_choice ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea,
add_blank_choice
) )
from .constants import VM_STATUS_CHOICES from .constants import VM_STATUS_CHOICES
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -247,6 +247,7 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
) )
) )
tags = TagField(required=False) tags = TagField(required=False)
local_context_data = JSONField(required=False)
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
@@ -254,6 +255,9 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm):
'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'vcpus', 'memory', 'disk', 'comments', 'tags',
] ]
help_texts = {
'local_context_data': "Local config context data overwrites all sources contexts in the final rendered config context",
}
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -415,11 +419,12 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm):
# #
class InterfaceForm(BootstrapMixin, forms.ModelForm): class InterfaceForm(BootstrapMixin, forms.ModelForm):
tags = TagField(required=False)
class Meta: class Meta:
model = Interface model = Interface
fields = [ fields = [
'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags',
'untagged_vlan', 'tagged_vlans', 'untagged_vlan', 'tagged_vlans',
] ]
widgets = { widgets = {
@@ -456,8 +461,9 @@ class InterfaceCreateForm(ComponentForm):
form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput()) form_factor = forms.ChoiceField(choices=VIFACE_FF_CHOICES, initial=IFACE_FF_VIRTUAL, widget=forms.HiddenInput())
enabled = forms.BooleanField(required=False) enabled = forms.BooleanField(required=False)
mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU') mtu = forms.IntegerField(required=False, min_value=1, max_value=32767, label='MTU')
mac_address = MACAddressFormField(required=False, label='MAC Address') mac_address = forms.CharField(required=False, label='MAC Address')
description = forms.CharField(max_length=100, required=False) description = forms.CharField(max_length=100, required=False)
tags = TagField(required=False)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):

View File

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

View File

@@ -260,6 +260,22 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('virtualization:virtualmachine', args=[self.pk]) return reverse('virtualization:virtualmachine', args=[self.pk])
def clean(self):
# Validate primary IP addresses
interfaces = self.interfaces.all()
for field in ['primary_ip4', 'primary_ip6']:
ip = getattr(self, field)
if ip is not None:
if ip.interface in interfaces:
pass
elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in interfaces:
pass
else:
raise ValidationError({
field: "The specified IP address ({}) is not assigned to this VM.".format(ip),
})
def to_csv(self): def to_csv(self):
return ( return (
self.name, self.name,

View File

@@ -1,11 +1,12 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.urls import reverse from django.urls import reverse
from netaddr import IPNetwork
from rest_framework import status from rest_framework import status
from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED
from dcim.models import Interface from dcim.models import Interface
from ipam.models import VLAN from ipam.models import IPAddress, VLAN
from utilities.testing import APITestCase from utilities.testing import APITestCase
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
@@ -367,6 +368,10 @@ class VirtualMachineTest(APITestCase):
def test_update_virtualmachine(self): def test_update_virtualmachine(self):
interface = Interface.objects.create(name='Test Interface 1', virtual_machine=self.virtualmachine1)
ip4_address = IPAddress.objects.create(address=IPNetwork('192.0.2.1/24'), interface=interface)
ip6_address = IPAddress.objects.create(address=IPNetwork('2001:db8::1/64'), interface=interface)
cluster2 = Cluster.objects.create( cluster2 = Cluster.objects.create(
name='Test Cluster 2', name='Test Cluster 2',
type=ClusterType.objects.first(), type=ClusterType.objects.first(),
@@ -375,6 +380,8 @@ class VirtualMachineTest(APITestCase):
data = { data = {
'name': 'Test Virtual Machine X', 'name': 'Test Virtual Machine X',
'cluster': cluster2.pk, 'cluster': cluster2.pk,
'primary_ip4': ip4_address.pk,
'primary_ip6': ip6_address.pk,
} }
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk}) url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': self.virtualmachine1.pk})
@@ -385,6 +392,8 @@ class VirtualMachineTest(APITestCase):
virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id'])
self.assertEqual(virtualmachine1.name, data['name']) self.assertEqual(virtualmachine1.name, data['name'])
self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) self.assertEqual(virtualmachine1.cluster.pk, data['cluster'])
self.assertEqual(virtualmachine1.primary_ip4.pk, data['primary_ip4'])
self.assertEqual(virtualmachine1.primary_ip6.pk, data['primary_ip6'])
def test_delete_virtualmachine(self): def test_delete_virtualmachine(self):

View File

@@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@@ -183,17 +184,21 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
if form.is_valid(): if form.is_valid():
device_pks = form.cleaned_data['devices']
with transaction.atomic():
# Assign the selected Devices to the Cluster # Assign the selected Devices to the Cluster
devices = form.cleaned_data['devices'] for device in Device.objects.filter(pk__in=device_pks):
Device.objects.filter(pk__in=devices).update(cluster=cluster) device.cluster = cluster
device.save()
messages.success(request, "Added {} devices to cluster {}".format( messages.success(request, "Added {} devices to cluster {}".format(
len(devices), cluster len(device_pks), cluster
)) ))
return redirect(cluster.get_absolute_url()) return redirect(cluster.get_absolute_url())
return render(request, self.template_name, { return render(request, self.template_name, {
'cluser': cluster, 'cluster': cluster,
'form': form, 'form': form,
'return_url': cluster.get_absolute_url(), 'return_url': cluster.get_absolute_url(),
}) })
@@ -212,12 +217,16 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
form = self.form(request.POST) form = self.form(request.POST)
if form.is_valid(): if form.is_valid():
device_pks = form.cleaned_data['pk']
with transaction.atomic():
# Remove the selected Devices from the Cluster # Remove the selected Devices from the Cluster
devices = form.cleaned_data['pk'] for device in Device.objects.filter(pk__in=device_pks):
Device.objects.filter(pk__in=devices).update(cluster=None) device.cluster = None
device.save()
messages.success(request, "Removed {} devices from cluster {}".format( messages.success(request, "Removed {} devices from cluster {}".format(
len(devices), cluster len(device_pks), cluster
)) ))
return redirect(cluster.get_absolute_url()) return redirect(cluster.get_absolute_url())