diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..ab7d7cdc4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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 diff --git a/README.md b/README.md index 478f37e5e..5167c53c4 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,12 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode ![Screenshot of main page](docs/media/screenshot1.png "Main page") +--- + ![Screenshot of rack elevation](docs/media/screenshot2.png "Rack elevation") +--- + ![Screenshot of prefix hierarchy](docs/media/screenshot3.png "Prefix hierarchy") # Installation diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index cf98a6290..0904f8c82 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -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() diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index fc1e89221..6deddc140 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/reports.md @@ -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: diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 9a02449f8..310e67bf5 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -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: +* 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. diff --git a/docs/installation/3-http-daemon.md b/docs/installation/3-http-daemon.md index cc1065fef..4225f8413 100644 --- a/docs/installation/3-http-daemon.md +++ b/docs/installation/3-http-daemon.md @@ -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: diff --git a/docs/media/screenshot1.png b/docs/media/screenshot1.png index 3939199d9..e25a5ed93 100644 Binary files a/docs/media/screenshot1.png and b/docs/media/screenshot1.png differ diff --git a/docs/media/screenshot2.png b/docs/media/screenshot2.png index 3a73fe048..bce0a3f5d 100644 Binary files a/docs/media/screenshot2.png and b/docs/media/screenshot2.png differ diff --git a/docs/media/screenshot3.png b/docs/media/screenshot3.png index d2acb9773..6cf67d8f7 100644 Binary files a/docs/media/screenshot3.png and b/docs/media/screenshot3.png differ diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index e224196ad..62b85bc83 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -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 --- diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index dbd9e6ba1..a425b3ace 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -6,7 +6,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Circuit, CircuitType, Provider CIRCUITTYPE_ACTIONS = """ - + {% if perms.circuit.change_circuittype %} diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 30d84f703..bb2d61faa 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -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') diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 234a9fb1c..5483904f5 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8bb127f67..c4a159126 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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}) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 6ceefa878..c1d7b4053 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 = { diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 13a5052e4..78a418283 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -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 diff --git a/netbox/dcim/elevations.py b/netbox/dcim/elevations.py new file mode 100644 index 000000000..a3672f60d --- /dev/null +++ b/netbox/dcim/elevations.py @@ -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 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 4c8a0821f..0e5fb382a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -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={ diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py deleted file mode 100644 index 502719646..000000000 --- a/netbox/dcim/managers.py +++ /dev/null @@ -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) diff --git a/netbox/dcim/migrations/0097_interfacetemplate_type_other.py b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py new file mode 100644 index 000000000..d71b5c655 --- /dev/null +++ b/netbox/dcim/migrations/0097_interfacetemplate_type_other.py @@ -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 + ), + ] diff --git a/netbox/dcim/migrations/0098_devicetype_images.py b/netbox/dcim/migrations/0098_devicetype_images.py new file mode 100644 index 000000000..837a2b73a --- /dev/null +++ b/netbox/dcim/migrations/0098_devicetype_images.py @@ -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'), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 29afef1f1..821b8eeb7 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -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] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index a41eda576..9a3d608d7 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -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): diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 1f67b93f1..bc91dd70c 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -41,7 +41,7 @@ DEVICE_LINK = """ """ REGION_ACTIONS = """ - + {% if perms.dcim.change_region %} @@ -50,7 +50,7 @@ REGION_ACTIONS = """ """ RACKGROUP_ACTIONS = """ - + @@ -64,7 +64,7 @@ RACKGROUP_ACTIONS = """ """ RACKROLE_ACTIONS = """ - + {% if perms.dcim.change_rackrole %} @@ -86,7 +86,7 @@ RACK_DEVICE_COUNT = """ """ RACKRESERVATION_ACTIONS = """ - + {% if perms.dcim.change_rackreservation %} @@ -95,7 +95,7 @@ RACKRESERVATION_ACTIONS = """ """ MANUFACTURER_ACTIONS = """ - + {% if perms.dcim.change_manufacturer %} @@ -104,7 +104,7 @@ MANUFACTURER_ACTIONS = """ """ DEVICEROLE_ACTIONS = """ - + {% if perms.dcim.change_devicerole %} @@ -129,7 +129,7 @@ PLATFORM_VM_COUNT = """ """ PLATFORM_ACTIONS = """ - + {% if perms.dcim.change_platform %} @@ -166,7 +166,7 @@ UTILIZATION_GRAPH = """ """ VIRTUALCHASSIS_ACTIONS = """ - + {% 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): diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a3a072bc9..ad893bec6 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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, } diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 32d864a51..c9d5c4c20 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -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() diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0bb6658a2..8f9da2d68 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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' ) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 2a39c207e..f66cc248f 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -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()]) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 58433df25..40606ed8e 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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 diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 0ae53f03d..9811cc0b0 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -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, - } diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index b12bc2f2c..7bb026d34 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -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=[ diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 3624e11a5..c8be8755f 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -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: diff --git a/netbox/extras/migrations/0038_webhook_template_support.py b/netbox/extras/migrations/0038_webhook_template_support.py new file mode 100644 index 000000000..7d563820f --- /dev/null +++ b/netbox/extras/migrations/0038_webhook_template_support.py @@ -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 + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5d175d172..d81fbeab9 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -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 ' + 'here.' + ) + 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 Name: Value. 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: event, model, ' + 'timestamp, username, request_id, and data.' ) 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) # diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index e5a32bde6..97fc50ea0 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -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() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index dca84dfdc..08c5ed471 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -5,7 +5,7 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ConfigContext, ObjectChange, Tag, TaggedItem TAG_ACTIONS = """ - + {% if perms.taggit.change_tag %} diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b15553a49..223e5a530 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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 %}', } diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index 026a82bb8..06b4f7c7e 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -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), )) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 3912c602f..b625cf7b1 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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() }) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index cfa05d0f6..8b20641d7 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,4 +1,3 @@ -import datetime import hashlib import hmac diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index e48d8a2d7..1b1b76dd9 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -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 diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 2b7fb2a6b..8156ae4aa 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -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/", ) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py index 62b356c5d..92fa5780d 100644 --- a/netbox/ipam/lookups.py +++ b/netbox/ipam/lookups.py @@ -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' diff --git a/netbox/ipam/managers.py b/netbox/ipam/managers.py index 8aebc60ce..8279bf205 100644 --- a/netbox/ipam/managers.py +++ b/netbox/ipam/managers.py @@ -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'))) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index ad119a907..4dcb0a6c3 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -26,7 +26,7 @@ RIR_UTILIZATION = """ """ RIR_ACTIONS = """ - + {% if perms.ipam.change_rir %} @@ -48,7 +48,7 @@ ROLE_VLAN_COUNT = """ """ ROLE_ACTIONS = """ - + {% if perms.ipam.change_role %} @@ -145,7 +145,7 @@ VLAN_ROLE_LINK = """ """ VLANGROUP_ACTIONS = """ - + {% 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) diff --git a/netbox/media/devicetype-images/.gitignore b/netbox/media/devicetype-images/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/netbox/media/devicetype-images/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 249ee9e53..89958bc13 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 456eeab6f..ec45f43e5 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -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 */ diff --git a/netbox/project-static/css/rack_elevation.css b/netbox/project-static/css/rack_elevation.css index cbb5015a5..6c04855a3 100644 --- a/netbox/project-static/css/rack_elevation.css +++ b/netbox/project-static/css/rack_elevation.css @@ -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; +} diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 802d1b4e9..ff520a23f 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -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 diff --git a/netbox/project-static/js/rack_elevations.js b/netbox/project-static/js/rack_elevations.js new file mode 100644 index 000000000..138065e3c --- /dev/null +++ b/netbox/project-static/js/rack_elevations.js @@ -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; +}); diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 79064e0dd..88e5325ec 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -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", diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 2b76e0f5a..7cebb744c 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -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) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index cc1760b93..1e8a4e669 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import SecretRole, Secret SECRETROLE_ACTIONS = """ - + {% if perms.secrets.change_secretrole %} diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py index 80bc953bc..884df9d14 100644 --- a/netbox/secrets/tests/test_models.py +++ b/netbox/secrets/tests/test_models.py @@ -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) diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 712ee5861..07d3c360d 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 5bf05b0e2..e99f8b2c4 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -54,7 +54,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index a190a7001..a78879b23 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -31,7 +31,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 5ede19d78..8c457121f 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -119,7 +119,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 68384f12b..292a31c89 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -54,7 +54,7 @@ {% if perms.extras.view_objectchange %} {% endif %} @@ -109,6 +109,30 @@ {% endif %} + + Front Image + + {% if devicetype.front_image %} + + {{ devicetype.front_image.name }} + + {% else %} + + {% endif %} + + + + Rear Image + + {% if devicetype.rear_image %} + + {{ devicetype.rear_image.name }} + + {% else %} + + {% endif %} + + Instances {{ devicetype.instances.count }} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html index 3c22eb9cd..f4f363b14 100644 --- a/netbox/templates/dcim/devicetype_edit.html +++ b/netbox/templates/dcim/devicetype_edit.html @@ -14,6 +14,13 @@ {% render_field form.subdevice_role %} +
+
Rack Images
+
+ {% render_field form.front_image %} + {% render_field form.rear_image %} +
+
{% if form.custom_fields %}
Custom Fields
diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index b0fcf4908..e020c44d6 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,7 +1,6 @@ -{% load helpers %} - -
- - - + + diff --git a/netbox/templates/dcim/inc/rack_elevation_header.html b/netbox/templates/dcim/inc/rack_elevation_header.html new file mode 100644 index 000000000..36ac11398 --- /dev/null +++ b/netbox/templates/dcim/inc/rack_elevation_header.html @@ -0,0 +1,10 @@ +{% load helpers %} +
+ {{ rack.name }} + {% if rack.role %} +
{{ rack.role }} + {% endif %} + {% if rack.facility_id %} +
{{ rack.facility_id }} + {% endif %} +
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 6027afb58..4fc7d21ac 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -34,7 +34,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 3ce1d36f2..316e0e5f0 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -52,7 +52,7 @@ {% if perms.extras.view_objectchange %} {% endif %} @@ -121,18 +121,8 @@
-
-
- Comments -
-
- {% if powerfeed.comments %} - {{ powerfeed.comments|gfm }} - {% else %} - None - {% endif %} -
-
+ {% include 'inc/custom_fields_panel.html' with obj=powerfeed %} + {% include 'extras/inc/tags_panel.html' with tags=powerfeed.tags.all url='dcim:powerfeed_list' %}
@@ -162,6 +152,18 @@
+
+
+ Comments +
+
+ {% if powerfeed.comments %} + {{ powerfeed.comments|gfm }} + {% else %} + None + {% endif %} +
+
{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index b0a6961f6..6d47e08b1 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -48,7 +48,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 51faeef50..b43a4bfdf 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -2,6 +2,7 @@ {% load buttons %} {% load custom_links %} {% load helpers %} +{% load static %} {% block header %}
@@ -45,6 +46,9 @@

{% block title %}Rack {{ rack }}{% endblock %}

{% include 'inc/created_updated.html' with obj=rack %}
+ {% custom_links rack %}
@@ -368,9 +372,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index da4f002d6..f48b81e06 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -1,8 +1,12 @@ {% extends '_base.html' %} {% load helpers %} +{% load static %} {% block content %}
+ Front Rear
@@ -13,16 +17,10 @@
{% for rack in page %}
-
- {{ rack.name|truncatechars:"25" }} -

{{ rack.facility_id|truncatechars:"30" }}

-
+ {% include 'dcim/inc/rack_elevation_header.html' %} {% include 'dcim/inc/rack_elevation.html' with face=rack_face %}
-
- {{ rack.name|truncatechars:"25" }} -

{{ rack.facility_id|truncatechars:"30" }}

-
+ {% include 'dcim/inc/rack_elevation_header.html' %}
{% endfor %}
@@ -41,9 +39,5 @@ {% endblock %} {% block javascript %} - + {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index decfc9261..dfbf65aeb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -60,7 +60,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index 5d649a692..970b54d4d 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -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 %}

{{ obj }}

{% endif %} {% include 'panel_table.html' %} {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
- 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 %}
{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index ee29281f9..16efa6421 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -7,7 +7,7 @@
+
+
+ Difference + +
+
+ {% if diff_added == diff_removed %} + + {% if objectchange.action == 'create' %} + Object created + {% elif objectchange.action == 'delete' %} + Object deleted + {% else %} + No changes + {% endif %} + + {% else %} +
{{ diff_removed|render_json }}
+
{{ diff_added|render_json }}
+ {% endif %} +
+
diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index c38bc2f50..2857d6188 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -44,7 +44,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index c4821d6c3..6977bba4c 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -284,7 +284,7 @@
- Changelog + Change Log
{% if changelog and perms.extras.view_objectchange %}
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html index 66281aace..590ed9208 100644 --- a/netbox/templates/ipam/aggregate.html +++ b/netbox/templates/ipam/aggregate.html @@ -48,7 +48,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html index 6a3a23bde..9611be175 100644 --- a/netbox/templates/ipam/inc/service.html +++ b/netbox/templates/ipam/inc/service.html @@ -14,7 +14,7 @@ {{ service.description }} - + {% if perms.ipam.change_service %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index 50bd90610..08a311492 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html index 324bd927d..7bfa7ba1f 100644 --- a/netbox/templates/ipam/prefix.html +++ b/netbox/templates/ipam/prefix.html @@ -69,7 +69,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 50b964992..246f3c866 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -55,7 +55,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html index 242cfde92..7bb2dea25 100644 --- a/netbox/templates/ipam/vrf.html +++ b/netbox/templates/ipam/vrf.html @@ -46,7 +46,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html index 438a5a943..6045897c9 100644 --- a/netbox/templates/secrets/secret.html +++ b/netbox/templates/secrets/secret.html @@ -34,7 +34,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 71c063502..9a1ed1be8 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/utilities/obj_list.html b/netbox/templates/utilities/obj_list.html index fe70edd3b..e917819c6 100644 --- a/netbox/templates/utilities/obj_list.html +++ b/netbox/templates/utilities/obj_list.html @@ -17,7 +17,7 @@

{% block title %}{{ content_type.model_class|model_name_plural|bettertitle }}{% endblock %}

-
+
{% 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 %}
@@ -34,12 +34,12 @@
{% if bulk_edit_url and permissions.change %} - {% endif %} {% if bulk_delete_url and permissions.delete %} - {% endif %} @@ -51,12 +51,12 @@
{% block bulk_buttons %}{% endblock %} {% if bulk_edit_url and permissions.change %} - {% endif %} {% if bulk_delete_url and permissions.delete %} - {% endif %} @@ -69,11 +69,11 @@ {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
-
- {% if filter_form %} + {% if filter_form %} +
{% include 'inc/search_panel.html' %} - {% endif %} - {% block sidebar %}{% endblock %} -
+ {% block sidebar %}{% endblock %} +
+ {% endif %}
{% endblock %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index 6b90a660d..92540fd70 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -49,7 +49,7 @@ {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 10c1f36d4..8f3bb61d4 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -54,7 +54,7 @@ {% endif %} {% if perms.extras.view_objectchange %} {% endif %} diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 884bdc3df..af4fb34c0 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -4,7 +4,7 @@ from utilities.tables import BaseTable, ToggleColumn from .models import Tenant, TenantGroup TENANTGROUP_ACTIONS = """ - + {% if perms.tenancy.change_tenantgroup %} diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index 95de2a25d..72a5735de 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -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 # diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py index 6181a7ca1..4eb19f539 100644 --- a/netbox/utilities/fields.py +++ b/netbox/utilities/fields.py @@ -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 diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index c629d70a2..c57996a43 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -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