mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-15 11:42:52 -06:00
Merge branch 'develop' into 4121-filter-lookup-expressions
This commit is contained in:
commit
c908f132ec
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
9
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: 📖 Contributing Policy
|
||||
url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md
|
||||
about: Please read through our contributing policy before opening an issue or pull request
|
||||
- name: 💬 Discussion Group
|
||||
url: https://groups.google.com/forum/#!forum/netbox-discuss
|
||||
about: Join our discussion group for assistance with installation issues and other problems
|
@ -26,8 +26,12 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||

|
||||
|
||||
# Installation
|
||||
|
@ -27,11 +27,17 @@ class MyScript(Script):
|
||||
var2 = IntegerVar(...)
|
||||
var3 = ObjectVar(...)
|
||||
|
||||
def run(self, data):
|
||||
def run(self, data, commit):
|
||||
...
|
||||
```
|
||||
|
||||
The `run()` method is passed a single argument: a dictionary containing all of the variable data passed via the web form. Your script can reference this data during execution.
|
||||
The `run()` method should accept two arguments:
|
||||
|
||||
* `data` - A dictionary containing all of the variable data passed via the web form.
|
||||
* `commit` - A boolean indicating whether database changes will be committed.
|
||||
|
||||
!!! note
|
||||
The `commit` argument was introduced in NetBox v2.7.8. Backward compatibility is maintained for scripts which accept only the `data` argument, however moving forward scripts should accept both arguments.
|
||||
|
||||
Defining variables is optional: You may create a script with only a `run()` method if no user input is needed.
|
||||
|
||||
@ -196,7 +202,7 @@ These variables are presented as a web form to be completed by the user. Once su
|
||||
```
|
||||
from django.utils.text import slugify
|
||||
|
||||
from dcim.constants import *
|
||||
from dcim.choices import DeviceStatusChoices, SiteStatusChoices
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Site
|
||||
from extras.scripts import *
|
||||
|
||||
@ -222,13 +228,13 @@ class NewBranchScript(Script):
|
||||
)
|
||||
)
|
||||
|
||||
def run(self, data):
|
||||
def run(self, data, commit):
|
||||
|
||||
# Create the new site
|
||||
site = Site(
|
||||
name=data['site_name'],
|
||||
slug=slugify(data['site_name']),
|
||||
status=SITE_STATUS_PLANNED
|
||||
status=SiteStatusChoices.STATUS_PLANNED
|
||||
)
|
||||
site.save()
|
||||
self.log_success("Created new site: {}".format(site))
|
||||
@ -240,7 +246,7 @@ class NewBranchScript(Script):
|
||||
device_type=data['switch_model'],
|
||||
name='{}-switch{}'.format(site.slug, i),
|
||||
site=site,
|
||||
status=DEVICE_STATUS_PLANNED,
|
||||
status=DeviceStatusChoices.STATUS_PLANNED,
|
||||
device_role=switch_role
|
||||
)
|
||||
switch.save()
|
||||
|
@ -32,7 +32,8 @@ class DeviceIPsReport(Report):
|
||||
Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
|
||||
|
||||
```
|
||||
from dcim.constants import CONNECTION_STATUS_PLANNED, DEVICE_STATUS_ACTIVE
|
||||
from dcim.choices import DeviceStatusChoices
|
||||
from dcim.constants import CONNECTION_STATUS_PLANNED
|
||||
from dcim.models import ConsolePort, Device, PowerPort
|
||||
from extras.reports import Report
|
||||
|
||||
@ -43,7 +44,8 @@ class DeviceConnectionsReport(Report):
|
||||
def test_console_connection(self):
|
||||
|
||||
# Check that every console port for every active device has a connection defined.
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
|
||||
active = DeviceStatusChoices.STATUS_ACTIVE
|
||||
for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
|
||||
if console_port.connected_endpoint is None:
|
||||
self.log_failure(
|
||||
console_port.device,
|
||||
@ -60,7 +62,7 @@ class DeviceConnectionsReport(Report):
|
||||
def test_power_connections(self):
|
||||
|
||||
# Check that every active device has at least two connected power supplies.
|
||||
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
|
||||
for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
|
||||
connected_ports = 0
|
||||
for power_port in PowerPort.objects.filter(device=device):
|
||||
if power_port.connected_endpoint is not None:
|
||||
|
@ -1,61 +1,73 @@
|
||||
# Webhooks
|
||||
|
||||
A webhook defines an HTTP request that is sent to an external application when certain types of objects are created, updated, and/or deleted in NetBox. When a webhook is triggered, a POST request is sent to its configured URL. This request will include a full representation of the object being modified for consumption by the receiver. Webhooks are configured via the admin UI under Extras > Webhooks.
|
||||
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever a device status is changed in NetBox. This can be done by creating a webhook for the device model in NetBox. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
|
||||
|
||||
An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content.
|
||||
## Configuration
|
||||
|
||||
## Requests
|
||||
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
|
||||
* **Object type(s)** - The type or types of NetBox object that will trigger the webhook.
|
||||
* **Enabled** - If unchecked, the webhook will be inactive.
|
||||
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
|
||||
* **HTTP method** - The type of HTTP request to send. Options include GET, POST, PUT, PATCH, and DELETE.
|
||||
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
|
||||
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
|
||||
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
|
||||
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
|
||||
* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key.
|
||||
* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!)
|
||||
* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional).
|
||||
|
||||
The webhook POST request is structured as so (assuming `application/json` as the Content-Type):
|
||||
## Jinja2 Template Support
|
||||
|
||||
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey change data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
|
||||
|
||||
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
|
||||
|
||||
* Object type: IPAM > IP address
|
||||
* HTTP method: POST
|
||||
* URL: <Slack incoming webhook URL>
|
||||
* HTTP content type: `application/json`
|
||||
* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}`
|
||||
|
||||
### Available Context
|
||||
|
||||
The following data is available as context for Jinja2 templates:
|
||||
|
||||
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
|
||||
* `model` - The NetBox model which triggered the change.
|
||||
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
|
||||
* `username` - The name of the user account associated with the change.
|
||||
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
|
||||
* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
|
||||
### Default Request Body
|
||||
|
||||
If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows:
|
||||
|
||||
```no-highlight
|
||||
{
|
||||
"event": "created",
|
||||
"timestamp": "2019-10-12 12:51:29.746944",
|
||||
"username": "admin",
|
||||
"timestamp": "2020-02-25 15:10:26.010582+00:00",
|
||||
"model": "site",
|
||||
"request_id": "43d8e212-94c7-4f67-b544-0dcde4fc0f43",
|
||||
"username": "jstretch",
|
||||
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
|
||||
"data": {
|
||||
"id": 19,
|
||||
"name": "Site 1",
|
||||
"slug": "site-1",
|
||||
"status":
|
||||
"value": "active",
|
||||
"label": "Active",
|
||||
"id": 1
|
||||
},
|
||||
"region": null,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`data` is the serialized representation of the model instance(s) from the event. The same serializers from the NetBox API are used. So an example of the payload for a Site delete event would be:
|
||||
## Webhook Processing
|
||||
|
||||
```no-highlight
|
||||
{
|
||||
"event": "deleted",
|
||||
"timestamp": "2019-10-12 12:55:44.030750",
|
||||
"username": "johnsmith",
|
||||
"model": "site",
|
||||
"request_id": "e9bb83b2-ebe4-4346-b13f-07144b1a00b4",
|
||||
"data": {
|
||||
"asn": None,
|
||||
"comments": "",
|
||||
"contact_email": "",
|
||||
"contact_name": "",
|
||||
"contact_phone": "",
|
||||
"count_circuits": 0,
|
||||
"count_devices": 0,
|
||||
"count_prefixes": 0,
|
||||
"count_racks": 0,
|
||||
"count_vlans": 0,
|
||||
"custom_fields": {},
|
||||
"facility": "",
|
||||
"id": 54,
|
||||
"name": "test",
|
||||
"physical_address": "",
|
||||
"region": None,
|
||||
"shipping_address": "",
|
||||
"slug": "test",
|
||||
"tenant": None
|
||||
}
|
||||
}
|
||||
```
|
||||
When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under Django RQ > Queues.
|
||||
|
||||
A request is considered successful if the response status code is any one of a list of "good" statuses defined in the [requests library](https://github.com/requests/requests/blob/205755834d34a8a6ecf2b0b5b2e9c3e6a7f4e4b6/requests/models.py#L688), otherwise the request is marked as having failed. The user may manually retry a failed request.
|
||||
|
||||
## Backend Status
|
||||
|
||||
Django-rq includes a status page in the admin site which can be used to view the result of processed webhooks and manually retry any failed webhooks. Access it from http://netbox.local/admin/webhook-backend-status/.
|
||||
A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI.
|
||||
|
@ -99,6 +99,9 @@ Save the contents of the above example in `/etc/apache2/sites-available/netbox.c
|
||||
|
||||
To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https://www.digitalocean.com/community/tutorials/how-to-secure-apache-with-let-s-encrypt-on-ubuntu-16-04).
|
||||
|
||||
!!! note
|
||||
Certain components of NetBox (such as the display of rack elevation diagrams) rely on the use of embedded objects. Ensure that your HTTP server configuration does not override the `X-Frame-Options` response header set by NetBox.
|
||||
|
||||
# gunicorn Installation
|
||||
|
||||
Install gunicorn:
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 336 KiB |
Binary file not shown.
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 336 KiB |
Binary file not shown.
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 339 KiB |
@ -1,15 +1,73 @@
|
||||
# v2.7.7 (FUTURE)
|
||||
# v2.7.9 (FUTURE)
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#4277](https://github.com/netbox-community/netbox/issues/4277) - Fix filtering of clusters by tenant
|
||||
|
||||
---
|
||||
|
||||
# v2.7.8 (2020-02-25)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#3145](https://github.com/netbox-community/netbox/issues/3145) - Add a "decommissioning" cable status
|
||||
* [#4173](https://github.com/netbox-community/netbox/issues/4173) - Return graceful error message when webhook queuing fails
|
||||
* [#4227](https://github.com/netbox-community/netbox/issues/4227) - Omit internal fields from the change log data
|
||||
* [#4237](https://github.com/netbox-community/netbox/issues/4237) - Support Jinja2 templating for webhook payload and headers
|
||||
* [#4262](https://github.com/netbox-community/netbox/issues/4262) - Extend custom scripts to pass the `commit` value via `run()`
|
||||
* [#4267](https://github.com/netbox-community/netbox/issues/4267) - Denote rack role on rack elevations list
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#4221](https://github.com/netbox-community/netbox/issues/4221) - Fix exception when deleting a device with interface connections when an interfaces webhook is defined
|
||||
* [#4222](https://github.com/netbox-community/netbox/issues/4222) - Escape double quotes on encapsulated values during CSV export
|
||||
* [#4224](https://github.com/netbox-community/netbox/issues/4224) - Fix display of rear device image if front image is not defined
|
||||
* [#4228](https://github.com/netbox-community/netbox/issues/4228) - Improve fit of device images in rack elevations
|
||||
* [#4230](https://github.com/netbox-community/netbox/issues/4230) - Fix rack units filtering on elevation endpoint
|
||||
* [#4232](https://github.com/netbox-community/netbox/issues/4232) - Enforce consistent background striping in rack elevations
|
||||
* [#4235](https://github.com/netbox-community/netbox/issues/4235) - Fix API representation of `content_type` for export templates
|
||||
* [#4239](https://github.com/netbox-community/netbox/issues/4239) - Fix exception when selecting all filtered objects during bulk edit
|
||||
* [#4240](https://github.com/netbox-community/netbox/issues/4240) - Fix exception when filtering foreign keys by NULL
|
||||
* [#4241](https://github.com/netbox-community/netbox/issues/4241) - Correct IP address hyperlinks on interface view
|
||||
* [#4246](https://github.com/netbox-community/netbox/issues/4246) - Fix duplication of field attributes when multiple IPNetworkVars are present in a script
|
||||
* [#4252](https://github.com/netbox-community/netbox/issues/4252) - Fix power port assignment for power outlet templates created via REST API
|
||||
* [#4272](https://github.com/netbox-community/netbox/issues/4272) - Interface type should be required by API serializer
|
||||
|
||||
---
|
||||
|
||||
# v2.7.7 (2020-02-20)
|
||||
|
||||
**Note:** This release fixes a bug affecting the natural ordering of interfaces. If any interfaces appear unordered in
|
||||
NetBox, run the following management command to recalculate their naturalized values after upgrading:
|
||||
|
||||
```
|
||||
python3 manage.py renaturalize dcim.Interface
|
||||
```
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#1529](https://github.com/netbox-community/netbox/issues/1529) - Enable display of device images in rack elevations
|
||||
* [#2511](https://github.com/netbox-community/netbox/issues/2511) - Compare object change to the previous change
|
||||
* [#3810](https://github.com/netbox-community/netbox/issues/3810) - Preserve slug value when editing existing objects
|
||||
* [#3840](https://github.com/netbox-community/netbox/issues/3840) - Enhance search function when selecting VLANs for interface assignment
|
||||
* [#4170](https://github.com/netbox-community/netbox/issues/4170) - Improve color contrast in rack elevation drawings
|
||||
* [#4206](https://github.com/netbox-community/netbox/issues/4206) - Add RJ-11 console port type
|
||||
* [#4209](https://github.com/netbox-community/netbox/issues/4209) - Enable filtering interfaces list view by enabled
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2519](https://github.com/netbox-community/netbox/issues/2519) - Avoid race condition when provisioning "next available" IPs/prefixes via the API
|
||||
* [#3967](https://github.com/netbox-community/netbox/issues/3967) - Fix missing migration for interface templates of type "other"
|
||||
* [#4168](https://github.com/netbox-community/netbox/issues/4168) - Role is not required when creating a virtual machine
|
||||
* [#4175](https://github.com/netbox-community/netbox/issues/4175) - Fix potential exception when bulk editing objects from a filtered list
|
||||
* [#4179](https://github.com/netbox-community/netbox/issues/4179) - Site is required when creating a rack group or power panel
|
||||
* [#4183](https://github.com/netbox-community/netbox/issues/4183) - Fix representation of NaturalOrderingField values in change log
|
||||
* [#4194](https://github.com/netbox-community/netbox/issues/4194) - Role field should not be required when searching/filtering secrets
|
||||
* [#4196](https://github.com/netbox-community/netbox/issues/4196) - Fix exception when viewing LLDP neighbors page
|
||||
* [#4202](https://github.com/netbox-community/netbox/issues/4202) - Prevent reassignment to master device when bulk editing VC member interfaces
|
||||
* [#4204](https://github.com/netbox-community/netbox/issues/4204) - Fix assignment of mask length when bulk editing prefixes
|
||||
* [#4211](https://github.com/netbox-community/netbox/issues/4211) - Include trailing text when naturalizing interface names
|
||||
* [#4213](https://github.com/netbox-community/netbox/issues/4213) - Restore display of tags and custom fields on power feed view
|
||||
|
||||
---
|
||||
|
||||
|
@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Circuit, CircuitType, Provider
|
||||
|
||||
CIRCUITTYPE_ACTIONS = """
|
||||
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'circuits:circuittype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.circuit.change_circuittype %}
|
||||
|
@ -3,8 +3,8 @@ from rest_framework import serializers
|
||||
from dcim.constants import CONNECTION_STATUS_CHOICES
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceType, DeviceRole, FrontPort, FrontPortTemplate,
|
||||
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole,
|
||||
RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, PowerPortTemplate, Rack,
|
||||
RackGroup, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis,
|
||||
)
|
||||
from utilities.api import ChoiceField, WritableNestedSerializer
|
||||
|
||||
@ -25,6 +25,7 @@ __all__ = [
|
||||
'NestedPowerOutletSerializer',
|
||||
'NestedPowerPanelSerializer',
|
||||
'NestedPowerPortSerializer',
|
||||
'NestedPowerPortTemplateSerializer',
|
||||
'NestedRackGroupSerializer',
|
||||
'NestedRackRoleSerializer',
|
||||
'NestedRackSerializer',
|
||||
@ -111,6 +112,14 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
|
||||
fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count']
|
||||
|
||||
|
||||
class NestedPowerPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerporttemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = PowerPortTemplate
|
||||
fields = ['id', 'url', 'name']
|
||||
|
||||
|
||||
class NestedRearPortTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearporttemplate-detail')
|
||||
|
||||
|
@ -172,6 +172,10 @@ class RackReservationSerializer(ValidatedModelSerializer):
|
||||
|
||||
|
||||
class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
q = serializers.CharField(
|
||||
required=False,
|
||||
default=None
|
||||
)
|
||||
face = serializers.ChoiceField(
|
||||
choices=DeviceFaceChoices,
|
||||
default=DeviceFaceChoices.FACE_FRONT
|
||||
@ -186,6 +190,9 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
unit_height = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT
|
||||
)
|
||||
legend_width = serializers.IntegerField(
|
||||
default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||
)
|
||||
exclude = serializers.IntegerField(
|
||||
required=False,
|
||||
default=None
|
||||
@ -194,6 +201,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
|
||||
required=False,
|
||||
default=True
|
||||
)
|
||||
include_images = serializers.BooleanField(
|
||||
required=False,
|
||||
default=True
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
@ -220,7 +231,8 @@ class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer):
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth',
|
||||
'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
|
||||
'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created',
|
||||
'last_updated', 'device_count',
|
||||
]
|
||||
|
||||
|
||||
@ -270,7 +282,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
allow_blank=True,
|
||||
required=False
|
||||
)
|
||||
power_port = PowerPortTemplateSerializer(
|
||||
power_port = NestedPowerPortTemplateSerializer(
|
||||
required=False
|
||||
)
|
||||
feed_leg = ChoiceField(
|
||||
@ -286,7 +298,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer):
|
||||
|
||||
class InterfaceTemplateSerializer(ValidatedModelSerializer):
|
||||
device_type = NestedDeviceTypeSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices, required=False)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
|
||||
class Meta:
|
||||
model = InterfaceTemplate
|
||||
@ -506,7 +518,7 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
|
||||
class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer):
|
||||
device = NestedDeviceSerializer()
|
||||
type = ChoiceField(choices=InterfaceTypeChoices, required=False)
|
||||
type = ChoiceField(choices=InterfaceTypeChoices)
|
||||
lag = NestedInterfaceSerializer(required=False, allow_null=True)
|
||||
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
|
||||
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
|
||||
|
@ -220,7 +220,13 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
|
||||
if data['render'] == 'svg':
|
||||
# Render and return the elevation as an SVG drawing with the correct content type
|
||||
drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height'])
|
||||
drawing = rack.get_elevation_svg(
|
||||
face=data['face'],
|
||||
unit_width=data['unit_width'],
|
||||
unit_height=data['unit_height'],
|
||||
legend_width=data['legend_width'],
|
||||
include_images=data['include_images']
|
||||
)
|
||||
return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
|
||||
|
||||
else:
|
||||
@ -231,6 +237,11 @@ class RackViewSet(CustomFieldModelViewSet):
|
||||
expand_devices=data['expand_devices']
|
||||
)
|
||||
|
||||
# Enable filtering rack units by ID
|
||||
q = data['q']
|
||||
if q:
|
||||
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
|
||||
|
||||
page = self.paginate_queryset(elevation)
|
||||
if page is not None:
|
||||
rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request})
|
||||
|
@ -195,6 +195,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_DE9 = 'de-9'
|
||||
TYPE_DB25 = 'db-25'
|
||||
TYPE_RJ11 = 'rj-11'
|
||||
TYPE_RJ12 = 'rj-12'
|
||||
TYPE_RJ45 = 'rj-45'
|
||||
TYPE_USB_A = 'usb-a'
|
||||
@ -210,6 +211,7 @@ class ConsolePortTypeChoices(ChoiceSet):
|
||||
('Serial', (
|
||||
(TYPE_DE9, 'DE-9'),
|
||||
(TYPE_DB25, 'DB-25'),
|
||||
(TYPE_RJ11, 'RJ-11'),
|
||||
(TYPE_RJ12, 'RJ-12'),
|
||||
(TYPE_RJ45, 'RJ-45'),
|
||||
)),
|
||||
@ -971,10 +973,12 @@ class CableStatusChoices(ChoiceSet):
|
||||
|
||||
STATUS_CONNECTED = 'connected'
|
||||
STATUS_PLANNED = 'planned'
|
||||
STATUS_DECOMMISSIONING = 'decommissioning'
|
||||
|
||||
CHOICES = (
|
||||
(STATUS_CONNECTED, 'Connected'),
|
||||
(STATUS_PLANNED, 'Planned'),
|
||||
(STATUS_DECOMMISSIONING, 'Decommissioning'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
|
@ -9,10 +9,10 @@ from .choices import InterfaceTypeChoices
|
||||
|
||||
RACK_U_HEIGHT_DEFAULT = 42
|
||||
|
||||
RACK_ELEVATION_BORDER_WIDTH = 2
|
||||
RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
|
||||
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230
|
||||
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20
|
||||
RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 220
|
||||
RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 22
|
||||
|
||||
|
||||
#
|
||||
@ -61,13 +61,10 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
|
||||
# Cabling and connections
|
||||
#
|
||||
|
||||
# TODO: Replace with CableStatusChoices?
|
||||
# Console/power/interface connection statuses
|
||||
CONNECTION_STATUS_PLANNED = False
|
||||
CONNECTION_STATUS_CONNECTED = True
|
||||
CONNECTION_STATUS_CHOICES = [
|
||||
[CONNECTION_STATUS_PLANNED, 'Planned'],
|
||||
[CONNECTION_STATUS_CONNECTED, 'Connected'],
|
||||
[False, 'Not Connected'],
|
||||
[True, 'Connected'],
|
||||
]
|
||||
|
||||
# Cable endpoint types
|
||||
|
204
netbox/dcim/elevations.py
Normal file
204
netbox/dcim/elevations.py
Normal file
@ -0,0 +1,204 @@
|
||||
import svgwrite
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
|
||||
from utilities.utils import foreground_color
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import RACK_ELEVATION_BORDER_WIDTH
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
"""
|
||||
Use this class to render a rack elevation as an SVG image.
|
||||
|
||||
:param rack: A NetBox Rack instance
|
||||
:param include_images: If true, the SVG document will embed front/rear device face images, where available
|
||||
"""
|
||||
def __init__(self, rack, include_images=True):
|
||||
self.rack = rack
|
||||
self.include_images = include_images
|
||||
|
||||
@staticmethod
|
||||
def _add_gradient(drawing, id_, color):
|
||||
gradient = drawing.linearGradient(
|
||||
start=(0, 0),
|
||||
end=(0, 25),
|
||||
spreadMethod='repeat',
|
||||
id_=id_,
|
||||
gradientTransform='rotate(45, 0, 0)',
|
||||
gradientUnits='userSpaceOnUse'
|
||||
)
|
||||
gradient.add_stop_color(offset='0%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color=color)
|
||||
gradient.add_stop_color(offset='100%', color=color)
|
||||
drawing.defs.add(gradient)
|
||||
|
||||
@staticmethod
|
||||
def _setup_drawing(width, height):
|
||||
drawing = svgwrite.Drawing(size=(width, height))
|
||||
|
||||
# add the stylesheet
|
||||
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# add gradients
|
||||
RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
|
||||
RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
|
||||
return drawing
|
||||
|
||||
def _draw_device_front(self, drawing, device, start, end, text):
|
||||
name = str(device)
|
||||
if device.devicebay_count:
|
||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
||||
|
||||
color = device.device_role.color
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href=reverse('dcim:device', kwargs={'pk': device.pk}),
|
||||
target='_top',
|
||||
fill='black'
|
||||
)
|
||||
)
|
||||
link.set_desc('{} — {} ({}U) {} {}'.format(
|
||||
device.device_role, device.device_type.display_name,
|
||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||
))
|
||||
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
|
||||
hex_color = '#{}'.format(foreground_color(color))
|
||||
link.add(drawing.text(str(name), insert=text, fill=hex_color))
|
||||
|
||||
# Embed front device type image if one exists
|
||||
if self.include_images and device.device_type.front_image:
|
||||
url = device.device_type.front_image.url
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image.fit(scale='slice')
|
||||
link.add(image)
|
||||
|
||||
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||
rect = drawing.rect(start, end, class_="slot blocked")
|
||||
rect.set_desc('{} — {} ({}U) {} {}'.format(
|
||||
device.device_role, device.device_type.display_name,
|
||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||
))
|
||||
drawing.add(rect)
|
||||
drawing.add(drawing.text(str(device), insert=text))
|
||||
|
||||
# Embed rear device type image if one exists
|
||||
if self.include_images and device.device_type.rear_image:
|
||||
url = device.device_type.rear_image.url
|
||||
image = drawing.image(href=url, insert=start, size=end, class_='device-image')
|
||||
image.fit(scale='slice')
|
||||
drawing.add(image)
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}?{}'.format(
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
|
||||
),
|
||||
target='_top'
|
||||
)
|
||||
)
|
||||
if reservation:
|
||||
link.set_desc('{} — {} · {}'.format(
|
||||
reservation.description, reservation.user, reservation.created
|
||||
))
|
||||
link.add(drawing.rect(start, end, class_=class_))
|
||||
link.add(drawing.text("add device", insert=text, class_='add-device'))
|
||||
|
||||
def merge_elevations(self, face):
|
||||
elevation = self.rack.get_rack_units(face=face, expand_devices=False)
|
||||
if face == DeviceFaceChoices.FACE_REAR:
|
||||
other_face = DeviceFaceChoices.FACE_FRONT
|
||||
else:
|
||||
other_face = DeviceFaceChoices.FACE_REAR
|
||||
other = self.rack.get_rack_units(face=other_face)
|
||||
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device']:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
||||
return elevation
|
||||
|
||||
def render(self, face, unit_width, unit_height, legend_width):
|
||||
"""
|
||||
Return an SVG document representing a rack elevation.
|
||||
"""
|
||||
drawing = self._setup_drawing(
|
||||
unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
|
||||
unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
|
||||
)
|
||||
reserved_units = self.rack.get_reserved_units()
|
||||
|
||||
unit_cursor = 0
|
||||
for ru in range(0, self.rack.u_height):
|
||||
start_y = ru * unit_height
|
||||
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
|
||||
unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
|
||||
drawing.add(
|
||||
drawing.text(str(unit), position_coordinates, class_="unit")
|
||||
)
|
||||
|
||||
for unit in self.merge_elevations(face):
|
||||
|
||||
# Loop through all units in the elevation
|
||||
device = unit['device']
|
||||
height = unit.get('height', 1)
|
||||
|
||||
# Setup drawing coordinates
|
||||
x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
|
||||
y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
|
||||
end_y = unit_height * height
|
||||
start_cordinates = (x_offset, y_offset)
|
||||
end_cordinates = (unit_width, end_y)
|
||||
text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
|
||||
|
||||
# Draw the device
|
||||
if device and device.face == face:
|
||||
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
elif device and device.device_type.is_full_depth:
|
||||
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
else:
|
||||
# Draw shallow devices, reservations, or empty units
|
||||
class_ = 'slot'
|
||||
reservation = reserved_units.get(unit["id"])
|
||||
if device:
|
||||
class_ += ' occupied'
|
||||
if reservation:
|
||||
class_ += ' reserved'
|
||||
self._draw_empty(
|
||||
drawing,
|
||||
self.rack,
|
||||
start_cordinates,
|
||||
end_cordinates,
|
||||
text_cordinates,
|
||||
unit["id"],
|
||||
face,
|
||||
class_,
|
||||
reservation
|
||||
)
|
||||
|
||||
unit_cursor += height
|
||||
|
||||
# Wrap the drawing with a border
|
||||
border_width = RACK_ELEVATION_BORDER_WIDTH
|
||||
border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
|
||||
frame = drawing.rect(
|
||||
insert=(legend_width + border_offset, border_offset),
|
||||
size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
|
||||
class_='rack'
|
||||
)
|
||||
drawing.add(frame)
|
||||
|
||||
return drawing
|
@ -385,7 +385,6 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
class RackGroupForm(BootstrapMixin, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/sites/"
|
||||
)
|
||||
@ -931,8 +930,8 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
|
||||
class Meta:
|
||||
model = DeviceType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments',
|
||||
'tags',
|
||||
'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'front_image', 'rear_image', 'comments', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'subdevice_role': StaticSelect2()
|
||||
@ -2764,6 +2763,7 @@ class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
@ -2821,6 +2821,12 @@ class PowerOutletBulkDisconnectForm(ConfirmationForm):
|
||||
|
||||
class InterfaceFilterForm(DeviceComponentFilterForm):
|
||||
model = Interface
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect2(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
@ -3061,6 +3067,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
|
||||
device = forms.ModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
disabled=True,
|
||||
widget=forms.HiddenInput()
|
||||
)
|
||||
type = forms.ChoiceField(
|
||||
@ -4522,7 +4529,6 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
class PowerPanelForm(BootstrapMixin, forms.ModelForm):
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
widget=APISelect(
|
||||
api_url="/api/dcim/sites/",
|
||||
filter_for={
|
||||
|
@ -1,19 +0,0 @@
|
||||
from django.db.models import Manager, QuerySet
|
||||
|
||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||
|
||||
|
||||
class InterfaceQuerySet(QuerySet):
|
||||
|
||||
def connectable(self):
|
||||
"""
|
||||
Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or
|
||||
wireless).
|
||||
"""
|
||||
return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES)
|
||||
|
||||
|
||||
class InterfaceManager(Manager):
|
||||
|
||||
def get_queryset(self):
|
||||
return InterfaceQuerySet(self.model, using=self._db)
|
20
netbox/dcim/migrations/0097_interfacetemplate_type_other.py
Normal file
20
netbox/dcim/migrations/0097_interfacetemplate_type_other.py
Normal file
@ -0,0 +1,20 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def interfacetemplate_type_to_slug(apps, schema_editor):
|
||||
InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate')
|
||||
InterfaceTemplate.objects.filter(type=32767).update(type='other')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0096_interface_ordering'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
# Missed type "other" in the initial migration (see #3967)
|
||||
migrations.RunPython(
|
||||
code=interfacetemplate_type_to_slug
|
||||
),
|
||||
]
|
23
netbox/dcim/migrations/0098_devicetype_images.py
Normal file
23
netbox/dcim/migrations/0098_devicetype_images.py
Normal file
@ -0,0 +1,23 @@
|
||||
# Generated by Django 2.2.9 on 2020-02-20 15:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0097_interfacetemplate_type_other'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='front_image',
|
||||
field=models.ImageField(blank=True, upload_to='devicetype-images'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='devicetype',
|
||||
name='rear_image',
|
||||
field=models.ImageField(blank=True, upload_to='devicetype-images'),
|
||||
),
|
||||
]
|
@ -1,7 +1,6 @@
|
||||
from collections import OrderedDict
|
||||
from itertools import count, groupby
|
||||
|
||||
import svgwrite
|
||||
import yaml
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import User
|
||||
@ -13,7 +12,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Count, F, ProtectedError, Sum
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
from timezone_field import TimeZoneField
|
||||
@ -21,10 +19,11 @@ from timezone_field import TimeZoneField
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import ASNField
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
|
||||
from dcim.elevations import RackElevationSVG
|
||||
from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.models import ChangeLoggedModel
|
||||
from utilities.utils import foreground_color, to_meters
|
||||
from utilities.utils import serialize_object, to_meters
|
||||
from .device_component_templates import (
|
||||
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate, InterfaceTemplate,
|
||||
PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
@ -119,6 +118,15 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
Q(region__in=self.get_descendants())
|
||||
).count()
|
||||
|
||||
def to_objectchange(self, action):
|
||||
# Remove MPTT-internal fields
|
||||
return ObjectChange(
|
||||
changed_object=self,
|
||||
object_repr=str(self),
|
||||
action=action,
|
||||
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
@ -350,180 +358,7 @@ class RackRole(ChangeLoggedModel):
|
||||
)
|
||||
|
||||
|
||||
class RackElevationHelperMixin:
|
||||
"""
|
||||
Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of
|
||||
rack units represented as dictionaries, or an SVG of the elevation.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _add_gradient(drawing, id_, color):
|
||||
gradient = drawing.linearGradient(
|
||||
start=('0', '0%'),
|
||||
end=('0', '5%'),
|
||||
spreadMethod='repeat',
|
||||
id_=id_,
|
||||
gradientTransform='rotate(45, 0, 0)',
|
||||
gradientUnits='userSpaceOnUse'
|
||||
)
|
||||
gradient.add_stop_color(offset='0%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color='#f7f7f7')
|
||||
gradient.add_stop_color(offset='50%', color=color)
|
||||
gradient.add_stop_color(offset='100%', color=color)
|
||||
drawing.defs.add(gradient)
|
||||
|
||||
@staticmethod
|
||||
def _setup_drawing(width, height):
|
||||
drawing = svgwrite.Drawing(size=(width, height))
|
||||
|
||||
# add the stylesheet
|
||||
with open('{}/css/rack_elevation.css'.format(settings.STATICFILES_DIRS[0])) as css_file:
|
||||
drawing.defs.add(drawing.style(css_file.read()))
|
||||
|
||||
# add gradients
|
||||
RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff')
|
||||
RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#d7d7d7')
|
||||
RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc0c0')
|
||||
|
||||
return drawing
|
||||
|
||||
@staticmethod
|
||||
def _draw_device_front(drawing, device, start, end, text):
|
||||
name = str(device)
|
||||
if device.devicebay_count:
|
||||
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
|
||||
|
||||
color = device.device_role.color
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href=reverse('dcim:device', kwargs={'pk': device.pk}),
|
||||
target='_top',
|
||||
fill='black'
|
||||
)
|
||||
)
|
||||
link.set_desc('{} — {} ({}U) {} {}'.format(
|
||||
device.device_role, device.device_type.display_name,
|
||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||
))
|
||||
link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
|
||||
hex_color = '#{}'.format(foreground_color(color))
|
||||
link.add(drawing.text(str(name), insert=text, fill=hex_color))
|
||||
|
||||
@staticmethod
|
||||
def _draw_device_rear(drawing, device, start, end, text):
|
||||
rect = drawing.rect(start, end, class_="slot blocked")
|
||||
rect.set_desc('{} — {} ({}U) {} {}'.format(
|
||||
device.device_role, device.device_type.display_name,
|
||||
device.device_type.u_height, device.asset_tag or '', device.serial or ''
|
||||
))
|
||||
drawing.add(rect)
|
||||
drawing.add(drawing.text(str(device), insert=text))
|
||||
|
||||
@staticmethod
|
||||
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
||||
link = drawing.add(
|
||||
drawing.a(
|
||||
href='{}?{}'.format(
|
||||
reverse('dcim:device_add'),
|
||||
urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
|
||||
),
|
||||
target='_top'
|
||||
)
|
||||
)
|
||||
if reservation:
|
||||
link.set_desc('{} — {} · {}'.format(
|
||||
reservation.description, reservation.user, reservation.created
|
||||
))
|
||||
link.add(drawing.rect(start, end, class_=class_))
|
||||
link.add(drawing.text("add device", insert=text, class_='add-device'))
|
||||
|
||||
def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height, legend_width):
|
||||
|
||||
drawing = self._setup_drawing(unit_width + legend_width, unit_height * self.u_height)
|
||||
|
||||
unit_cursor = 0
|
||||
for ru in range(0, self.u_height):
|
||||
start_y = ru * unit_height
|
||||
position_coordinates = (legend_width / 2, start_y + unit_height / 2 + 2)
|
||||
unit = ru + 1 if self.desc_units else self.u_height - ru
|
||||
drawing.add(
|
||||
drawing.text(str(unit), position_coordinates, class_="unit")
|
||||
)
|
||||
|
||||
for unit in elevation:
|
||||
|
||||
# Loop through all units in the elevation
|
||||
device = unit['device']
|
||||
height = unit.get('height', 1)
|
||||
|
||||
# Setup drawing coordinates
|
||||
start_y = unit_cursor * unit_height
|
||||
end_y = unit_height * height
|
||||
start_cordinates = (legend_width, start_y)
|
||||
end_cordinates = (legend_width + unit_width, end_y)
|
||||
text_cordinates = (legend_width + (unit_width / 2), start_y + end_y / 2)
|
||||
|
||||
# Draw the device
|
||||
if device and device.face == face:
|
||||
self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
elif device and device.device_type.is_full_depth:
|
||||
self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
|
||||
else:
|
||||
# Draw shallow devices, reservations, or empty units
|
||||
class_ = 'slot'
|
||||
reservation = reserved_units.get(unit["id"])
|
||||
if device:
|
||||
class_ += ' occupied'
|
||||
if reservation:
|
||||
class_ += ' reserved'
|
||||
self._draw_empty(
|
||||
drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_, reservation
|
||||
)
|
||||
|
||||
unit_cursor += height
|
||||
|
||||
# Wrap the drawing with a border
|
||||
drawing.add(drawing.rect((legend_width, 0), (unit_width, self.u_height * unit_height), class_='rack'))
|
||||
|
||||
return drawing
|
||||
|
||||
def merge_elevations(self, face):
|
||||
elevation = self.get_rack_units(face=face, expand_devices=False)
|
||||
other_face = DeviceFaceChoices.FACE_FRONT if face == DeviceFaceChoices.FACE_REAR else DeviceFaceChoices.FACE_REAR
|
||||
other = self.get_rack_units(face=other_face)
|
||||
|
||||
unit_cursor = 0
|
||||
for u in elevation:
|
||||
o = other[unit_cursor]
|
||||
if not u['device'] and o['device']:
|
||||
u['device'] = o['device']
|
||||
u['height'] = 1
|
||||
unit_cursor += u.get('height', 1)
|
||||
|
||||
return elevation
|
||||
|
||||
def get_elevation_svg(
|
||||
self,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
|
||||
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT
|
||||
):
|
||||
"""
|
||||
Return an SVG of the rack elevation
|
||||
|
||||
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
|
||||
:param width: Width in pixles for the rendered drawing
|
||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||
height of the elevation
|
||||
"""
|
||||
elevation = self.merge_elevations(face)
|
||||
reserved_units = self.get_reserved_units()
|
||||
|
||||
return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height, legend_width)
|
||||
|
||||
|
||||
class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
|
||||
class Rack(ChangeLoggedModel, CustomFieldModel):
|
||||
"""
|
||||
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
|
||||
Each Rack is assigned to a Site and (optionally) a RackGroup.
|
||||
@ -835,6 +670,28 @@ class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin):
|
||||
reserved_units[u] = r
|
||||
return reserved_units
|
||||
|
||||
def get_elevation_svg(
|
||||
self,
|
||||
face=DeviceFaceChoices.FACE_FRONT,
|
||||
unit_width=RACK_ELEVATION_UNIT_WIDTH_DEFAULT,
|
||||
unit_height=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT,
|
||||
legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT,
|
||||
include_images=True
|
||||
):
|
||||
"""
|
||||
Return an SVG of the rack elevation
|
||||
|
||||
:param face: Enum of [front, rear] representing the desired side of the rack elevation to render
|
||||
:param unit_width: Width in pixels for the rendered drawing
|
||||
:param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total
|
||||
height of the elevation
|
||||
:param legend_width: Width of the unit legend, in pixels
|
||||
:param include_images: Embed front/rear device images where available
|
||||
"""
|
||||
elevation = RackElevationSVG(self, include_images=include_images)
|
||||
|
||||
return elevation.render(face, unit_width, unit_height, legend_width)
|
||||
|
||||
def get_0u_devices(self):
|
||||
return self.devices.filter(position=0)
|
||||
|
||||
@ -1025,6 +882,14 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
help_text='Parent devices house child devices in device bays. Leave blank '
|
||||
'if this device type is neither a parent nor a child.'
|
||||
)
|
||||
front_image = models.ImageField(
|
||||
upload_to='devicetype-images',
|
||||
blank=True
|
||||
)
|
||||
rear_image = models.ImageField(
|
||||
upload_to='devicetype-images',
|
||||
blank=True
|
||||
)
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
@ -1056,6 +921,10 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
# Save a copy of u_height for validation in clean()
|
||||
self._original_u_height = self.u_height
|
||||
|
||||
# Save references to the original front/rear images
|
||||
self._original_front_image = self.front_image
|
||||
self._original_rear_image = self.rear_image
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('dcim:devicetype', args=[self.pk])
|
||||
|
||||
@ -1175,6 +1044,26 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel):
|
||||
'u_height': "Child device types must be 0U."
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ret = super().save(*args, **kwargs)
|
||||
|
||||
# Delete any previously uploaded image files that are no longer in use
|
||||
if self.front_image != self._original_front_image:
|
||||
self._original_front_image.delete(save=False)
|
||||
if self.rear_image != self._original_rear_image:
|
||||
self._original_rear_image.delete(save=False)
|
||||
|
||||
return ret
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
super().delete(*args, **kwargs)
|
||||
|
||||
# Delete any uploaded image files
|
||||
if self.front_image:
|
||||
self.front_image.delete(save=False)
|
||||
if self.rear_image:
|
||||
self.rear_image.delete(save=False)
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
return '{} {}'.format(self.manufacturer.name, self.model)
|
||||
@ -2076,6 +1965,7 @@ class Cable(ChangeLoggedModel):
|
||||
STATUS_CLASS_MAP = {
|
||||
CableStatusChoices.STATUS_CONNECTED: 'success',
|
||||
CableStatusChoices.STATUS_PLANNED: 'info',
|
||||
CableStatusChoices.STATUS_DECOMMISSIONING: 'warning',
|
||||
}
|
||||
|
||||
class Meta:
|
||||
@ -2236,14 +2126,14 @@ class Cable(ChangeLoggedModel):
|
||||
b_path = self.termination_a.trace()
|
||||
|
||||
# Determine overall path status (connected or planned)
|
||||
if self.status == CableStatusChoices.STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
else:
|
||||
path_status = CONNECTION_STATUS_CONNECTED
|
||||
if self.status == CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = True
|
||||
for segment in a_path[1:] + b_path[1:]:
|
||||
if segment[1] is None or segment[1].status == CableStatusChoices.STATUS_PLANNED:
|
||||
path_status = CONNECTION_STATUS_PLANNED
|
||||
if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED:
|
||||
path_status = False
|
||||
break
|
||||
else:
|
||||
path_status = False
|
||||
|
||||
a_endpoint = a_path[-1][2]
|
||||
b_endpoint = b_path[-1][2]
|
||||
|
@ -360,9 +360,21 @@ class PowerPort(CableTermination, ComponentModel):
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
if self._connected_poweroutlet:
|
||||
return self._connected_poweroutlet
|
||||
return self._connected_powerfeed
|
||||
"""
|
||||
Return the connected PowerOutlet, if it exists, or the connected PowerFeed, if it exists. We have to check for
|
||||
ObjectDoesNotExist in case the referenced object has been deleted from the database.
|
||||
"""
|
||||
try:
|
||||
if self._connected_poweroutlet:
|
||||
return self._connected_poweroutlet
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
try:
|
||||
if self._connected_powerfeed:
|
||||
return self._connected_powerfeed
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
@connected_endpoint.setter
|
||||
def connected_endpoint(self, value):
|
||||
@ -717,9 +729,21 @@ class Interface(CableTermination, ComponentModel):
|
||||
|
||||
@property
|
||||
def connected_endpoint(self):
|
||||
if self._connected_interface:
|
||||
return self._connected_interface
|
||||
return self._connected_circuittermination
|
||||
"""
|
||||
Return the connected Interface, if it exists, or the connected CircuitTermination, if it exists. We have to
|
||||
check for ObjectDoesNotExist in case the referenced object has been deleted from the database.
|
||||
"""
|
||||
try:
|
||||
if self._connected_interface:
|
||||
return self._connected_interface
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
try:
|
||||
if self._connected_circuittermination:
|
||||
return self._connected_circuittermination
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
@connected_endpoint.setter
|
||||
def connected_endpoint(self, value):
|
||||
|
@ -41,7 +41,7 @@ DEVICE_LINK = """
|
||||
"""
|
||||
|
||||
REGION_ACTIONS = """
|
||||
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:region_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_region %}
|
||||
@ -50,7 +50,7 @@ REGION_ACTIONS = """
|
||||
"""
|
||||
|
||||
RACKGROUP_ACTIONS = """
|
||||
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:rackgroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&group_id={{ record.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """
|
||||
"""
|
||||
|
||||
RACKROLE_ACTIONS = """
|
||||
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:rackrole_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackrole %}
|
||||
@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """
|
||||
"""
|
||||
|
||||
RACKRESERVATION_ACTIONS = """
|
||||
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:rackreservation_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """
|
||||
"""
|
||||
|
||||
MANUFACTURER_ACTIONS = """
|
||||
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:manufacturer_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_manufacturer %}
|
||||
@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """
|
||||
"""
|
||||
|
||||
DEVICEROLE_ACTIONS = """
|
||||
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:devicerole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_devicerole %}
|
||||
@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """
|
||||
"""
|
||||
|
||||
PLATFORM_ACTIONS = """
|
||||
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:platform_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_platform %}
|
||||
@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """
|
||||
"""
|
||||
|
||||
VIRTUALCHASSIS_ACTIONS = """
|
||||
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'dcim:virtualchassis_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.dcim.change_virtualchassis %}
|
||||
@ -795,11 +795,12 @@ class InterfaceTable(BaseTable):
|
||||
class InterfaceDetailTable(DeviceComponentDetailTable):
|
||||
parent = tables.LinkColumn(order_by=('device', 'virtual_machine'))
|
||||
name = tables.LinkColumn()
|
||||
enabled = BooleanColumn()
|
||||
|
||||
class Meta(InterfaceTable.Meta):
|
||||
order_by = ('parent', 'name')
|
||||
fields = ('pk', 'parent', 'name', 'type', 'description', 'cable')
|
||||
sequence = ('pk', 'parent', 'name', 'type', 'description', 'cable')
|
||||
fields = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||
sequence = ('pk', 'parent', 'name', 'enabled', 'type', 'description', 'cable')
|
||||
|
||||
|
||||
class FrontPortTable(BaseTable):
|
||||
|
@ -596,6 +596,28 @@ class RackTest(APITestCase):
|
||||
|
||||
self.assertEqual(response.data['count'], 42)
|
||||
|
||||
def test_get_elevation_rack_units(self):
|
||||
|
||||
url = '{}?q=3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 13)
|
||||
|
||||
url = '{}?q=U3'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 11)
|
||||
|
||||
url = '{}?q=10'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
url = '{}?q=U20'.format(reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk}))
|
||||
response = self.client.get(url, **self.header)
|
||||
|
||||
self.assertEqual(response.data['count'], 1)
|
||||
|
||||
def test_get_rack_elevation(self):
|
||||
|
||||
url = reverse('dcim-api:rack-elevation', kwargs={'pk': self.rack1.pk})
|
||||
@ -1448,13 +1470,13 @@ class InterfaceTemplateTest(APITestCase):
|
||||
manufacturer=self.manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
self.interfacetemplate1 = InterfaceTemplate.objects.create(
|
||||
device_type=self.devicetype, name='Test Interface Template 1'
|
||||
device_type=self.devicetype, name='Test Interface Template 1', type='1000base-t'
|
||||
)
|
||||
self.interfacetemplate2 = InterfaceTemplate.objects.create(
|
||||
device_type=self.devicetype, name='Test Interface Template 2'
|
||||
device_type=self.devicetype, name='Test Interface Template 2', type='1000base-t'
|
||||
)
|
||||
self.interfacetemplate3 = InterfaceTemplate.objects.create(
|
||||
device_type=self.devicetype, name='Test Interface Template 3'
|
||||
device_type=self.devicetype, name='Test Interface Template 3', type='1000base-t'
|
||||
)
|
||||
|
||||
def test_get_interfacetemplate(self):
|
||||
@ -1476,6 +1498,7 @@ class InterfaceTemplateTest(APITestCase):
|
||||
data = {
|
||||
'device_type': self.devicetype.pk,
|
||||
'name': 'Test Interface Template 4',
|
||||
'type': '1000base-t',
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:interfacetemplate-list')
|
||||
@ -1493,14 +1516,17 @@ class InterfaceTemplateTest(APITestCase):
|
||||
{
|
||||
'device_type': self.devicetype.pk,
|
||||
'name': 'Test Interface Template 4',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'device_type': self.devicetype.pk,
|
||||
'name': 'Test Interface Template 5',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'device_type': self.devicetype.pk,
|
||||
'name': 'Test Interface Template 6',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
]
|
||||
|
||||
@ -1518,6 +1544,7 @@ class InterfaceTemplateTest(APITestCase):
|
||||
data = {
|
||||
'device_type': self.devicetype.pk,
|
||||
'name': 'Test Interface Template X',
|
||||
'type': '1000base-x-gbic',
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:interfacetemplate-detail', kwargs={'pk': self.interfacetemplate1.pk})
|
||||
@ -2628,9 +2655,9 @@ class InterfaceTest(APITestCase):
|
||||
self.device = Device.objects.create(
|
||||
device_type=devicetype, device_role=devicerole, name='Test Device 1', site=site
|
||||
)
|
||||
self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1')
|
||||
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2')
|
||||
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3')
|
||||
self.interface1 = Interface.objects.create(device=self.device, name='Test Interface 1', type='1000base-t')
|
||||
self.interface2 = Interface.objects.create(device=self.device, name='Test Interface 2', type='1000base-t')
|
||||
self.interface3 = Interface.objects.create(device=self.device, name='Test Interface 3', type='1000base-t')
|
||||
|
||||
self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1)
|
||||
self.vlan2 = VLAN.objects.create(name="Test VLAN 2", vid=2)
|
||||
@ -2691,6 +2718,7 @@ class InterfaceTest(APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'type': '1000base-t',
|
||||
}
|
||||
|
||||
url = reverse('dcim-api:interface-list')
|
||||
@ -2707,6 +2735,7 @@ class InterfaceTest(APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': self.vlan3.id,
|
||||
'tagged_vlans': [self.vlan1.id, self.vlan2.id],
|
||||
@ -2728,14 +2757,17 @@ class InterfaceTest(APITestCase):
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 5',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 6',
|
||||
'type': '1000base-t',
|
||||
},
|
||||
]
|
||||
|
||||
@ -2754,6 +2786,7 @@ class InterfaceTest(APITestCase):
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 4',
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
@ -2761,6 +2794,7 @@ class InterfaceTest(APITestCase):
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 5',
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
@ -2768,6 +2802,7 @@ class InterfaceTest(APITestCase):
|
||||
{
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface 6',
|
||||
'type': '1000base-t',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': self.vlan2.id,
|
||||
'tagged_vlans': [self.vlan1.id],
|
||||
@ -2793,6 +2828,7 @@ class InterfaceTest(APITestCase):
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Test Interface X',
|
||||
'type': '1000base-x-gbic',
|
||||
'lag': lag_interface.pk,
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import CONNECTION_STATUS_CONNECTED, CONNECTION_STATUS_PLANNED
|
||||
from dcim.models import *
|
||||
from tenancy.models import Tenant
|
||||
|
||||
@ -522,14 +521,14 @@ class CablePathTestCase(TestCase):
|
||||
cable3.save()
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertEqual(interface1.connected_endpoint, self.interface2)
|
||||
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_PLANNED)
|
||||
self.assertFalse(interface1.connection_status)
|
||||
|
||||
# Switch third segment from planned to connected
|
||||
cable3.status = CableStatusChoices.STATUS_CONNECTED
|
||||
cable3.save()
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertEqual(interface1.connected_endpoint, self.interface2)
|
||||
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
|
||||
self.assertTrue(interface1.connection_status)
|
||||
|
||||
def test_path_teardown(self):
|
||||
|
||||
@ -542,7 +541,7 @@ class CablePathTestCase(TestCase):
|
||||
cable3.save()
|
||||
interface1 = Interface.objects.get(pk=self.interface1.pk)
|
||||
self.assertEqual(interface1.connected_endpoint, self.interface2)
|
||||
self.assertEqual(interface1.connection_status, CONNECTION_STATUS_CONNECTED)
|
||||
self.assertTrue(interface1.connection_status)
|
||||
|
||||
# Remove a cable
|
||||
cable2.delete()
|
||||
|
@ -31,6 +31,7 @@ from utilities.views import (
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filters, forms, tables
|
||||
from .choices import DeviceFaceChoices
|
||||
from .constants import NONCONNECTABLE_IFACE_TYPES
|
||||
from .models import (
|
||||
Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
|
||||
DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
|
||||
@ -356,7 +357,7 @@ class RackElevationListView(PermissionRequiredMixin, View):
|
||||
|
||||
def get(self, request):
|
||||
|
||||
racks = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role', 'devices__device_type')
|
||||
racks = Rack.objects.prefetch_related('role')
|
||||
racks = filters.RackFilterSet(request.GET, racks).qs
|
||||
total_count = racks.count()
|
||||
|
||||
@ -1181,7 +1182,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View):
|
||||
def get(self, request, pk):
|
||||
|
||||
device = get_object_or_404(Device, pk=pk)
|
||||
interfaces = device.vc_interfaces.connectable().prefetch_related(
|
||||
interfaces = device.vc_interfaces.exclude(type__in=NONCONNECTABLE_IFACE_TYPES).prefetch_related(
|
||||
'_connected_interface__device'
|
||||
)
|
||||
|
||||
|
@ -26,7 +26,7 @@ class WebhookForm(forms.ModelForm):
|
||||
|
||||
class Meta:
|
||||
model = Webhook
|
||||
exclude = []
|
||||
exclude = ()
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@ -38,13 +38,35 @@ class WebhookForm(forms.ModelForm):
|
||||
@admin.register(Webhook, site=admin_site)
|
||||
class WebhookAdmin(admin.ModelAdmin):
|
||||
list_display = [
|
||||
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
|
||||
'type_delete', 'ssl_verification',
|
||||
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete',
|
||||
'ssl_verification',
|
||||
]
|
||||
list_filter = [
|
||||
'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
|
||||
]
|
||||
form = WebhookForm
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': (
|
||||
'name', 'obj_type', 'enabled',
|
||||
)
|
||||
}),
|
||||
('Events', {
|
||||
'fields': (
|
||||
'type_create', 'type_update', 'type_delete',
|
||||
)
|
||||
}),
|
||||
('HTTP Request', {
|
||||
'fields': (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
)
|
||||
}),
|
||||
('SSL', {
|
||||
'fields': (
|
||||
'ssl_verification', 'ca_file_path',
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
def models(self, obj):
|
||||
return ', '.join([ct.name for ct in obj.obj_type.all()])
|
||||
|
@ -40,10 +40,14 @@ class GraphSerializer(ValidatedModelSerializer):
|
||||
|
||||
|
||||
class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
embed_url = serializers.SerializerMethodField()
|
||||
embed_link = serializers.SerializerMethodField()
|
||||
embed_url = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
embed_link = serializers.SerializerMethodField(
|
||||
read_only=True
|
||||
)
|
||||
type = ContentTypeField(
|
||||
queryset=ContentType.objects.all()
|
||||
read_only=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -62,6 +66,9 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
|
||||
#
|
||||
|
||||
class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
content_type = ContentTypeField(
|
||||
queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
|
||||
)
|
||||
template_language = ChoiceField(
|
||||
choices=TemplateLanguageChoices,
|
||||
default=TemplateLanguageChoices.LANGUAGE_JINJA2
|
||||
|
@ -124,17 +124,18 @@ class TemplateLanguageChoices(ChoiceSet):
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
class WebhookContentTypeChoices(ChoiceSet):
|
||||
class WebhookHttpMethodChoices(ChoiceSet):
|
||||
|
||||
CONTENTTYPE_JSON = 'application/json'
|
||||
CONTENTTYPE_FORMDATA = 'application/x-www-form-urlencoded'
|
||||
METHOD_GET = 'GET'
|
||||
METHOD_POST = 'POST'
|
||||
METHOD_PUT = 'PUT'
|
||||
METHOD_PATCH = 'PATCH'
|
||||
METHOD_DELETE = 'DELETE'
|
||||
|
||||
CHOICES = (
|
||||
(CONTENTTYPE_JSON, 'JSON'),
|
||||
(CONTENTTYPE_FORMDATA, 'Form data'),
|
||||
(METHOD_GET, 'GET'),
|
||||
(METHOD_POST, 'POST'),
|
||||
(METHOD_PUT, 'PUT'),
|
||||
(METHOD_PATCH, 'PATCH'),
|
||||
(METHOD_DELETE, 'DELETE'),
|
||||
)
|
||||
|
||||
LEGACY_MAP = {
|
||||
CONTENTTYPE_JSON: 1,
|
||||
CONTENTTYPE_FORMDATA: 2,
|
||||
}
|
||||
|
@ -138,6 +138,8 @@ LOG_LEVEL_CODES = {
|
||||
LOG_FAILURE: 'failure',
|
||||
}
|
||||
|
||||
HTTP_CONTENT_TYPE_JSON = 'application/json'
|
||||
|
||||
# Models which support registered webhooks
|
||||
WEBHOOK_MODELS = Q(
|
||||
Q(app_label='circuits', model__in=[
|
||||
|
@ -5,11 +5,14 @@ from copy import deepcopy
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
from django.db.models.signals import pre_delete, post_save
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from extras.utils import is_taggable
|
||||
from utilities.api import is_api_request
|
||||
from utilities.querysets import DummyQuerySet
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .models import ObjectChange
|
||||
@ -98,7 +101,12 @@ class ObjectChangeMiddleware(object):
|
||||
if not _thread_locals.changed_objects:
|
||||
return response
|
||||
|
||||
# Disconnect our receivers from the post_save and post_delete signals.
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
|
||||
# Create records for any cached objects that were changed.
|
||||
redis_failed = False
|
||||
for instance, action in _thread_locals.changed_objects:
|
||||
|
||||
# Refresh cached custom field values
|
||||
@ -114,7 +122,16 @@ class ObjectChangeMiddleware(object):
|
||||
objectchange.save()
|
||||
|
||||
# Enqueue webhooks
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
try:
|
||||
enqueue_webhooks(instance, request.user, request.id, action)
|
||||
except RedisError as e:
|
||||
if not redis_failed and not is_api_request(request):
|
||||
messages.error(
|
||||
request,
|
||||
"There was an error processing webhooks for this request. Check that the Redis service is "
|
||||
"running and reachable. The full error details were: {}".format(e)
|
||||
)
|
||||
redis_failed = True
|
||||
|
||||
# Increment metric counters
|
||||
if action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||
|
48
netbox/extras/migrations/0038_webhook_template_support.py
Normal file
48
netbox/extras/migrations/0038_webhook_template_support.py
Normal file
@ -0,0 +1,48 @@
|
||||
import json
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def json_to_text(apps, schema_editor):
|
||||
"""
|
||||
Convert a JSON representation of HTTP headers to key-value pairs (one header per line)
|
||||
"""
|
||||
Webhook = apps.get_model('extras', 'Webhook')
|
||||
for webhook in Webhook.objects.exclude(additional_headers=''):
|
||||
data = json.loads(webhook.additional_headers)
|
||||
headers = ['{}: {}'.format(k, v) for k, v in data.items()]
|
||||
Webhook.objects.filter(pk=webhook.pk).update(additional_headers='\n'.join(headers))
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0037_configcontexts_clusters'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='http_method',
|
||||
field=models.CharField(default='POST', max_length=30),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='webhook',
|
||||
name='body_template',
|
||||
field=models.TextField(blank=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='additional_headers',
|
||||
field=models.TextField(blank=True, default=''),
|
||||
preserve_default=False,
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='webhook',
|
||||
name='http_content_type',
|
||||
field=models.CharField(default='application/json', max_length=100),
|
||||
),
|
||||
migrations.RunPython(
|
||||
code=json_to_text
|
||||
),
|
||||
]
|
@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from datetime import date
|
||||
|
||||
@ -12,6 +13,7 @@ from django.http import HttpResponse
|
||||
from django.template import Template, Context
|
||||
from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from utilities.fields import ColorField
|
||||
@ -52,7 +54,6 @@ class Webhook(models.Model):
|
||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||
Each Webhook can be limited to firing only on certain actions or certain object types.
|
||||
"""
|
||||
|
||||
obj_type = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='webhooks',
|
||||
@ -81,17 +82,33 @@ class Webhook(models.Model):
|
||||
verbose_name='URL',
|
||||
help_text="A POST will be sent to this URL when the webhook is called."
|
||||
)
|
||||
http_content_type = models.CharField(
|
||||
max_length=50,
|
||||
choices=WebhookContentTypeChoices,
|
||||
default=WebhookContentTypeChoices.CONTENTTYPE_JSON,
|
||||
verbose_name='HTTP content type'
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
additional_headers = JSONField(
|
||||
null=True,
|
||||
http_method = models.CharField(
|
||||
max_length=30,
|
||||
choices=WebhookHttpMethodChoices,
|
||||
default=WebhookHttpMethodChoices.METHOD_POST,
|
||||
verbose_name='HTTP method'
|
||||
)
|
||||
http_content_type = models.CharField(
|
||||
max_length=100,
|
||||
default=HTTP_CONTENT_TYPE_JSON,
|
||||
verbose_name='HTTP content type',
|
||||
help_text='The complete list of official content types is available '
|
||||
'<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.'
|
||||
)
|
||||
additional_headers = models.TextField(
|
||||
blank=True,
|
||||
help_text="User supplied headers which should be added to the request in addition to the HTTP content type. "
|
||||
"Headers are supplied as key/value pairs in a JSON object."
|
||||
help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
|
||||
"Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
|
||||
"support with the same context as the request body (below)."
|
||||
)
|
||||
body_template = models.TextField(
|
||||
blank=True,
|
||||
help_text='Jinja2 template for a custom request body. If blank, a JSON object representing the change will be '
|
||||
'included. Available context data includes: <code>event</code>, <code>model</code>, '
|
||||
'<code>timestamp</code>, <code>username</code>, <code>request_id</code>, and <code>data</code>.'
|
||||
)
|
||||
secret = models.CharField(
|
||||
max_length=255,
|
||||
@ -101,9 +118,6 @@ class Webhook(models.Model):
|
||||
"the secret as the key. The secret is not transmitted in "
|
||||
"the request."
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
ssl_verification = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name='SSL verification',
|
||||
@ -126,9 +140,6 @@ class Webhook(models.Model):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validate model
|
||||
"""
|
||||
if not self.type_create and not self.type_delete and not self.type_update:
|
||||
raise ValidationError(
|
||||
"You must select at least one type: create, update, and/or delete."
|
||||
@ -136,14 +147,30 @@ class Webhook(models.Model):
|
||||
|
||||
if not self.ssl_verification and self.ca_file_path:
|
||||
raise ValidationError({
|
||||
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.'
|
||||
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
|
||||
})
|
||||
|
||||
# Verify that JSON data is provided as an object
|
||||
if self.additional_headers and type(self.additional_headers) is not dict:
|
||||
raise ValidationError({
|
||||
'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}'
|
||||
})
|
||||
def render_headers(self, context):
|
||||
"""
|
||||
Render additional_headers and return a dict of Header: Value pairs.
|
||||
"""
|
||||
if not self.additional_headers:
|
||||
return {}
|
||||
ret = {}
|
||||
data = render_jinja2(self.additional_headers, context)
|
||||
for line in data.splitlines():
|
||||
header, value = line.split(':')
|
||||
ret[header.strip()] = value.strip()
|
||||
return ret
|
||||
|
||||
def render_body(self, context):
|
||||
"""
|
||||
Render the body template, if defined. Otherwise, jump the context as a JSON object.
|
||||
"""
|
||||
if self.body_template:
|
||||
return render_jinja2(self.body_template, context)
|
||||
else:
|
||||
return json.dumps(context, cls=JSONEncoder)
|
||||
|
||||
|
||||
#
|
||||
|
@ -63,10 +63,6 @@ class ScriptVariable:
|
||||
self.field_attrs['widget'] = widget
|
||||
self.field_attrs['required'] = required
|
||||
|
||||
# Initialize the list of optional validators if none have already been defined
|
||||
if 'validators' not in self.field_attrs:
|
||||
self.field_attrs['validators'] = []
|
||||
|
||||
def as_field(self):
|
||||
"""
|
||||
Render the variable as a Django form field.
|
||||
@ -227,14 +223,12 @@ class IPNetworkVar(ScriptVariable):
|
||||
An IPv4 or IPv6 prefix.
|
||||
"""
|
||||
form_field = IPNetworkFormField
|
||||
field_attrs = {
|
||||
'validators': [prefix_validator]
|
||||
}
|
||||
|
||||
def __init__(self, min_prefix_length=None, max_prefix_length=None, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Optional minimum/maximum prefix lengths
|
||||
# Set prefix validator and optional minimum/maximum prefix lengths
|
||||
self.field_attrs['validators'] = [prefix_validator]
|
||||
if min_prefix_length is not None:
|
||||
self.field_attrs['validators'].append(
|
||||
MinPrefixLengthValidator(min_prefix_length)
|
||||
@ -292,7 +286,7 @@ class BaseScript:
|
||||
|
||||
return vars
|
||||
|
||||
def run(self, data):
|
||||
def run(self, data, commit):
|
||||
raise NotImplementedError("The script must define a run() method.")
|
||||
|
||||
def as_form(self, data=None, files=None, initial=None):
|
||||
@ -389,10 +383,17 @@ def run_script(script, data, request, commit=True):
|
||||
# Add the current request as a property of the script
|
||||
script.request = request
|
||||
|
||||
# Determine whether the script accepts a 'commit' argument (this was introduced in v2.7.8)
|
||||
kwargs = {
|
||||
'data': data
|
||||
}
|
||||
if 'commit' in inspect.signature(script.run).parameters:
|
||||
kwargs['commit'] = commit
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
start_time = time.time()
|
||||
output = script.run(data)
|
||||
output = script.run(**kwargs)
|
||||
end_time = time.time()
|
||||
if not commit:
|
||||
raise AbortTransaction()
|
||||
|
@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn
|
||||
from .models import ConfigContext, ObjectChange, Tag, TaggedItem
|
||||
|
||||
TAG_ACTIONS = """
|
||||
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'extras:tag_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.taggit.change_tag %}
|
||||
|
@ -163,17 +163,17 @@ class ExportTemplateTest(APITestCase):
|
||||
|
||||
super().setUp()
|
||||
|
||||
self.content_type = ContentType.objects.get_for_model(Device)
|
||||
content_type = ContentType.objects.get_for_model(Device)
|
||||
self.exporttemplate1 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 1',
|
||||
content_type=content_type, name='Test Export Template 1',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate2 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 2',
|
||||
content_type=content_type, name='Test Export Template 2',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
self.exporttemplate3 = ExportTemplate.objects.create(
|
||||
content_type=self.content_type, name='Test Export Template 3',
|
||||
content_type=content_type, name='Test Export Template 3',
|
||||
template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}'
|
||||
)
|
||||
|
||||
@ -194,7 +194,7 @@ class ExportTemplateTest(APITestCase):
|
||||
def test_create_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
'content_type': self.content_type.pk,
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
}
|
||||
@ -205,7 +205,7 @@ class ExportTemplateTest(APITestCase):
|
||||
self.assertHttpStatus(response, status.HTTP_201_CREATED)
|
||||
self.assertEqual(ExportTemplate.objects.count(), 4)
|
||||
exporttemplate4 = ExportTemplate.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(exporttemplate4.content_type_id, data['content_type'])
|
||||
self.assertEqual(exporttemplate4.content_type, ContentType.objects.get_for_model(Device))
|
||||
self.assertEqual(exporttemplate4.name, data['name'])
|
||||
self.assertEqual(exporttemplate4.template_code, data['template_code'])
|
||||
|
||||
@ -213,17 +213,17 @@ class ExportTemplateTest(APITestCase):
|
||||
|
||||
data = [
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 4',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 5',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
{
|
||||
'content_type': self.content_type.pk,
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template 6',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
},
|
||||
@ -241,7 +241,7 @@ class ExportTemplateTest(APITestCase):
|
||||
def test_update_exporttemplate(self):
|
||||
|
||||
data = {
|
||||
'content_type': self.content_type.pk,
|
||||
'content_type': 'dcim.device',
|
||||
'name': 'Test Export Template X',
|
||||
'template_code': '{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
|
||||
}
|
||||
|
@ -34,7 +34,7 @@ class WebhookTest(APITestCase):
|
||||
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
|
||||
|
||||
webhooks = Webhook.objects.bulk_create((
|
||||
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}),
|
||||
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
|
||||
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
|
||||
))
|
||||
|
@ -12,6 +12,7 @@ from django_tables2 import RequestConfig
|
||||
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator
|
||||
from utilities.utils import shallow_compare_dict
|
||||
from utilities.views import BulkDeleteView, BulkEditView, ObjectDeleteView, ObjectEditView, ObjectListView
|
||||
from . import filters, forms
|
||||
from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem
|
||||
@ -207,8 +208,31 @@ class ObjectChangeView(PermissionRequiredMixin, View):
|
||||
orderable=False
|
||||
)
|
||||
|
||||
objectchanges = ObjectChange.objects.filter(
|
||||
changed_object_type=objectchange.changed_object_type,
|
||||
changed_object_id=objectchange.changed_object_id,
|
||||
)
|
||||
|
||||
next_change = objectchanges.filter(time__gt=objectchange.time).order_by('time').first()
|
||||
prev_change = objectchanges.filter(time__lt=objectchange.time).order_by('-time').first()
|
||||
|
||||
if prev_change:
|
||||
diff_added = shallow_compare_dict(
|
||||
prev_change.object_data,
|
||||
objectchange.object_data,
|
||||
exclude=['last_updated'],
|
||||
)
|
||||
diff_removed = {x: prev_change.object_data.get(x) for x in diff_added}
|
||||
else:
|
||||
# No previous change; this is the initial change that added the object
|
||||
diff_added = diff_removed = objectchange.object_data
|
||||
|
||||
return render(request, 'extras/objectchange.html', {
|
||||
'objectchange': objectchange,
|
||||
'diff_added': diff_added,
|
||||
'diff_removed': diff_removed,
|
||||
'next_change': next_change,
|
||||
'prev_change': prev_change,
|
||||
'related_changes_table': related_changes_table,
|
||||
'related_changes_count': related_changes.count()
|
||||
})
|
||||
|
@ -1,4 +1,3 @@
|
||||
import datetime
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
|
@ -1,19 +1,21 @@
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
from django_rq import job
|
||||
from rest_framework.utils.encoders import JSONEncoder
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices
|
||||
from .choices import ObjectChangeActionChoices
|
||||
from .webhooks import generate_signature
|
||||
|
||||
logger = logging.getLogger('netbox.webhooks_worker')
|
||||
|
||||
|
||||
@job('default')
|
||||
def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
|
||||
"""
|
||||
Make a POST request to the defined Webhook
|
||||
"""
|
||||
payload = {
|
||||
context = {
|
||||
'event': dict(ObjectChangeActionChoices)[event].lower(),
|
||||
'timestamp': timestamp,
|
||||
'model': model_name,
|
||||
@ -21,29 +23,48 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
'request_id': request_id,
|
||||
'data': data
|
||||
}
|
||||
|
||||
# Build the headers for the HTTP request
|
||||
headers = {
|
||||
'Content-Type': webhook.http_content_type,
|
||||
}
|
||||
if webhook.additional_headers:
|
||||
headers.update(webhook.additional_headers)
|
||||
try:
|
||||
headers.update(webhook.render_headers(context))
|
||||
except (TemplateError, ValueError) as e:
|
||||
logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e))
|
||||
raise e
|
||||
|
||||
# Render the request body
|
||||
try:
|
||||
body = webhook.render_body(context)
|
||||
except TemplateError as e:
|
||||
logger.error("Error rendering request body for webhook {}: {}".format(webhook, e))
|
||||
raise e
|
||||
|
||||
# Prepare the HTTP request
|
||||
params = {
|
||||
'method': 'POST',
|
||||
'method': webhook.http_method,
|
||||
'url': webhook.payload_url,
|
||||
'headers': headers
|
||||
'headers': headers,
|
||||
'data': body,
|
||||
}
|
||||
logger.info(
|
||||
"Sending {} request to {} ({} {})".format(
|
||||
params['method'], params['url'], context['model'], context['event']
|
||||
)
|
||||
)
|
||||
logger.debug(params)
|
||||
try:
|
||||
prepared_request = requests.Request(**params).prepare()
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error("Error forming HTTP request: {}".format(e))
|
||||
raise e
|
||||
|
||||
if webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_JSON:
|
||||
params.update({'data': json.dumps(payload, cls=JSONEncoder)})
|
||||
elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA:
|
||||
params.update({'data': payload})
|
||||
|
||||
prepared_request = requests.Request(**params).prepare()
|
||||
|
||||
# If a secret key is defined, sign the request with a hash of the key and its content
|
||||
if webhook.secret != '':
|
||||
# Sign the request with a hash of the secret key and its content.
|
||||
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
|
||||
|
||||
# Send the request
|
||||
with requests.Session() as session:
|
||||
session.verify = webhook.ssl_verification
|
||||
if webhook.ca_file_path:
|
||||
@ -51,8 +72,10 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
|
||||
response = session.send(prepared_request)
|
||||
|
||||
if 200 <= response.status_code <= 299:
|
||||
logger.info("Request succeeded; response status {}".format(response.status_code))
|
||||
return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
|
||||
else:
|
||||
logger.warning("Request failed; response status {}: {}".format(response.status_code, response.content))
|
||||
raise requests.exceptions.RequestException(
|
||||
"Status {} returned with content '{}', webhook FAILED to process.".format(
|
||||
response.status_code, response.content
|
||||
|
@ -276,6 +276,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
label='VRF',
|
||||
widget=APISelect(
|
||||
api_url="/api/ipam/vrfs/",
|
||||
)
|
||||
|
@ -154,10 +154,24 @@ class NetHostContained(Lookup):
|
||||
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
#
|
||||
# Transforms
|
||||
#
|
||||
|
||||
class NetMaskLength(Transform):
|
||||
lookup_name = 'net_mask_length'
|
||||
function = 'MASKLEN'
|
||||
lookup_name = 'net_mask_length'
|
||||
|
||||
@property
|
||||
def output_field(self):
|
||||
return IntegerField()
|
||||
|
||||
|
||||
class Host(Transform):
|
||||
function = 'HOST'
|
||||
lookup_name = 'host'
|
||||
|
||||
|
||||
class Inet(Transform):
|
||||
function = 'INET'
|
||||
lookup_name = 'inet'
|
||||
|
@ -1,5 +1,6 @@
|
||||
from django.db import models
|
||||
from django.db.models.expressions import RawSQL
|
||||
|
||||
from ipam.lookups import Host, Inet
|
||||
|
||||
|
||||
class IPAddressManager(models.Manager):
|
||||
@ -13,4 +14,4 @@ class IPAddressManager(models.Manager):
|
||||
IP address as a /32 or /128.
|
||||
"""
|
||||
qs = super().get_queryset()
|
||||
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
|
||||
return qs.order_by('family', Inet(Host('address')))
|
||||
|
@ -26,7 +26,7 @@ RIR_UTILIZATION = """
|
||||
"""
|
||||
|
||||
RIR_ACTIONS = """
|
||||
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'ipam:rir_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.ipam.change_rir %}
|
||||
@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """
|
||||
"""
|
||||
|
||||
ROLE_ACTIONS = """
|
||||
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'ipam:role_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.ipam.change_role %}
|
||||
@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """
|
||||
"""
|
||||
|
||||
VLANGROUP_ACTIONS = """
|
||||
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'ipam:vlangroup_changelog' pk=record.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
@ -385,7 +385,7 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
"""
|
||||
List IP addresses assigned to a specific Interface.
|
||||
"""
|
||||
address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address')
|
||||
address = tables.LinkColumn(verbose_name='IP Address')
|
||||
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
|
||||
status = tables.TemplateColumn(STATUS_LABEL)
|
||||
tenant = tables.TemplateColumn(template_code=TENANT_LINK)
|
||||
|
2
netbox/media/devicetype-images/.gitignore
vendored
Normal file
2
netbox/media/devicetype-images/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.7.7-dev'
|
||||
VERSION = '2.7.9-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -179,25 +179,9 @@ nav ul.pagination {
|
||||
|
||||
/* Racks */
|
||||
div.rack_header {
|
||||
margin-left: 36px;
|
||||
margin-left: 32px;
|
||||
text-align: center;
|
||||
width: 230px;
|
||||
}
|
||||
ul.rack_legend {
|
||||
float: left;
|
||||
list-style-type: none;
|
||||
margin-right: 6px;
|
||||
padding: 0;
|
||||
width: 30px;
|
||||
}
|
||||
ul.rack_legend li {
|
||||
color: #c0c0c0;
|
||||
display: block;
|
||||
font-size: 10px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
padding: 5px 0;
|
||||
text-align: right;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
/* Devices */
|
||||
|
@ -14,7 +14,7 @@ text {
|
||||
background-color: #f0f0f0;
|
||||
fill: none;
|
||||
stroke: black;
|
||||
stroke-width: 3px;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
.slot {
|
||||
fill: #f7f7f7;
|
||||
@ -56,7 +56,6 @@ text {
|
||||
.blocked:hover+.add-device {
|
||||
fill: none;
|
||||
}
|
||||
|
||||
.unit {
|
||||
margin: 0;
|
||||
padding: 5px 0px;
|
||||
@ -65,3 +64,6 @@ text {
|
||||
font-size: 10px;
|
||||
font-family: "Helvetica Neue",Helvetica,Arial,sans-serif;
|
||||
}
|
||||
.hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
@ -42,17 +42,23 @@ $(document).ready(function() {
|
||||
return s.substring(0, num_chars); // Trim to first num_chars chars
|
||||
}
|
||||
var slug_field = $('#id_slug');
|
||||
slug_field.change(function() {
|
||||
$(this).attr('_changed', true);
|
||||
});
|
||||
if (slug_field) {
|
||||
var slug_source = $('#id_' + slug_field.attr('slug-source'));
|
||||
var slug_length = slug_field.attr('maxlength');
|
||||
if (slug_field.val()) {
|
||||
slug_field.attr('_changed', true);
|
||||
}
|
||||
slug_field.change(function() {
|
||||
$(this).attr('_changed', true);
|
||||
});
|
||||
slug_source.on('keyup change', function() {
|
||||
if (slug_field && !slug_field.attr('_changed')) {
|
||||
slug_field.val(slugify($(this).val(), (slug_length ? slug_length : 50)));
|
||||
}
|
||||
})
|
||||
});
|
||||
$('button.reslugify').click(function() {
|
||||
slug_field.val(slugify(slug_source.val(), (slug_length ? slug_length : 50)));
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk edit nullification
|
||||
|
16
netbox/project-static/js/rack_elevations.js
Normal file
16
netbox/project-static/js/rack_elevations.js
Normal file
@ -0,0 +1,16 @@
|
||||
// Toggle the display of device images within an SVG rack elevation
|
||||
$('button.toggle-images').click(function() {
|
||||
var selected = $(this).attr('selected');
|
||||
var rack_front = $("#rack_front");
|
||||
var rack_rear = $("#rack_rear");
|
||||
if (selected) {
|
||||
$('.device-image', rack_front.contents()).addClass('hidden');
|
||||
$('.device-image', rack_rear.contents()).addClass('hidden');
|
||||
} else {
|
||||
$('.device-image', rack_front.contents()).removeClass('hidden');
|
||||
$('.device-image', rack_rear.contents()).removeClass('hidden');
|
||||
}
|
||||
$(this).attr('selected', !selected);
|
||||
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');
|
||||
return false;
|
||||
});
|
@ -185,7 +185,7 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
role = DynamicModelMultipleChoiceField(
|
||||
queryset=SecretRole.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=True,
|
||||
required=False,
|
||||
widget=APISelectMultiple(
|
||||
api_url="/api/secrets/secret-roles/",
|
||||
value_field="slug",
|
||||
|
@ -302,8 +302,8 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
||||
Device; Devices may have multiple Secrets associated with them. A name can optionally be defined along with the
|
||||
ciphertext; this string is stored as plain text in the database.
|
||||
|
||||
A Secret can be up to 65,536 bytes (64KB) in length. Each secret string will be padded with random data to a minimum
|
||||
of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
|
||||
A Secret can be up to 65,535 bytes (64KB - 1B) in length. Each secret string will be padded with random data to
|
||||
a minimum of 64 bytes during encryption in order to protect short strings from ciphertext analysis.
|
||||
"""
|
||||
device = models.ForeignKey(
|
||||
to='dcim.Device',
|
||||
@ -320,7 +320,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
||||
blank=True
|
||||
)
|
||||
ciphertext = models.BinaryField(
|
||||
max_length=65568, # 16B IV + 2B pad length + {62-65550}B padded
|
||||
max_length=65568, # 128-bit IV + 16-bit pad length + 65535B secret + 15B padding
|
||||
editable=False
|
||||
)
|
||||
hash = models.CharField(
|
||||
@ -388,11 +388,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel):
|
||||
else:
|
||||
pad_length = 0
|
||||
|
||||
# Python 2 compatibility
|
||||
if sys.version_info[0] < 3:
|
||||
header = chr(len(s) >> 8) + chr(len(s) % 256)
|
||||
else:
|
||||
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
|
||||
header = bytes([len(s) >> 8]) + bytes([len(s) % 256])
|
||||
|
||||
return header + s + os.urandom(pad_length)
|
||||
|
||||
|
@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import SecretRole, Secret
|
||||
|
||||
SECRETROLE_ACTIONS = """
|
||||
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'secrets:secretrole_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.secrets.change_secretrole %}
|
||||
|
@ -85,14 +85,19 @@ class UserKeyTestCase(TestCase):
|
||||
|
||||
class SecretTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
# Generate a random key for encryption/decryption of secrets
|
||||
cls.secret_key = generate_random_key()
|
||||
|
||||
def test_01_encrypt_decrypt(self):
|
||||
"""
|
||||
Test basic encryption and decryption functionality using a random master key.
|
||||
"""
|
||||
plaintext = string.printable * 2
|
||||
secret_key = generate_random_key()
|
||||
s = Secret(plaintext=plaintext)
|
||||
s.encrypt(secret_key)
|
||||
s.encrypt(self.secret_key)
|
||||
|
||||
# Ensure plaintext is deleted upon encryption
|
||||
self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.")
|
||||
@ -112,7 +117,7 @@ class SecretTestCase(TestCase):
|
||||
self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash")
|
||||
|
||||
# Test decryption
|
||||
s.decrypt(secret_key)
|
||||
s.decrypt(self.secret_key)
|
||||
self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext")
|
||||
|
||||
def test_02_ciphertext_uniqueness(self):
|
||||
@ -120,15 +125,45 @@ class SecretTestCase(TestCase):
|
||||
Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads.
|
||||
"""
|
||||
plaintext = "1234567890abcdef"
|
||||
secret_key = generate_random_key()
|
||||
ivs = []
|
||||
ciphertexts = []
|
||||
for i in range(1, 51):
|
||||
s = Secret(plaintext=plaintext)
|
||||
s.encrypt(secret_key)
|
||||
s.encrypt(self.secret_key)
|
||||
ivs.append(s.ciphertext[0:16])
|
||||
ciphertexts.append(s.ciphertext[16:32])
|
||||
duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1]
|
||||
self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!")
|
||||
duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1]
|
||||
self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!")
|
||||
|
||||
def test_minimum_length(self):
|
||||
"""
|
||||
Test enforcement of the minimum length for ciphertexts.
|
||||
"""
|
||||
plaintext = 'A' # One-byte plaintext
|
||||
secret = Secret(plaintext=plaintext)
|
||||
secret.encrypt(self.secret_key)
|
||||
|
||||
# 16B IV + 2B length + 1B secret + 61B padding = 80 bytes
|
||||
self.assertEqual(len(secret.ciphertext), 80)
|
||||
self.assertIsNone(secret.plaintext)
|
||||
|
||||
secret.decrypt(self.secret_key)
|
||||
self.assertEqual(secret.plaintext, plaintext)
|
||||
|
||||
def test_maximum_length(self):
|
||||
"""
|
||||
Test encrypting a plaintext value of the maximum length.
|
||||
"""
|
||||
plaintext = '0123456789abcdef' * 4096
|
||||
plaintext = plaintext[:65535] # 65,535 chars
|
||||
secret = Secret(plaintext=plaintext)
|
||||
secret.encrypt(self.secret_key)
|
||||
|
||||
# 16B IV + 2B length + 65535B secret + 15B padding = 65568 bytes
|
||||
self.assertEqual(len(secret.ciphertext), 65568)
|
||||
self.assertIsNone(secret.plaintext)
|
||||
|
||||
secret.decrypt(self.secret_key)
|
||||
self.assertEqual(secret.plaintext, plaintext)
|
||||
|
@ -49,7 +49,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Changelog</a>
|
||||
<a href="{% url 'circuits:circuit_changelog' pk=circuit.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -54,7 +54,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Changelog</a>
|
||||
<a href="{% url 'circuits:provider_changelog' slug=provider.slug %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -31,7 +31,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:cable_changelog' pk=cable.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -119,7 +119,7 @@
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:device_changelog' pk=device.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -54,7 +54,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:devicetype_changelog' pk=devicetype.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -109,6 +109,30 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Front Image</td>
|
||||
<td>
|
||||
{% if devicetype.front_image %}
|
||||
<a href="{{ devicetype.front_image.url }}">
|
||||
<img src="{{ devicetype.front_image.url }}" alt="{{ devicetype.front_image.name }}" class="img-responsive" />
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Rear Image</td>
|
||||
<td>
|
||||
{% if devicetype.rear_image %}
|
||||
<a href="{{ devicetype.rear_image.url }}">
|
||||
<img src="{{ devicetype.rear_image.url }}" alt="{{ devicetype.rear_image.name }}" class="img-responsive" />
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Instances</td>
|
||||
<td><a href="{% url 'dcim:device_list' %}?device_type_id={{ devicetype.pk }}">{{ devicetype.instances.count }}</a></td>
|
||||
|
@ -14,6 +14,13 @@
|
||||
{% render_field form.subdevice_role %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Rack Images</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.front_image %}
|
||||
{% render_field form.rear_image %}
|
||||
</div>
|
||||
</div>
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
|
@ -1,7 +1,6 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="rack_frame">
|
||||
|
||||
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg"></object>
|
||||
|
||||
<object data="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}"></object>
|
||||
<div class="text-center text-small">
|
||||
<a href="{% url 'dcim-api:rack-elevation' pk=rack.pk %}?face={{face}}&render=svg" id="rack_{{ face }}">
|
||||
<i class="fa fa-download"></i> Save SVG
|
||||
</a>
|
||||
</div>
|
||||
|
10
netbox/templates/dcim/inc/rack_elevation_header.html
Normal file
10
netbox/templates/dcim/inc/rack_elevation_header.html
Normal file
@ -0,0 +1,10 @@
|
||||
{% load helpers %}
|
||||
<div class="rack_header">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
{% if rack.role %}
|
||||
<br /><small class="label" style="color: {{ rack.role.color|fgcolor }}; background-color: #{{ rack.role.color }}">{{ rack.role }}</small>
|
||||
{% endif %}
|
||||
{% if rack.facility_id %}
|
||||
<br /><small class="text-muted">{{ rack.facility_id }}</small>
|
||||
{% endif %}
|
||||
</div>
|
@ -34,7 +34,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:interface_changelog' pk=interface.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -52,7 +52,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:powerfeed_changelog' pk=powerfeed.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -121,18 +121,8 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body rendered-markdown">
|
||||
{% if powerfeed.comments %}
|
||||
{{ powerfeed.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/custom_fields_panel.html' with obj=powerfeed %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="panel panel-default">
|
||||
@ -162,6 +152,18 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Comments</strong>
|
||||
</div>
|
||||
<div class="panel-body rendered-markdown">
|
||||
{% if powerfeed.comments %}
|
||||
{{ powerfeed.comments|gfm }}
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -48,7 +48,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:powerpanel_changelog' pk=powerpanel.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -2,6 +2,7 @@
|
||||
{% load buttons %}
|
||||
{% load custom_links %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
|
||||
{% block header %}
|
||||
<div class="row noprint">
|
||||
@ -45,6 +46,9 @@
|
||||
<h1>{% block title %}Rack {{ rack }}{% endblock %}</h1>
|
||||
{% include 'inc/created_updated.html' with obj=rack %}
|
||||
<div class="pull-right noprint">
|
||||
<button class="btn btn-sm btn-default toggle-images" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||
</button>
|
||||
{% custom_links rack %}
|
||||
</div>
|
||||
<ul class="nav nav-tabs">
|
||||
@ -53,7 +57,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Changelog</a>
|
||||
<a href="{% url 'dcim:rack_changelog' pk=rack.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@ -368,9 +372,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
$('[data-toggle="popover"]').popover()
|
||||
})
|
||||
</script>
|
||||
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -1,8 +1,12 @@
|
||||
{% extends '_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="btn-group pull-right noprint" role="group">
|
||||
<button class="btn btn-default toggle-images" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||
</button>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
</div>
|
||||
@ -13,16 +17,10 @@
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% for rack in page %}
|
||||
<div style="display: inline-block; width: 266px">
|
||||
<div class="rack_header">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
|
||||
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
{% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
|
||||
<div class="clearfix"></div>
|
||||
<div class="rack_header">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name|truncatechars:"25" }}</a></strong>
|
||||
<p><small class="text-muted">{{ rack.facility_id|truncatechars:"30" }}</small></p>
|
||||
</div>
|
||||
{% include 'dcim/inc/rack_elevation_header.html' %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@ -41,9 +39,5 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script type="text/javascript">
|
||||
$(function() {
|
||||
$('[data-toggle="popover"]').popover()
|
||||
})
|
||||
</script>
|
||||
<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
|
||||
{% endblock %}
|
||||
|
@ -60,7 +60,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Changelog</a>
|
||||
<a href="{% url 'dcim:site_changelog' slug=site.slug %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -1,12 +1,12 @@
|
||||
{% extends base_template %}
|
||||
|
||||
{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Changelog{% endblock %}
|
||||
{% block title %}{% if obj %}{{ obj }}{% else %}{{ block.super }}{% endif %} - Change Log{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if obj %}<h1>{{ obj }}</h1>{% endif %}
|
||||
{% include 'panel_table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
<div class="text-muted">
|
||||
Changelog retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -7,7 +7,7 @@
|
||||
<div class="row noprint">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'extras:objectchange_list' %}">Changelog</a></li>
|
||||
<li><a href="{% url 'extras:objectchange_list' %}">Change Log</a></li>
|
||||
{% if objectchange.related_object.get_absolute_url %}
|
||||
<li><a href="{{ objectchange.related_object.get_absolute_url }}changelog/">{{ objectchange.related_object }}</a></li>
|
||||
{% elif objectchange.changed_object.get_absolute_url %}
|
||||
@ -83,6 +83,35 @@
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Difference</strong>
|
||||
<div class="btn-group btn-group-xs pull-right noprint">
|
||||
<a {% if prev_change %}href="{% url 'extras:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
|
||||
<span class="fa fa-chevron-left" aria-hidden="true"></span> Previous
|
||||
</a>
|
||||
<a {% if next_change %}href="{% url 'extras:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-default">
|
||||
Next <span class="fa fa-chevron-right" aria-hidden="true"></span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% if diff_added == diff_removed %}
|
||||
<span class="text-muted" style="margin-left: 10px;">
|
||||
{% if objectchange.action == 'create' %}
|
||||
Object created
|
||||
{% elif objectchange.action == 'delete' %}
|
||||
Object deleted
|
||||
{% else %}
|
||||
No changes
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
<pre style="background-color: #ffdce0;">{{ diff_removed|render_json }}</pre>
|
||||
<pre style="background-color: #cdffd8;">{{ diff_added|render_json }}</pre>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="panel panel-default">
|
||||
|
@ -44,7 +44,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Changelog</a>
|
||||
<a href="{% url 'extras:tag_changelog' slug=tag.slug %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -284,7 +284,7 @@
|
||||
</div>
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>Changelog</strong>
|
||||
<strong>Change Log</strong>
|
||||
</div>
|
||||
{% if changelog and perms.extras.view_objectchange %}
|
||||
<div class="list-group">
|
||||
|
@ -48,7 +48,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Changelog</a>
|
||||
<a href="{% url 'ipam:aggregate_changelog' pk=aggregate.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -14,7 +14,7 @@
|
||||
</td>
|
||||
<td>{{ service.description }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'ipam:service_changelog' pk=service.pk %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.ipam.change_service %}
|
||||
|
@ -49,7 +49,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Changelog</a>
|
||||
<a href="{% url 'ipam:ipaddress_changelog' pk=ipaddress.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -69,7 +69,7 @@
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Changelog</a>
|
||||
<a href="{% url 'ipam:prefix_changelog' pk=prefix.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -55,7 +55,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Changelog</a>
|
||||
<a href="{% url 'ipam:vlan_changelog' pk=vlan.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -46,7 +46,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<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 %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -34,7 +34,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Changelog</a>
|
||||
<a href="{% url 'secrets:secret_changelog' pk=secret.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -49,7 +49,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Changelog</a>
|
||||
<a href="{% url 'tenancy:tenant_changelog' slug=tenant.slug %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<h1>{% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}</h1>
|
||||
<div class="row">
|
||||
<div class="col-md-9">
|
||||
<div class="col-md-{% if filter_form %}9{% else %}12{% endif %}">
|
||||
{% with bulk_edit_url=content_type.model_class|url_name:"bulk_edit" bulk_delete_url=content_type.model_class|url_name:"bulk_delete" %}
|
||||
{% if permissions.change or permissions.delete %}
|
||||
<form method="post" class="form form-horizontal">
|
||||
@ -34,12 +34,12 @@
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit All
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete All
|
||||
</button>
|
||||
{% endif %}
|
||||
@ -51,12 +51,12 @@
|
||||
<div class="pull-left noprint">
|
||||
{% block bulk_buttons %}{% endblock %}
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}" class="btn btn-warning btn-sm">
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}" class="btn btn-danger btn-sm">
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
@ -69,11 +69,11 @@
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
<div class="col-md-3 noprint">
|
||||
{% if filter_form %}
|
||||
{% if filter_form %}
|
||||
<div class="col-md-3 noprint">
|
||||
{% include 'inc/search_panel.html' %}
|
||||
{% endif %}
|
||||
{% block sidebar %}{% endblock %}
|
||||
</div>
|
||||
{% block sidebar %}{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -49,7 +49,7 @@
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Changelog</a>
|
||||
<a href="{% url 'virtualization:cluster_changelog' pk=cluster.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -54,7 +54,7 @@
|
||||
{% endif %}
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
<a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Changelog</a>
|
||||
<a href="{% url 'virtualization:virtualmachine_changelog' pk=virtualmachine.pk %}">Change Log</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Tenant, TenantGroup
|
||||
|
||||
TENANTGROUP_ACTIONS = """
|
||||
<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'tenancy:tenantgroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.tenancy.change_tenantgroup %}
|
||||
|
@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist
|
||||
from django.db.models import ManyToManyField, ProtectedError
|
||||
from django.http import Http404
|
||||
from django.urls import reverse
|
||||
from rest_framework.exceptions import APIException
|
||||
from rest_framework.permissions import BasePermission
|
||||
from rest_framework.relations import PrimaryKeyRelatedField, RelatedField
|
||||
@ -41,6 +42,14 @@ def get_serializer_for_model(model, prefix=''):
|
||||
)
|
||||
|
||||
|
||||
def is_api_request(request):
|
||||
"""
|
||||
Return True of the request is being made via the REST API.
|
||||
"""
|
||||
api_path = reverse('api-root')
|
||||
return request.path_info.startswith(api_path)
|
||||
|
||||
|
||||
#
|
||||
# Authentication
|
||||
#
|
||||
|
@ -56,8 +56,11 @@ class NaturalOrderingField(models.CharField):
|
||||
"""
|
||||
Generate a naturalized value from the target field
|
||||
"""
|
||||
value = getattr(model_instance, self.target_field)
|
||||
return self.naturalize_function(value, max_length=self.max_length)
|
||||
original_value = getattr(model_instance, self.target_field)
|
||||
naturalized_value = self.naturalize_function(original_value, max_length=self.max_length)
|
||||
setattr(model_instance, self.attname, naturalized_value)
|
||||
|
||||
return naturalized_value
|
||||
|
||||
def deconstruct(self):
|
||||
kwargs = super().deconstruct()[3] # Pass kwargs from CharField
|
||||
|
@ -2,8 +2,9 @@ import csv
|
||||
import json
|
||||
import re
|
||||
from io import StringIO
|
||||
import yaml
|
||||
|
||||
import django_filters
|
||||
import yaml
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput
|
||||
@ -132,6 +133,13 @@ class SmallTextarea(forms.Textarea):
|
||||
pass
|
||||
|
||||
|
||||
class SlugWidget(forms.TextInput):
|
||||
"""
|
||||
Subclass TextInput and add a slug regeneration button next to the form field.
|
||||
"""
|
||||
template_name = 'widgets/sluginput.html'
|
||||
|
||||
|
||||
class ColorSelect(forms.Select):
|
||||
"""
|
||||
Extends the built-in Select widget to colorize each <option>.
|
||||
@ -534,7 +542,8 @@ class SlugField(forms.SlugField):
|
||||
def __init__(self, slug_source='name', *args, **kwargs):
|
||||
label = kwargs.pop('label', "Slug")
|
||||
help_text = kwargs.pop('help_text', "URL-friendly unique shorthand")
|
||||
super().__init__(label=label, help_text=help_text, *args, **kwargs)
|
||||
widget = kwargs.pop('widget', SlugWidget)
|
||||
super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs)
|
||||
self.widget.attrs['slug-source'] = slug_source
|
||||
|
||||
|
||||
@ -556,18 +565,17 @@ class TagFilterField(forms.MultipleChoiceField):
|
||||
|
||||
|
||||
class DynamicModelChoiceMixin:
|
||||
field_modifier = ''
|
||||
filter = django_filters.ModelChoiceFilter
|
||||
|
||||
def get_bound_field(self, form, field_name):
|
||||
bound_field = BoundField(form, self, field_name)
|
||||
|
||||
# Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options
|
||||
# will be populated on-demand via the APISelect widget.
|
||||
field_name = '{}{}'.format(self.to_field_name or 'pk', self.field_modifier)
|
||||
if bound_field.data:
|
||||
self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.data)})
|
||||
elif bound_field.initial:
|
||||
self.queryset = self.queryset.filter(**{field_name: self.prepare_value(bound_field.initial)})
|
||||
data = self.prepare_value(bound_field.data or bound_field.initial)
|
||||
if data:
|
||||
filter = self.filter(field_name=self.to_field_name or 'pk', queryset=self.queryset)
|
||||
self.queryset = filter.filter(self.queryset, data)
|
||||
else:
|
||||
self.queryset = self.queryset.none()
|
||||
|
||||
@ -586,7 +594,7 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
|
||||
"""
|
||||
A multiple-choice version of DynamicModelChoiceField.
|
||||
"""
|
||||
field_modifier = '__in'
|
||||
filter = django_filters.ModelMultipleChoiceFilter
|
||||
|
||||
|
||||
class LaxURLField(forms.URLField):
|
||||
|
@ -5,6 +5,7 @@ from django.db import ProgrammingError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from .api import is_api_request
|
||||
from .views import server_error
|
||||
|
||||
|
||||
@ -38,9 +39,8 @@ class APIVersionMiddleware(object):
|
||||
self.get_response = get_response
|
||||
|
||||
def __call__(self, request):
|
||||
api_path = reverse('api-root')
|
||||
response = self.get_response(request)
|
||||
if request.path_info.startswith(api_path):
|
||||
if is_api_request(request):
|
||||
response['API-Version'] = settings.REST_FRAMEWORK_VERSION
|
||||
return response
|
||||
|
||||
|
@ -7,7 +7,8 @@ INTERFACE_NAME_REGEX = r'(^(?P<type>[^\d\.:]+)?)' \
|
||||
r'((?P<subposition>\d+)/)?' \
|
||||
r'((?P<id>\d+))?' \
|
||||
r'(:(?P<channel>\d+))?' \
|
||||
r'(.(?P<vc>\d+)$)?'
|
||||
r'(\.(?P<vc>\d+))?' \
|
||||
r'(?P<remainder>.*)$'
|
||||
|
||||
|
||||
def naturalize(value, max_length, integer_places=8):
|
||||
@ -50,7 +51,7 @@ def naturalize_interface(value, max_length):
|
||||
:param value: The value to be naturalized
|
||||
:param max_length: The maximum length of the returned string. Characters beyond this length will be stripped.
|
||||
"""
|
||||
output = []
|
||||
output = ''
|
||||
match = re.search(INTERFACE_NAME_REGEX, value)
|
||||
if match is None:
|
||||
return value
|
||||
@ -60,21 +61,25 @@ def naturalize_interface(value, max_length):
|
||||
for part_name in ('slot', 'subslot', 'position', 'subposition'):
|
||||
part = match.group(part_name)
|
||||
if part is not None:
|
||||
output.append(part.rjust(4, '0'))
|
||||
output += part.rjust(4, '0')
|
||||
else:
|
||||
output.append('9999')
|
||||
output += '9999'
|
||||
|
||||
# Append the type, if any.
|
||||
if match.group('type') is not None:
|
||||
output.append(match.group('type'))
|
||||
output += match.group('type')
|
||||
|
||||
# Finally, append any remaining fields, left-padding to six digits each.
|
||||
# Append any remaining fields, left-padding to six digits each.
|
||||
for part_name in ('id', 'channel', 'vc'):
|
||||
part = match.group(part_name)
|
||||
if part is not None:
|
||||
output.append(part.rjust(6, '0'))
|
||||
output += part.rjust(6, '0')
|
||||
else:
|
||||
output.append('000000')
|
||||
output += '000000'
|
||||
|
||||
ret = ''.join(output)
|
||||
return ret[:max_length]
|
||||
# Finally, naturalize any remaining text and append it
|
||||
if match.group('remainder') is not None and len(output) < max_length:
|
||||
remainder = naturalize(match.group('remainder'), max_length - len(output))
|
||||
output += remainder
|
||||
|
||||
return output[:max_length]
|
||||
|
8
netbox/utilities/templates/widgets/sluginput.html
Normal file
8
netbox/utilities/templates/widgets/sluginput.html
Normal file
@ -0,0 +1,8 @@
|
||||
<div class="input-group">
|
||||
{% include "django/forms/widgets/input.html" %}
|
||||
<span class="input-group-btn">
|
||||
<button class="btn btn-default reslugify" type="button" title="Regenerate slug">
|
||||
<i class="fa fa-refresh"></i>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
@ -172,24 +172,29 @@ class ViewTestCases:
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
||||
def test_create_object(self):
|
||||
|
||||
# Try GET without permission
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(self._get_url('add')), 403)
|
||||
|
||||
# Try GET with permission
|
||||
self.add_permissions(
|
||||
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.get(path=self._get_url('add'))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Try POST with permission
|
||||
initial_count = self.model.objects.count()
|
||||
request = {
|
||||
'path': self._get_url('add'),
|
||||
'data': post_data(self.form_data),
|
||||
'follow': False, # Do not follow 302 redirects
|
||||
}
|
||||
|
||||
# Attempt to make the request without required permissions
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(**request), 403)
|
||||
|
||||
# Assign the required permission and submit again
|
||||
self.add_permissions(
|
||||
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
# Validate object creation
|
||||
self.assertEqual(initial_count + 1, self.model.objects.count())
|
||||
instance = self.model.objects.order_by('-pk').first()
|
||||
self.assertInstanceEqual(instance, self.form_data)
|
||||
@ -204,23 +209,27 @@ class ViewTestCases:
|
||||
def test_edit_object(self):
|
||||
instance = self.model.objects.first()
|
||||
|
||||
# Try GET without permission
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(self._get_url('edit', instance)), 403)
|
||||
|
||||
# Try GET with permission
|
||||
self.add_permissions(
|
||||
'{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.get(path=self._get_url('edit', instance))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Try POST with permission
|
||||
request = {
|
||||
'path': self._get_url('edit', instance),
|
||||
'data': post_data(self.form_data),
|
||||
'follow': False, # Do not follow 302 redirects
|
||||
}
|
||||
|
||||
# Attempt to make the request without required permissions
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(**request), 403)
|
||||
|
||||
# Assign the required permission and submit again
|
||||
self.add_permissions(
|
||||
'{}.change_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
# Validate object modifications
|
||||
instance = self.model.objects.get(pk=instance.pk)
|
||||
self.assertInstanceEqual(instance, self.form_data)
|
||||
|
||||
@ -232,23 +241,26 @@ class ViewTestCases:
|
||||
def test_delete_object(self):
|
||||
instance = self.model.objects.first()
|
||||
|
||||
# Try GET without permissions
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(self._get_url('delete', instance)), 403)
|
||||
|
||||
# Try GET with permission
|
||||
self.add_permissions(
|
||||
'{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.get(path=self._get_url('delete', instance))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
request = {
|
||||
'path': self._get_url('delete', instance),
|
||||
'data': {'confirm': True},
|
||||
'follow': False, # Do not follow 302 redirects
|
||||
}
|
||||
|
||||
# Attempt to make the request without required permissions
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(**request), 403)
|
||||
|
||||
# Assign the required permission and submit again
|
||||
self.add_permissions(
|
||||
'{}.delete_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
# Validate object deletion
|
||||
with self.assertRaises(ObjectDoesNotExist):
|
||||
self.model.objects.get(pk=instance.pk)
|
||||
|
||||
@ -314,6 +326,20 @@ class ViewTestCases:
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=[])
|
||||
def test_import_objects(self):
|
||||
|
||||
# Test GET without permission
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.get(self._get_url('import')), 403)
|
||||
|
||||
# Test GET with permission
|
||||
self.add_permissions(
|
||||
'{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name),
|
||||
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.get(self._get_url('import'))
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Test POST with permission
|
||||
initial_count = self.model.objects.count()
|
||||
request = {
|
||||
'path': self._get_url('import'),
|
||||
@ -321,19 +347,10 @@ class ViewTestCases:
|
||||
'csv': '\n'.join(self.csv_data)
|
||||
}
|
||||
}
|
||||
|
||||
# Attempt to make the request without required permissions
|
||||
with disable_warnings('django.request'):
|
||||
self.assertHttpStatus(self.client.post(**request), 403)
|
||||
|
||||
# Assign the required permission and submit again
|
||||
self.add_permissions(
|
||||
'{}.view_{}'.format(self.model._meta.app_label, self.model._meta.model_name),
|
||||
'{}.add_{}'.format(self.model._meta.app_label, self.model._meta.model_name)
|
||||
)
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 200)
|
||||
|
||||
# Validate import of new objects
|
||||
self.assertEqual(self.model.objects.count(), initial_count + len(self.csv_data) - 1)
|
||||
|
||||
class BulkEditObjectsViewTestCase(ModelViewTestCase):
|
||||
|
@ -9,8 +9,8 @@ class NaturalizationTestCase(TestCase):
|
||||
"""
|
||||
def test_naturalize(self):
|
||||
|
||||
# Original, naturalized
|
||||
data = (
|
||||
# Original, naturalized
|
||||
('abc', 'abc'),
|
||||
('123', '00000123'),
|
||||
('abc123', 'abc00000123'),
|
||||
@ -21,15 +21,16 @@ class NaturalizationTestCase(TestCase):
|
||||
)
|
||||
|
||||
for origin, naturalized in data:
|
||||
self.assertEqual(naturalize(origin, max_length=50), naturalized)
|
||||
self.assertEqual(naturalize(origin, max_length=100), naturalized)
|
||||
|
||||
def test_naturalize_max_length(self):
|
||||
self.assertEqual(naturalize('abc123def456', max_length=10), 'abc0000012')
|
||||
|
||||
def test_naturalize_interface(self):
|
||||
|
||||
# Original, naturalized
|
||||
data = (
|
||||
# Original, naturalized
|
||||
# IOS/JunOS-style
|
||||
('Gi', '9999999999999999Gi000000000000000000'),
|
||||
('Gi1', '9999999999999999Gi000001000000000000'),
|
||||
('Gi1/2', '0001999999999999Gi000002000000000000'),
|
||||
@ -40,10 +41,16 @@ class NaturalizationTestCase(TestCase):
|
||||
('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'),
|
||||
('Gi1:2', '9999999999999999Gi000001000002000000'),
|
||||
('Gi1:2.3', '9999999999999999Gi000001000002000003'),
|
||||
# Generic
|
||||
('Interface 1', '9999999999999999Interface 000001000000000000'),
|
||||
('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'),
|
||||
('Interface 99', '9999999999999999Interface 000099000000000000'),
|
||||
('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'),
|
||||
('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'),
|
||||
)
|
||||
|
||||
for origin, naturalized in data:
|
||||
self.assertEqual(naturalize_interface(origin, max_length=50), naturalized)
|
||||
self.assertEqual(naturalize_interface(origin, max_length=100), naturalized)
|
||||
|
||||
def test_naturalize_interface_max_length(self):
|
||||
self.assertEqual(naturalize_interface('Gi1/2/3', max_length=20), '0001000299999999Gi00')
|
||||
|
@ -31,8 +31,9 @@ def csv_format(data):
|
||||
if not isinstance(value, str):
|
||||
value = '{}'.format(value)
|
||||
|
||||
# Double-quote the value if it contains a comma
|
||||
# Double-quote the value if it contains a comma or line break
|
||||
if ',' in value or '\n' in value:
|
||||
value = value.replace('"', '""') # Escape double-quotes
|
||||
csv.append('"{}"'.format(value))
|
||||
else:
|
||||
csv.append('{}'.format(value))
|
||||
@ -80,10 +81,12 @@ def get_subquery(model, field):
|
||||
return subquery
|
||||
|
||||
|
||||
def serialize_object(obj, extra=None):
|
||||
def serialize_object(obj, extra=None, exclude=None):
|
||||
"""
|
||||
Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like
|
||||
change logging, not the REST API.) Optionally include a dictionary to supplement the object data.
|
||||
change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys
|
||||
can be provided to exclude them from the returned dictionary. Private fields (prefaced with an underscore) are
|
||||
implicitly excluded.
|
||||
"""
|
||||
json_str = serialize('json', [obj])
|
||||
data = json.loads(json_str)[0]['fields']
|
||||
@ -102,6 +105,16 @@ def serialize_object(obj, extra=None):
|
||||
if extra is not None:
|
||||
data.update(extra)
|
||||
|
||||
# Copy keys to list to avoid 'dictionary changed size during iteration' exception
|
||||
for key in list(data):
|
||||
# Private fields shouldn't be logged in the object change
|
||||
if isinstance(key, str) and key.startswith('_'):
|
||||
data.pop(key)
|
||||
|
||||
# Explicitly excluded keys
|
||||
if isinstance(exclude, (list, tuple)) and key in exclude:
|
||||
data.pop(key)
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@ -222,3 +235,19 @@ def querydict_to_dict(querydict):
|
||||
key: querydict.get(key) if len(value) == 1 and key != 'pk' else querydict.getlist(key)
|
||||
for key, value in querydict.lists()
|
||||
}
|
||||
|
||||
|
||||
def shallow_compare_dict(source_dict, destination_dict, exclude=None):
|
||||
"""
|
||||
Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
|
||||
the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
|
||||
"""
|
||||
difference = {}
|
||||
|
||||
for key in destination_dict:
|
||||
if source_dict.get(key) != destination_dict[key]:
|
||||
if isinstance(exclude, (list, tuple)) and key in exclude:
|
||||
continue
|
||||
difference[key] = destination_dict[key]
|
||||
|
||||
return difference
|
||||
|
@ -626,12 +626,13 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
|
||||
model = self.queryset.model
|
||||
|
||||
# Create a mutable copy of the POST data
|
||||
post_data = request.POST.copy()
|
||||
|
||||
# If we are editing *all* objects in the queryset, replace the PK list with all matched objects.
|
||||
if post_data.get('_all') and self.filterset is not None:
|
||||
post_data['pk'] = [obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs]
|
||||
if request.POST.get('_all') and self.filterset is not None:
|
||||
pk_list = [
|
||||
obj.pk for obj in self.filterset(request.GET, model.objects.only('pk')).qs
|
||||
]
|
||||
else:
|
||||
pk_list = request.POST.getlist('pk')
|
||||
|
||||
if '_apply' in request.POST:
|
||||
form = self.form(model, request.POST)
|
||||
@ -656,9 +657,8 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
try:
|
||||
model_field = model._meta.get_field(name)
|
||||
except FieldDoesNotExist:
|
||||
# The form field is used to modify a field rather than set its value directly,
|
||||
# so we skip it.
|
||||
continue
|
||||
# This form field is used to modify a field rather than set its value directly
|
||||
model_field = None
|
||||
|
||||
# Handle nullification
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
@ -716,12 +716,10 @@ class BulkEditView(GetReturnURLMixin, View):
|
||||
messages.error(self.request, "{} failed validation: {}".format(obj, e))
|
||||
|
||||
else:
|
||||
# Pass the PK list as initial data to avoid binding the form
|
||||
initial_data = querydict_to_dict(post_data)
|
||||
form = self.form(model, initial=initial_data)
|
||||
form = self.form(model, initial={'pk': pk_list})
|
||||
|
||||
# Retrieve objects being edited
|
||||
table = self.table(self.queryset.filter(pk__in=post_data.getlist('pk')), orderable=False)
|
||||
table = self.table(self.queryset.filter(pk__in=pk_list), orderable=False)
|
||||
if not table.rows:
|
||||
messages.warning(request, "No {} were selected.".format(model._meta.verbose_name_plural))
|
||||
return redirect(self.get_return_url(request))
|
||||
|
@ -35,7 +35,7 @@ class ClusterGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet):
|
||||
fields = ['id', 'name', 'slug']
|
||||
|
||||
|
||||
class ClusterFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
|
||||
id__in = NumericInFilter(
|
||||
field_name='id',
|
||||
lookup_expr='in'
|
||||
@ -87,10 +87,6 @@ class ClusterFilterSet(BaseFilterSet, CustomFieldFilterSet, CreatedUpdatedFilter
|
||||
to_field_name='slug',
|
||||
label='Cluster type (slug)',
|
||||
)
|
||||
tenant = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Tenant.objects.all(),
|
||||
label="Tenant (ID)"
|
||||
)
|
||||
tag = TagFilter()
|
||||
|
||||
class Meta:
|
||||
|
@ -7,7 +7,7 @@ from utilities.tables import BaseTable, ToggleColumn
|
||||
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
|
||||
CLUSTERTYPE_ACTIONS = """
|
||||
<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'virtualization:clustertype_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.virtualization.change_clustertype %}
|
||||
@ -16,7 +16,7 @@ CLUSTERTYPE_ACTIONS = """
|
||||
"""
|
||||
|
||||
CLUSTERGROUP_ACTIONS = """
|
||||
<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Changelog">
|
||||
<a href="{% url 'virtualization:clustergroup_changelog' slug=record.slug %}" class="btn btn-default btn-xs" title="Change log">
|
||||
<i class="fa fa-history"></i>
|
||||
</a>
|
||||
{% if perms.virtualization.change_clustergroup %}
|
||||
|
Loading…
Reference in New Issue
Block a user