diff --git a/.travis.yml b/.travis.yml index 13c6d406b..29fa87b64 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,7 @@ sudo: required services: - postgresql + - redis-server addons: postgresql: "9.4" language: python diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecdcd652..96d5aa674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,17 +1,322 @@ -2.5.11 (FUTURE) +v2.6.2 (2019-08-02) ## Enhancements -* [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components -* [#3023](https://github.com/digitalocean/netbox/issues/3023) - Add support for filtering cables by connected device -* [#3070](https://github.com/digitalocean/netbox/issues/3070) - Add decommissioning status for devices +* [#984](https://github.com/netbox-community/netbox/issues/984) - Allow ordering circuits by A/Z side +* [#3307](https://github.com/netbox-community/netbox/issues/3307) - Add power panels count to home page +* [#3314](https://github.com/netbox-community/netbox/issues/3314) - Paginate object changelog entries +* [#3367](https://github.com/netbox-community/netbox/issues/3367) - Add BNC port type and coaxial cable type +* [#3368](https://github.com/netbox-community/netbox/issues/3368) - Indicate indefinite changelog retention when applicable +* [#3370](https://github.com/netbox-community/netbox/issues/3370) - Add filter class to VirtualChassis API ## Bug Fixes -* [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware -* [#3112](https://github.com/digitalocean/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device -* [#3116](https://github.com/digitalocean/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint -* [#3118](https://github.com/digitalocean/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled +* [#3018](https://github.com/netbox-community/netbox/issues/3018) - Components connected via a cable must have an equal number of positions +* [#3289](https://github.com/netbox-community/netbox/issues/3289) - Prevent position from being nullified when moving a device to a new rack +* [#3293](https://github.com/netbox-community/netbox/issues/3293) - Enable filtering device components by multiple device IDs +* [#3315](https://github.com/netbox-community/netbox/issues/3315) - Enable filtering devices/interfaces by multiple MAC addresses +* [#3317](https://github.com/netbox-community/netbox/issues/3317) - Fix permissions for ConfigContextBulkDeleteView +* [#3323](https://github.com/netbox-community/netbox/issues/3323) - Fix permission evaluation for interface connections view +* [#3342](https://github.com/netbox-community/netbox/issues/3342) - Fix cluster delete button +* [#3384](https://github.com/netbox-community/netbox/issues/3384) - Maximum and allocated draw fields should be included on power port template creation form +* [#3385](https://github.com/netbox-community/netbox/issues/3385) - Fix power panels list when bulk editing power feeds + +--- + +v2.6.1 (2019-06-25) + +## Enhancements + +* [#3154](https://github.com/netbox-community/netbox/issues/3154) - Add `virtual_chassis_member` device filter +* [#3277](https://github.com/netbox-community/netbox/issues/3277) - Add cable trace buttons for console and power ports +* [#3281](https://github.com/netbox-community/netbox/issues/3281) - Hide custom links which render as empty text + +## Bug Fixes + +* [#3229](https://github.com/netbox-community/netbox/issues/3229) - Limit rack group selection by parent site on racks list +* [#3269](https://github.com/netbox-community/netbox/issues/3269) - Raise validation error when specifying non-existent cable terminations +* [#3275](https://github.com/netbox-community/netbox/issues/3275) - Fix error when adding power outlets to a device type +* [#3279](https://github.com/netbox-community/netbox/issues/3279) - Reset the PostgreSQL sequence for Tag and TaggedItem IDs +* [#3283](https://github.com/netbox-community/netbox/issues/3283) - Fix rack group assignment on PowerFeed CSV import +* [#3290](https://github.com/netbox-community/netbox/issues/3290) - Fix server error when viewing cascaded PDUs +* [#3292](https://github.com/netbox-community/netbox/issues/3292) - Ignore empty URL query parameters + +--- + +v2.6.0 (2019-06-20) + +## New Features + +### Power Panels and Feeds ([#54](https://github.com/netbox-community/netbox/issues/54)) + +NetBox now supports power circuit modeling via two new models: power panels and power feeds. Power feeds are terminated +to power panels and are optionally associated with individual racks. Each power feed defines a supply type (AC/DC), +amperage, voltage, and phase. A power port can be connected directly to a power feed, but a power feed may have only one +power port connected to it. + +Additionally, the power port model, which represents a device's power input, has been extended to include fields +denoting maximum and allocated draw, in volt-amperes. This allows a device (e.g. a PDU) to calculate its total load +compared to its connected power feed. + +### Caching ([#2647](https://github.com/netbox-community/netbox/issues/2647)) + +To improve performance, NetBox now supports caching for most object and list views. Caching is implemented using Redis, +which is now a required dependency. (Previously, Redis was required only if webhooks were enabled.) + +A new configuration parameter is available to control the cache timeout: + +``` +# Cache timeout (in seconds) +CACHE_TIMEOUT = 900 +``` + +### View Permissions ([#323](https://github.com/netbox-community/netbox/issues/323)) + +Django 2.1 introduced the ability to enforce view-only permissions for different object types. NetBox now enforces +these by default. You can grant view permission to a user or group by assigning the "can view" permission for the +desired object(s). + +To exempt certain object types from the enforcement of view permissions, so that any user (including anonymous users) +can view them, add them to the new `EXEMPT_VIEW_PERMISSIONS` setting in `configuration.py`: + +``` +EXEMPT_VIEW_PERMISSIONS = [ + 'dcim.site', + 'ipam.prefix', +] +``` + +To exclude _all_ objects, effectively disabling view permissions and restoring pre-v2.6 behavior, set: + +``` +EXEMPT_VIEW_PERMISSIONS = ['*'] +``` + +### Custom Links ([#969](https://github.com/netbox-community/netbox/issues/969)) + +Custom links are created under the admin UI and will be displayed on each object of the selected type. Link text and +URLs can be formed from Jinja2 template code, with the viewed object passed as context data. For example, to link to an +external NMS from the device view, you might create a custom link with the following URL: + +``` +https://nms.example.com/nodes/?name={{ obj.name }} +``` + +Custom links appear as buttons at the top of the object view. Grouped links will render as a dropdown menu beneath a +single button. + +### Prometheus Metrics ([#3104](https://github.com/netbox-community/netbox/issues/3104)) + +NetBox now supports exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a +popular time series metric platform used for monitoring. Metric exposition can be toggled with the `METRICS_ENABLED` +configuration setting; it is not enabled by default. NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g. +`https://netbox.local/metrics`. + +NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of +different types of metrics, including: + +* Per model insert, update, and delete counters +* Per view request counters +* Per view request latency histograms +* Request body size histograms +* Response body size histograms +* Response code counters +* Database connection, execution, and error counters +* Cache hit, miss, and invalidation counters +* Django middleware latency histograms +* Other Django related metadata metrics + +For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance. See the documentation +for more details on using Prometheus metrics in NetBox. + +## Changes + +### New Dependency: Redis + +[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component +of NetBox since the introduction of webhooks in version 2.4, it is now required to support NetBox's new caching +functionality (as well as other planned features). Redis can be installed via your platform's package manager: for +example, `sudo apt-get install redis-server` on Ubuntu or `sudo yum install redis` on CentOS. + +The Redis database is configured using a configuration setting similar to `DATABASE` in `configuration.py`: + +``` +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'CACHE_DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, +} +``` + +Note that if you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but +an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The +`DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting. It is +highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result +in webhook processing data being lost during cache flushing events. + +### API Support for Specifying Related Objects by Attributes([#3077](https://github.com/netbox-community/netbox/issues/3077)) + +Previously, specifying a related object in an API request required knowing the primary key (integer ID) of that object. +For example, when creating a new device, its rack would be specified as an integer: + +``` +{ + "name": "MyNewDevice", + "rack": 123, + ... +} +``` + +The NetBox API now also supports referencing related objects by a set of sufficiently unique attrbiutes. For example, a +rack can be identified by its name and parent site: + +``` +{ + "name": "MyNewDevice", + "rack": { + "site": { + "name": "Equinix DC6" + }, + "name": "R204" + }, + ... +} +``` + +There is no limit to the depth of nested references. Note that if the provided parameters do not return exactly one +object, a validation error is raised. + +### API Device/VM Config Context Included by Default ([#2350](https://github.com/netbox-community/netbox/issues/2350)) + +The rendered config context for devices and VMs is now included by default in all API results (list and detail views). +Previously, the rendered config context was available only in the detail view for individual objects. Users with large +amounts of context data may observe a performance drop when returning multiple objects. To combat this, in cases where +the rendered config context is not needed, the query parameter `?exclude=config_context` may be appended to the request +URL to exclude the config context data from the API response. + +### Changes to Tag Permissions + +NetBox now makes use of its own `Tag` model instead of the stock model which ships with django-taggit. This new model +lives in the `extras` app and thus any permissions that you may have configured using "Taggit | Tag" should be changed +to now use "Extras | Tag." Also note that the admin interface for tags has been removed as it was redundant to the +functionality provided by the front end UI. + +### CORS_ORIGIN_WHITELIST Requires URI Scheme + +If you have the `CORS_ORIGIN_WHITELIST` configuration parameter defined, note that each origin must now incldue a URI +scheme. This change was introuced in django-cors-headers 3.0. + +## Enhancements + +* [#166](https://github.com/netbox-community/netbox/issues/166) - Add `dns_name` field to IPAddress +* [#524](https://github.com/netbox-community/netbox/issues/524) - Added power utilization graphs to power feeds, devices, and racks +* [#1792](https://github.com/netbox-community/netbox/issues/1792) - Add CustomFieldChoices API endpoint at `/api/extras/_custom_field_choices/` +* [#1863](https://github.com/netbox-community/netbox/issues/1863) - Add child object counts to API representation of organizational objects +* [#2324](https://github.com/netbox-community/netbox/issues/2324) - Add `color` field for tags +* [#2643](https://github.com/netbox-community/netbox/issues/2643) - Add `description` field to console/power components and device bays +* [#2791](https://github.com/netbox-community/netbox/issues/2791) - Add `comments` field for tags +* [#2920](https://github.com/netbox-community/netbox/issues/2920) - Rename Interface `form_factor` to `type` (backward-compatible until v2.7) +* [#2926](https://github.com/netbox-community/netbox/issues/2926) - Add change logging to the Tag model +* [#3038](https://github.com/netbox-community/netbox/issues/3038) - OR logic now used when multiple values of a query filter are passed +* [#3264](https://github.com/netbox-community/netbox/issues/3264) - Annotate changelog retention time on UI + +## Bug Fixes + +* [#2968](https://github.com/netbox-community/netbox/issues/2968) - Correct API documentation for SerializerMethodFields +* [#3176](https://github.com/netbox-community/netbox/issues/3176) - Add cable trace button for console server ports and power outlets +* [#3231](https://github.com/netbox-community/netbox/issues/3231) - Fixed cosmetic error indicating a missing schema migration +* [#3239](https://github.com/netbox-community/netbox/issues/3239) - Corrected count of tags reported via API + +## Bug Fixes From v2.6-beta1 + +* [#3123](https://github.com/netbox-community/netbox/issues/3123) - Exempt `/metrics` view from authentication +* [#3125](https://github.com/netbox-community/netbox/issues/3125) - Fix exception when viewing PDUs +* [#3126](https://github.com/netbox-community/netbox/issues/3126) - Incorrect calculation of PowerFeed available power +* [#3130](https://github.com/netbox-community/netbox/issues/3130) - Fix exception when creating a new power outlet +* [#3136](https://github.com/netbox-community/netbox/issues/3136) - Add power draw fields to power port creation form +* [#3137](https://github.com/netbox-community/netbox/issues/3137) - Add `power_port` and `feed_leg` fields to power outlet creation form +* [#3140](https://github.com/netbox-community/netbox/issues/3140) - Add bulk edit capability for power outlets and console server ports +* [#3204](https://github.com/netbox-community/netbox/issues/3204) - Fix interface filtering when connecting cables +* [#3207](https://github.com/netbox-community/netbox/issues/3207) - Fix link for connecting interface to rear port +* [#3258](https://github.com/netbox-community/netbox/issues/3258) - Exception raised when creating/viewing a circuit with a non-connected termination + +## API Changes + +* New API endpoints for power modeling: `/api/dcim/power-panels/` and `/api/dcim/power-feeds/` +* New API endpoint for custom field choices: `/api/extras/_custom_field_choices/` +* ForeignKey fields now accept either the related object PK or a dictionary of attributes describing the related object. +* Organizational objects now include child object counts. For example, the Role serializer includes `prefix_count` and `vlan_count`. +* The `id__in` filter is now deprecated and will be removed in v2.7. (Begin using the `?id=1&id=2` format instead.) +* Added a `description` field for all device components. +* dcim.Device: The devices list endpoint now includes rendered context data. +* dcim.DeviceType: `instance_count` has been renamed to `device_count`. +* dcim.Interface: `form_factor` has been renamed to `type`. Backward compatibility for `form_factor` will be maintained until NetBox v2.7. +* dcim.Interface: The `type` filter has been renamed to `kind`. +* dcim.Site: The `count_*` read-only fields have been renamed to `*_count` for consistency with other objects. +* dcim.Site: Added the `virtualmachine_count` read-only field. +* extras.Tag: Added `color` and `comments` fields to the Tag serializer. +* virtualization.VirtualMachine: The virtual machines list endpoint now includes rendered context data. + +--- + +2.5.13 (2019-05-31) + +## Enhancements + +* [#2813](https://github.com/netbox-community/netbox/issues/2813) - Add tenant group filters +* [#3085](https://github.com/netbox-community/netbox/issues/3085) - Catch all exceptions during export template rendering +* [#3138](https://github.com/netbox-community/netbox/issues/3138) - Add 2.5GE and 5GE interface form factors +* [#3151](https://github.com/netbox-community/netbox/issues/3151) - Add inventory item count to manufacturers list +* [#3156](https://github.com/netbox-community/netbox/issues/3156) - Add site link to rack reservations overview +* [#3183](https://github.com/netbox-community/netbox/issues/3183) - Enable bulk deletion of sites +* [#3185](https://github.com/netbox-community/netbox/issues/3185) - Improve performance for custom field access within templates +* [#3186](https://github.com/netbox-community/netbox/issues/3186) - Add interface name filter for IP addresses + +## Bug Fixes + +* [#3031](https://github.com/netbox-community/netbox/issues/3031) - Fixed form field population of tags with spaces +* [#3132](https://github.com/netbox-community/netbox/issues/3132) - Circuit termination missing from available cable termination types +* [#3150](https://github.com/netbox-community/netbox/issues/3150) - Fix formatting of cable length during cable trace +* [#3184](https://github.com/netbox-community/netbox/issues/3184) - Correctly display color block for white cables +* [#3190](https://github.com/netbox-community/netbox/issues/3190) - Fix custom field rendering for Jinja2 export templates +* [#3211](https://github.com/netbox-community/netbox/issues/3211) - Fix error handling when attempting to delete a protected object via API +* [#3223](https://github.com/netbox-community/netbox/issues/3223) - Fix filtering devices by "has power outlets" +* [#3227](https://github.com/netbox-community/netbox/issues/3227) - Fix exception when deleting a circuit with a termination(s) +* [#3228](https://github.com/netbox-community/netbox/issues/3228) - Fixed login link retaining query parameters + +--- + +2.5.12 (2019-05-01) + +## Bug Fixes + +* [#3127](https://github.com/netbox-community/netbox/issues/3127) - Fix natural ordering of device components + +--- + +2.5.11 (2019-04-29) + +## Notes + +This release upgrades the Django framework to version 2.2. + +## Enhancements + +* [#2986](https://github.com/netbox-community/netbox/issues/2986) - Improve natural ordering of device components +* [#3023](https://github.com/netbox-community/netbox/issues/3023) - Add support for filtering cables by connected device +* [#3070](https://github.com/netbox-community/netbox/issues/3070) - Add decommissioning status for devices + +## Bug Fixes + +* [#2621](https://github.com/netbox-community/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware +* [#3072](https://github.com/netbox-community/netbox/issues/3072) - Preserve multiselect filter values when updating per-page count for list views +* [#3112](https://github.com/netbox-community/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device +* [#3116](https://github.com/netbox-community/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint +* [#3118](https://github.com/netbox-community/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled --- @@ -19,17 +324,17 @@ v2.5.10 (2019-04-08) ## Enhancements -* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates +* [#3052](https://github.com/netbox-community/netbox/issues/3052) - Add Jinja2 support for export templates ## Bug Fixes -* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view -* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces -* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API -* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update -* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search -* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint -* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API +* [#2937](https://github.com/netbox-community/netbox/issues/2937) - Redirect to list view after editing an object from list view +* [#3036](https://github.com/netbox-community/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces +* [#3039](https://github.com/netbox-community/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API +* [#3041](https://github.com/netbox-community/netbox/issues/3041) - Fix form widget for bulk cable label update +* [#3044](https://github.com/netbox-community/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search +* [#3046](https://github.com/netbox-community/netbox/issues/3046) - Fix exception at reports API endpoint +* [#3047](https://github.com/netbox-community/netbox/issues/3047) - Fix exception when writing mac address for an interface via API --- @@ -37,24 +342,24 @@ v2.5.9 (2019-04-01) ## Enhancements -* [#2933](https://github.com/digitalocean/netbox/issues/2933) - Add username to outbound webhook requests -* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+) -* [#3025](https://github.com/digitalocean/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request) +* [#2933](https://github.com/netbox-community/netbox/issues/2933) - Add username to outbound webhook requests +* [#3011](https://github.com/netbox-community/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+) +* [#3025](https://github.com/netbox-community/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request) ## Bug Fixes -* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes deterministic ordering of interfaces -* [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering -* [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE -* [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry -* [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable -* [#3001](https://github.com/digitalocean/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type` -* [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering -* [#3019](https://github.com/digitalocean/netbox/issues/3019) - Fix tag population when running NetBox within a path -* [#3022](https://github.com/digitalocean/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint -* [#3026](https://github.com/digitalocean/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher -* [#3027](https://github.com/digitalocean/netbox/issues/3027) - Ignore empty local context data when rendering config contexts -* [#3032](https://github.com/digitalocean/netbox/issues/3032) - Save assigned tags when creating a new secret +* [#2207](https://github.com/netbox-community/netbox/issues/2207) - Fixes deterministic ordering of interfaces +* [#2577](https://github.com/netbox-community/netbox/issues/2577) - Clarification of wording in API regarding filtering +* [#2924](https://github.com/netbox-community/netbox/issues/2924) - Add interface type for QSFP28 50GE +* [#2936](https://github.com/netbox-community/netbox/issues/2936) - Fix device role selection showing duplicate first entry +* [#2998](https://github.com/netbox-community/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable +* [#3001](https://github.com/netbox-community/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type` +* [#3014](https://github.com/netbox-community/netbox/issues/3014) - Fixes VM Role filtering +* [#3019](https://github.com/netbox-community/netbox/issues/3019) - Fix tag population when running NetBox within a path +* [#3022](https://github.com/netbox-community/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint +* [#3026](https://github.com/netbox-community/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher +* [#3027](https://github.com/netbox-community/netbox/issues/3027) - Ignore empty local context data when rendering config contexts +* [#3032](https://github.com/netbox-community/netbox/issues/3032) - Save assigned tags when creating a new secret --- @@ -62,29 +367,29 @@ v2.5.8 (2019-03-11) ## Enhancements -* [#2435](https://github.com/digitalocean/netbox/issues/2435) - Printer friendly CSS +* [#2435](https://github.com/netbox-community/netbox/issues/2435) - Printer friendly CSS ## Bug Fixes -* [#2065](https://github.com/digitalocean/netbox/issues/2065) - Correct documentation for VM interface serializer -* [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs -* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions -* [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default -* [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API -* [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint -* [#2940](https://github.com/digitalocean/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned -* [#2944](https://github.com/digitalocean/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any) -* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function -* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows -* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices -* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length -* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API -* [#2972](https://github.com/digitalocean/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data -* [#2976](https://github.com/digitalocean/netbox/issues/2976) - Add delete button to tag view -* [#2980](https://github.com/digitalocean/netbox/issues/2980) - Improve rendering time for API docs -* [#2982](https://github.com/digitalocean/netbox/issues/2982) - Correct CSS class assignment on color picker -* [#2984](https://github.com/digitalocean/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion -* [#2985](https://github.com/digitalocean/netbox/issues/2985) - Fix pagination page length for rack elevations +* [#2065](https://github.com/netbox-community/netbox/issues/2065) - Correct documentation for VM interface serializer +* [#2705](https://github.com/netbox-community/netbox/issues/2705) - Fix endpoint grouping in API docs +* [#2781](https://github.com/netbox-community/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions +* [#2923](https://github.com/netbox-community/netbox/issues/2923) - Provider filter form's site field should be blank by default +* [#2938](https://github.com/netbox-community/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API +* [#2939](https://github.com/netbox-community/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint +* [#2940](https://github.com/netbox-community/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned +* [#2944](https://github.com/netbox-community/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any) +* [#2952](https://github.com/netbox-community/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function +* [#2954](https://github.com/netbox-community/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows +* [#2961](https://github.com/netbox-community/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices +* [#2962](https://github.com/netbox-community/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length +* [#2966](https://github.com/netbox-community/netbox/issues/2966) - Accept `null` cable length_unit via API +* [#2972](https://github.com/netbox-community/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data +* [#2976](https://github.com/netbox-community/netbox/issues/2976) - Add delete button to tag view +* [#2980](https://github.com/netbox-community/netbox/issues/2980) - Improve rendering time for API docs +* [#2982](https://github.com/netbox-community/netbox/issues/2982) - Correct CSS class assignment on color picker +* [#2984](https://github.com/netbox-community/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion +* [#2985](https://github.com/netbox-community/netbox/issues/2985) - Fix pagination page length for rack elevations --- @@ -92,27 +397,27 @@ v2.5.7 (2019-02-21) ## Enhancements -* [#2357](https://github.com/digitalocean/netbox/issues/2357) - Enable filtering of devices by rack face -* [#2638](https://github.com/digitalocean/netbox/issues/2638) - Add button to copy unlocked secret to clipboard -* [#2870](https://github.com/digitalocean/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields -* [#2878](https://github.com/digitalocean/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber -* [#2890](https://github.com/digitalocean/netbox/issues/2890) - Add port types for APC fiber -* [#2898](https://github.com/digitalocean/netbox/issues/2898) - Enable filtering cables list by connection status -* [#2903](https://github.com/digitalocean/netbox/issues/2903) - Clarify purpose of tags field on interface edit form +* [#2357](https://github.com/netbox-community/netbox/issues/2357) - Enable filtering of devices by rack face +* [#2638](https://github.com/netbox-community/netbox/issues/2638) - Add button to copy unlocked secret to clipboard +* [#2870](https://github.com/netbox-community/netbox/issues/2870) - Add Markdown rendering for provider NOC/admin contact fields +* [#2878](https://github.com/netbox-community/netbox/issues/2878) - Add cable types for OS1/OS2 singlemode fiber +* [#2890](https://github.com/netbox-community/netbox/issues/2890) - Add port types for APC fiber +* [#2898](https://github.com/netbox-community/netbox/issues/2898) - Enable filtering cables list by connection status +* [#2903](https://github.com/netbox-community/netbox/issues/2903) - Clarify purpose of tags field on interface edit form ## Bug Fixes -* [#2852](https://github.com/digitalocean/netbox/issues/2852) - Allow filtering devices by null rack position -* [#2884](https://github.com/digitalocean/netbox/issues/2884) - Don't display connect button for wireless interfaces -* [#2888](https://github.com/digitalocean/netbox/issues/2888) - Correct foreground color of device roles in rack elevations -* [#2893](https://github.com/digitalocean/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view -* [#2895](https://github.com/digitalocean/netbox/issues/2895) - Fix filtering of nullable character fields -* [#2901](https://github.com/digitalocean/netbox/issues/2901) - Fix ordering regions by site count -* [#2910](https://github.com/digitalocean/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements -* [#2912](https://github.com/digitalocean/netbox/issues/2912) - Cable type in filter form should be blank by default -* [#2913](https://github.com/digitalocean/netbox/issues/2913) - Fix assigned prefixes link on VRF view -* [#2914](https://github.com/digitalocean/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list -* [#2915](https://github.com/digitalocean/netbox/issues/2915) - Fix bulk editing of pass-through ports +* [#2852](https://github.com/netbox-community/netbox/issues/2852) - Allow filtering devices by null rack position +* [#2884](https://github.com/netbox-community/netbox/issues/2884) - Don't display connect button for wireless interfaces +* [#2888](https://github.com/netbox-community/netbox/issues/2888) - Correct foreground color of device roles in rack elevations +* [#2893](https://github.com/netbox-community/netbox/issues/2893) - Remove duplicate display of VRF RD on IP address view +* [#2895](https://github.com/netbox-community/netbox/issues/2895) - Fix filtering of nullable character fields +* [#2901](https://github.com/netbox-community/netbox/issues/2901) - Fix ordering regions by site count +* [#2910](https://github.com/netbox-community/netbox/issues/2910) - Fix config context list and edit forms to use Select2 elements +* [#2912](https://github.com/netbox-community/netbox/issues/2912) - Cable type in filter form should be blank by default +* [#2913](https://github.com/netbox-community/netbox/issues/2913) - Fix assigned prefixes link on VRF view +* [#2914](https://github.com/netbox-community/netbox/issues/2914) - Fix empty connected circuit link on device interfaces list +* [#2915](https://github.com/netbox-community/netbox/issues/2915) - Fix bulk editing of pass-through ports --- @@ -120,22 +425,22 @@ v2.5.6 (2019-02-13) ## Enhancements -* [#2758](https://github.com/digitalocean/netbox/issues/2758) - Add cable trace button to pass-through ports -* [#2839](https://github.com/digitalocean/netbox/issues/2839) - Add "110 punch" type for pass-through ports -* [#2854](https://github.com/digitalocean/netbox/issues/2854) - Enable bulk editing of pass-through ports -* [#2866](https://github.com/digitalocean/netbox/issues/2866) - Add cellular interface types (GSM/CDMA/LTE) +* [#2758](https://github.com/netbox-community/netbox/issues/2758) - Add cable trace button to pass-through ports +* [#2839](https://github.com/netbox-community/netbox/issues/2839) - Add "110 punch" type for pass-through ports +* [#2854](https://github.com/netbox-community/netbox/issues/2854) - Enable bulk editing of pass-through ports +* [#2866](https://github.com/netbox-community/netbox/issues/2866) - Add cellular interface types (GSM/CDMA/LTE) ## Bug Fixes -* [#2841](https://github.com/digitalocean/netbox/issues/2841) - Fix filtering by VRF for prefix and IP address lists -* [#2844](https://github.com/digitalocean/netbox/issues/2844) - Correct display of far cable end for pass-through ports -* [#2845](https://github.com/digitalocean/netbox/issues/2845) - Enable filtering of rack unit list by unit ID -* [#2856](https://github.com/digitalocean/netbox/issues/2856) - Fix navigation links between LAG interfaces and their members on device view -* [#2857](https://github.com/digitalocean/netbox/issues/2857) - Add `display_name` to DeviceType API serializer; fix DeviceType list for bulk device edit -* [#2862](https://github.com/digitalocean/netbox/issues/2862) - Follow return URL when connecting a cable -* [#2864](https://github.com/digitalocean/netbox/issues/2864) - Correct display of VRF name when no RD is assigned -* [#2877](https://github.com/digitalocean/netbox/issues/2877) - Fixed device role label display on light background color -* [#2880](https://github.com/digitalocean/netbox/issues/2880) - Sanitize user password if an exception is raised during login +* [#2841](https://github.com/netbox-community/netbox/issues/2841) - Fix filtering by VRF for prefix and IP address lists +* [#2844](https://github.com/netbox-community/netbox/issues/2844) - Correct display of far cable end for pass-through ports +* [#2845](https://github.com/netbox-community/netbox/issues/2845) - Enable filtering of rack unit list by unit ID +* [#2856](https://github.com/netbox-community/netbox/issues/2856) - Fix navigation links between LAG interfaces and their members on device view +* [#2857](https://github.com/netbox-community/netbox/issues/2857) - Add `display_name` to DeviceType API serializer; fix DeviceType list for bulk device edit +* [#2862](https://github.com/netbox-community/netbox/issues/2862) - Follow return URL when connecting a cable +* [#2864](https://github.com/netbox-community/netbox/issues/2864) - Correct display of VRF name when no RD is assigned +* [#2877](https://github.com/netbox-community/netbox/issues/2877) - Fixed device role label display on light background color +* [#2880](https://github.com/netbox-community/netbox/issues/2880) - Sanitize user password if an exception is raised during login --- @@ -143,16 +448,16 @@ v2.5.5 (2019-01-31) ## Enhancements -* [#2805](https://github.com/digitalocean/netbox/issues/2805) - Allow null route distinguisher for VRFs -* [#2809](https://github.com/digitalocean/netbox/issues/2809) - Remove VRF child prefixes table; link to main prefixes view -* [#2825](https://github.com/digitalocean/netbox/issues/2825) - Include directly connected device for front/rear ports +* [#2805](https://github.com/netbox-community/netbox/issues/2805) - Allow null route distinguisher for VRFs +* [#2809](https://github.com/netbox-community/netbox/issues/2809) - Remove VRF child prefixes table; link to main prefixes view +* [#2825](https://github.com/netbox-community/netbox/issues/2825) - Include directly connected device for front/rear ports ## Bug Fixes -* [#2824](https://github.com/digitalocean/netbox/issues/2824) - Fix template exception when viewing rack elevations list -* [#2833](https://github.com/digitalocean/netbox/issues/2833) - Fix form widget for front port template creation -* [#2835](https://github.com/digitalocean/netbox/issues/2835) - Fix certain model filters did not support the `q` query param -* [#2837](https://github.com/digitalocean/netbox/issues/2837) - Fix select2 nullable filter fields add multiple null_option elements when paging +* [#2824](https://github.com/netbox-community/netbox/issues/2824) - Fix template exception when viewing rack elevations list +* [#2833](https://github.com/netbox-community/netbox/issues/2833) - Fix form widget for front port template creation +* [#2835](https://github.com/netbox-community/netbox/issues/2835) - Fix certain model filters did not support the `q` query param +* [#2837](https://github.com/netbox-community/netbox/issues/2837) - Fix select2 nullable filter fields add multiple null_option elements when paging --- @@ -160,25 +465,25 @@ v2.5.4 (2019-01-29) ## Enhancements -* [#2516](https://github.com/digitalocean/netbox/issues/2516) - Implemented Select2 for all Model backed selection fields -* [#2590](https://github.com/digitalocean/netbox/issues/2590) - Implemented the color picker with Select2 to show colors in the background -* [#2733](https://github.com/digitalocean/netbox/issues/2733) - Enable bulk assignment of MAC addresses to interfaces -* [#2735](https://github.com/digitalocean/netbox/issues/2735) - Implemented Select2 for all list filter form select elements -* [#2753](https://github.com/digitalocean/netbox/issues/2753) - Implemented Select2 to replace most all instances of select fields in forms -* [#2766](https://github.com/digitalocean/netbox/issues/2766) - Extend users admin table to include superuser and active fields -* [#2782](https://github.com/digitalocean/netbox/issues/2782) - Add `is_pool` field for prefix filtering -* [#2807](https://github.com/digitalocean/netbox/issues/2807) - Include device site/rack assignment in cable trace view -* [#2808](https://github.com/digitalocean/netbox/issues/2808) - Loosen version pinning for Django to allow patch releases -* [#2810](https://github.com/digitalocean/netbox/issues/2810) - Include description fields in interface connections export +* [#2516](https://github.com/netbox-community/netbox/issues/2516) - Implemented Select2 for all Model backed selection fields +* [#2590](https://github.com/netbox-community/netbox/issues/2590) - Implemented the color picker with Select2 to show colors in the background +* [#2733](https://github.com/netbox-community/netbox/issues/2733) - Enable bulk assignment of MAC addresses to interfaces +* [#2735](https://github.com/netbox-community/netbox/issues/2735) - Implemented Select2 for all list filter form select elements +* [#2753](https://github.com/netbox-community/netbox/issues/2753) - Implemented Select2 to replace most all instances of select fields in forms +* [#2766](https://github.com/netbox-community/netbox/issues/2766) - Extend users admin table to include superuser and active fields +* [#2782](https://github.com/netbox-community/netbox/issues/2782) - Add `is_pool` field for prefix filtering +* [#2807](https://github.com/netbox-community/netbox/issues/2807) - Include device site/rack assignment in cable trace view +* [#2808](https://github.com/netbox-community/netbox/issues/2808) - Loosen version pinning for Django to allow patch releases +* [#2810](https://github.com/netbox-community/netbox/issues/2810) - Include description fields in interface connections export ## Bug Fixes -* [#2779](https://github.com/digitalocean/netbox/issues/2779) - Include "none" option when filter IP addresses by role -* [#2783](https://github.com/digitalocean/netbox/issues/2783) - Fix AttributeError exception when attempting to delete region(s) -* [#2795](https://github.com/digitalocean/netbox/issues/2795) - Fix duplicate display of pagination controls on child prefix/IP tables -* [#2798](https://github.com/digitalocean/netbox/issues/2798) - Properly URL-encode "map it" link on site view -* [#2802](https://github.com/digitalocean/netbox/issues/2802) - Better error handling for unsupported NAPALM methods -* [#2816](https://github.com/digitalocean/netbox/issues/2816) - Handle exception when deleting a device with connected components +* [#2779](https://github.com/netbox-community/netbox/issues/2779) - Include "none" option when filter IP addresses by role +* [#2783](https://github.com/netbox-community/netbox/issues/2783) - Fix AttributeError exception when attempting to delete region(s) +* [#2795](https://github.com/netbox-community/netbox/issues/2795) - Fix duplicate display of pagination controls on child prefix/IP tables +* [#2798](https://github.com/netbox-community/netbox/issues/2798) - Properly URL-encode "map it" link on site view +* [#2802](https://github.com/netbox-community/netbox/issues/2802) - Better error handling for unsupported NAPALM methods +* [#2816](https://github.com/netbox-community/netbox/issues/2816) - Handle exception when deleting a device with connected components --- @@ -186,20 +491,20 @@ v2.5.3 (2019-01-11) ## Enhancements -* [#1630](https://github.com/digitalocean/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length -* [#1870](https://github.com/digitalocean/netbox/issues/1870) - Add per-page toggle to object lists -* [#1871](https://github.com/digitalocean/netbox/issues/1871) - Enable filtering sites by parent region -* [#1983](https://github.com/digitalocean/netbox/issues/1983) - Enable regular expressions when bulk renaming device components -* [#2682](https://github.com/digitalocean/netbox/issues/2682) - Add DAC and AOC cable types -* [#2693](https://github.com/digitalocean/netbox/issues/2693) - Additional cable colors -* [#2726](https://github.com/digitalocean/netbox/issues/2726) - Include cables in global search +* [#1630](https://github.com/netbox-community/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length +* [#1870](https://github.com/netbox-community/netbox/issues/1870) - Add per-page toggle to object lists +* [#1871](https://github.com/netbox-community/netbox/issues/1871) - Enable filtering sites by parent region +* [#1983](https://github.com/netbox-community/netbox/issues/1983) - Enable regular expressions when bulk renaming device components +* [#2682](https://github.com/netbox-community/netbox/issues/2682) - Add DAC and AOC cable types +* [#2693](https://github.com/netbox-community/netbox/issues/2693) - Additional cable colors +* [#2726](https://github.com/netbox-community/netbox/issues/2726) - Include cables in global search ## Bug Fixes -* [#2742](https://github.com/digitalocean/netbox/issues/2742) - Preserve cluster assignment when editing a device -* [#2757](https://github.com/digitalocean/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable -* [#2762](https://github.com/digitalocean/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint -* [#2777](https://github.com/digitalocean/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import +* [#2742](https://github.com/netbox-community/netbox/issues/2742) - Preserve cluster assignment when editing a device +* [#2757](https://github.com/netbox-community/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable +* [#2762](https://github.com/netbox-community/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint +* [#2777](https://github.com/netbox-community/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import --- @@ -208,21 +513,21 @@ v2.5.2 (2018-12-21) ## Enhancements -* [#2561](https://github.com/digitalocean/netbox/issues/2561) - Add 200G and 400G interface types -* [#2701](https://github.com/digitalocean/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value +* [#2561](https://github.com/netbox-community/netbox/issues/2561) - Add 200G and 400G interface types +* [#2701](https://github.com/netbox-community/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value ## Bug Fixes -* [#2673](https://github.com/digitalocean/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected -* [#2691](https://github.com/digitalocean/netbox/issues/2691) - Cable trace should follow circuits -* [#2698](https://github.com/digitalocean/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs -* [#2704](https://github.com/digitalocean/netbox/issues/2704) - Fix form select widget population on parent with null value -* [#2707](https://github.com/digitalocean/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling -* [#2712](https://github.com/digitalocean/netbox/issues/2712) - Preserve list filtering after editing objects in bulk -* [#2717](https://github.com/digitalocean/netbox/issues/2717) - Fix bulk deletion of tags -* [#2721](https://github.com/digitalocean/netbox/issues/2721) - Detect loops when tracing front/rear ports -* [#2723](https://github.com/digitalocean/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags -* [#2724](https://github.com/digitalocean/netbox/issues/2724) - Limit rear port choices to current device when editing a front port +* [#2673](https://github.com/netbox-community/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected +* [#2691](https://github.com/netbox-community/netbox/issues/2691) - Cable trace should follow circuits +* [#2698](https://github.com/netbox-community/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs +* [#2704](https://github.com/netbox-community/netbox/issues/2704) - Fix form select widget population on parent with null value +* [#2707](https://github.com/netbox-community/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling +* [#2712](https://github.com/netbox-community/netbox/issues/2712) - Preserve list filtering after editing objects in bulk +* [#2717](https://github.com/netbox-community/netbox/issues/2717) - Fix bulk deletion of tags +* [#2721](https://github.com/netbox-community/netbox/issues/2721) - Detect loops when tracing front/rear ports +* [#2723](https://github.com/netbox-community/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags +* [#2724](https://github.com/netbox-community/netbox/issues/2724) - Limit rear port choices to current device when editing a front port --- @@ -230,20 +535,20 @@ v2.5.1 (2018-12-13) ## Enhancements -* [#2655](https://github.com/digitalocean/netbox/issues/2655) - Add 128GFC Fibrechannel interface type -* [#2674](https://github.com/digitalocean/netbox/issues/2674) - Enable filtering changelog by object type under web UI +* [#2655](https://github.com/netbox-community/netbox/issues/2655) - Add 128GFC Fibrechannel interface type +* [#2674](https://github.com/netbox-community/netbox/issues/2674) - Enable filtering changelog by object type under web UI ## Bug Fixes -* [#2662](https://github.com/digitalocean/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs -* [#2663](https://github.com/digitalocean/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view -* [#2666](https://github.com/digitalocean/netbox/issues/2666) - Correct display of length unit in cables list -* [#2676](https://github.com/digitalocean/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField -* [#2678](https://github.com/digitalocean/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission -* [#2680](https://github.com/digitalocean/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint -* [#2683](https://github.com/digitalocean/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort -* [#2684](https://github.com/digitalocean/netbox/issues/2684) - Fix custom field filtering -* [#2687](https://github.com/digitalocean/netbox/issues/2687) - Correct naming of before/after filters for changelog entries +* [#2662](https://github.com/netbox-community/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs +* [#2663](https://github.com/netbox-community/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view +* [#2666](https://github.com/netbox-community/netbox/issues/2666) - Correct display of length unit in cables list +* [#2676](https://github.com/netbox-community/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField +* [#2678](https://github.com/netbox-community/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission +* [#2680](https://github.com/netbox-community/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint +* [#2683](https://github.com/netbox-community/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort +* [#2684](https://github.com/netbox-community/netbox/issues/2684) - Fix custom field filtering +* [#2687](https://github.com/netbox-community/netbox/issues/2687) - Correct naming of before/after filters for changelog entries --- @@ -261,7 +566,7 @@ The UserAction model, which was deprecated by the new change logging feature in ### View Permissions in Django 2.1 -Django 2.1 introduces view permissions for object types (not to be confused with object-level permissions). Implementation of [#323](https://github.com/digitalocean/netbox/issues/323) is planned for NetBox v2.6. Users are encourage to begin assigning view permissions as desired in preparation for their eventual enforcement. +Django 2.1 introduces view permissions for object types (not to be confused with object-level permissions). Implementation of [#323](https://github.com/netbox-community/netbox/issues/323) is planned for NetBox v2.6. Users are encourage to begin assigning view permissions as desired in preparation for their eventual enforcement. ### upgrade.sh No Longer Invokes sudo @@ -269,37 +574,37 @@ The `upgrade.sh` script has been tweaked so that it no longer invokes `sudo` int ## New Features -### Patch Panels and Cables ([#20](https://github.com/digitalocean/netbox/issues/20)) +### Patch Panels and Cables ([#20](https://github.com/netbox-community/netbox/issues/20)) NetBox now supports modeling physical cables for console, power, and interface connections. The new pass-through port component type has also been introduced to model patch panels and similar devices. ## Enhancements -* [#450](https://github.com/digitalocean/netbox/issues/450) - Added `outer_width` and `outer_depth` fields to rack model -* [#867](https://github.com/digitalocean/netbox/issues/867) - Added `description` field to circuit terminations -* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added an `asset_tag` field for racks -* [#1931](https://github.com/digitalocean/netbox/issues/1931) - Added a count of assigned IP addresses to the interface API serializer -* [#2000](https://github.com/digitalocean/netbox/issues/2000) - Dropped support for Python 2 -* [#2053](https://github.com/digitalocean/netbox/issues/2053) - Introduced the `LOGIN_TIMEOUT` configuration setting -* [#2057](https://github.com/digitalocean/netbox/issues/2057) - Added description columns to interface connections list -* [#2104](https://github.com/digitalocean/netbox/issues/2104) - Added a `status` field for racks -* [#2165](https://github.com/digitalocean/netbox/issues/2165) - Improved natural ordering of Interfaces -* [#2292](https://github.com/digitalocean/netbox/issues/2292) - Removed the deprecated UserAction model -* [#2367](https://github.com/digitalocean/netbox/issues/2367) - Removed deprecated RPCClient functionality -* [#2426](https://github.com/digitalocean/netbox/issues/2426) - Introduced `SESSION_FILE_PATH` configuration setting for authentication without write access to database -* [#2594](https://github.com/digitalocean/netbox/issues/2594) - `upgrade.sh` no longer invokes sudo +* [#450](https://github.com/netbox-community/netbox/issues/450) - Added `outer_width` and `outer_depth` fields to rack model +* [#867](https://github.com/netbox-community/netbox/issues/867) - Added `description` field to circuit terminations +* [#1444](https://github.com/netbox-community/netbox/issues/1444) - Added an `asset_tag` field for racks +* [#1931](https://github.com/netbox-community/netbox/issues/1931) - Added a count of assigned IP addresses to the interface API serializer +* [#2000](https://github.com/netbox-community/netbox/issues/2000) - Dropped support for Python 2 +* [#2053](https://github.com/netbox-community/netbox/issues/2053) - Introduced the `LOGIN_TIMEOUT` configuration setting +* [#2057](https://github.com/netbox-community/netbox/issues/2057) - Added description columns to interface connections list +* [#2104](https://github.com/netbox-community/netbox/issues/2104) - Added a `status` field for racks +* [#2165](https://github.com/netbox-community/netbox/issues/2165) - Improved natural ordering of Interfaces +* [#2292](https://github.com/netbox-community/netbox/issues/2292) - Removed the deprecated UserAction model +* [#2367](https://github.com/netbox-community/netbox/issues/2367) - Removed deprecated RPCClient functionality +* [#2426](https://github.com/netbox-community/netbox/issues/2426) - Introduced `SESSION_FILE_PATH` configuration setting for authentication without write access to database +* [#2594](https://github.com/netbox-community/netbox/issues/2594) - `upgrade.sh` no longer invokes sudo ## Changes From v2.5-beta2 -* [#2474](https://github.com/digitalocean/netbox/issues/2474) - Add `cabled` and `connection_status` filters for device components -* [#2616](https://github.com/digitalocean/netbox/issues/2616) - Convert Rack `outer_unit` and Cable `length_unit` to integer-based choice fields -* [#2622](https://github.com/digitalocean/netbox/issues/2622) - Enable filtering cables by multiple types/colors -* [#2624](https://github.com/digitalocean/netbox/issues/2624) - Delete associated content type and permissions when removing InterfaceConnection model -* [#2626](https://github.com/digitalocean/netbox/issues/2626) - Remove extraneous permissions generated from proxy models -* [#2632](https://github.com/digitalocean/netbox/issues/2632) - Change representation of null values from `0` to `null` -* [#2639](https://github.com/digitalocean/netbox/issues/2639) - Fix preservation of length/dimensions unit for racks and cables -* [#2648](https://github.com/digitalocean/netbox/issues/2648) - Include the `connection_status` field in nested represenations of connectable device components -* [#2649](https://github.com/digitalocean/netbox/issues/2649) - Add `connected_endpoint_type` to connectable device component API representations +* [#2474](https://github.com/netbox-community/netbox/issues/2474) - Add `cabled` and `connection_status` filters for device components +* [#2616](https://github.com/netbox-community/netbox/issues/2616) - Convert Rack `outer_unit` and Cable `length_unit` to integer-based choice fields +* [#2622](https://github.com/netbox-community/netbox/issues/2622) - Enable filtering cables by multiple types/colors +* [#2624](https://github.com/netbox-community/netbox/issues/2624) - Delete associated content type and permissions when removing InterfaceConnection model +* [#2626](https://github.com/netbox-community/netbox/issues/2626) - Remove extraneous permissions generated from proxy models +* [#2632](https://github.com/netbox-community/netbox/issues/2632) - Change representation of null values from `0` to `null` +* [#2639](https://github.com/netbox-community/netbox/issues/2639) - Fix preservation of length/dimensions unit for racks and cables +* [#2648](https://github.com/netbox-community/netbox/issues/2648) - Include the `connection_status` field in nested represenations of connectable device components +* [#2649](https://github.com/netbox-community/netbox/issues/2649) - Add `connected_endpoint_type` to connectable device component API representations ## API Changes @@ -330,20 +635,20 @@ v2.4.9 (2018-12-07) ## Enhancements -* [#2089](https://github.com/digitalocean/netbox/issues/2089) - Add SONET interface form factors -* [#2495](https://github.com/digitalocean/netbox/issues/2495) - Enable deep-merging of config context data -* [#2597](https://github.com/digitalocean/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor +* [#2089](https://github.com/netbox-community/netbox/issues/2089) - Add SONET interface form factors +* [#2495](https://github.com/netbox-community/netbox/issues/2495) - Enable deep-merging of config context data +* [#2597](https://github.com/netbox-community/netbox/issues/2597) - Add FibreChannel SFP28 (32GFC) interface form factor ## Bug Fixes -* [#2400](https://github.com/digitalocean/netbox/issues/2400) - Correct representation of nested object assignment in API docs -* [#2576](https://github.com/digitalocean/netbox/issues/2576) - Correct type for count_* fields in site API representation -* [#2606](https://github.com/digitalocean/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor -* [#2611](https://github.com/digitalocean/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site -* [#2613](https://github.com/digitalocean/netbox/issues/2613) - Decrease live search minimum characters to three -* [#2615](https://github.com/digitalocean/netbox/issues/2615) - Tweak live search widget to use brief format for API requests -* [#2623](https://github.com/digitalocean/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks -* [#2634](https://github.com/digitalocean/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view +* [#2400](https://github.com/netbox-community/netbox/issues/2400) - Correct representation of nested object assignment in API docs +* [#2576](https://github.com/netbox-community/netbox/issues/2576) - Correct type for count_* fields in site API representation +* [#2606](https://github.com/netbox-community/netbox/issues/2606) - Fixed filtering for interfaces with a virtual form factor +* [#2611](https://github.com/netbox-community/netbox/issues/2611) - Fix error handling when assigning a clustered device to a different site +* [#2613](https://github.com/netbox-community/netbox/issues/2613) - Decrease live search minimum characters to three +* [#2615](https://github.com/netbox-community/netbox/issues/2615) - Tweak live search widget to use brief format for API requests +* [#2623](https://github.com/netbox-community/netbox/issues/2623) - Removed the need to pass the model class to the rqworker process for webhooks +* [#2634](https://github.com/netbox-community/netbox/issues/2634) - Enforce consistent representation of unnamed devices in rack view --- @@ -351,17 +656,17 @@ v2.4.8 (2018-11-20) ## Enhancements -* [#2490](https://github.com/digitalocean/netbox/issues/2490) - Added bulk editing for config contexts -* [#2557](https://github.com/digitalocean/netbox/issues/2557) - Added object view for tags +* [#2490](https://github.com/netbox-community/netbox/issues/2490) - Added bulk editing for config contexts +* [#2557](https://github.com/netbox-community/netbox/issues/2557) - Added object view for tags ## Bug Fixes -* [#2473](https://github.com/digitalocean/netbox/issues/2473) - Fix encoding of long (>127 character) secrets -* [#2558](https://github.com/digitalocean/netbox/issues/2558) - Filter on all tags when multiple are passed -* [#2565](https://github.com/digitalocean/netbox/issues/2565) - Improved rendering of Markdown tables -* [#2575](https://github.com/digitalocean/netbox/issues/2575) - Correct model specified for rack roles table -* [#2588](https://github.com/digitalocean/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls -* [#2589](https://github.com/digitalocean/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment +* [#2473](https://github.com/netbox-community/netbox/issues/2473) - Fix encoding of long (>127 character) secrets +* [#2558](https://github.com/netbox-community/netbox/issues/2558) - Filter on all tags when multiple are passed +* [#2565](https://github.com/netbox-community/netbox/issues/2565) - Improved rendering of Markdown tables +* [#2575](https://github.com/netbox-community/netbox/issues/2575) - Correct model specified for rack roles table +* [#2588](https://github.com/netbox-community/netbox/issues/2588) - Catch all exceptions from failed NAPALM API Calls +* [#2589](https://github.com/netbox-community/netbox/issues/2589) - Virtual machine API serializer should require cluster assignment --- @@ -369,17 +674,17 @@ v2.4.7 (2018-11-06) ## Enhancements -* [#2388](https://github.com/digitalocean/netbox/issues/2388) - Enable filtering of devices/VMs by region -* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID -* [#2512](https://github.com/digitalocean/netbox/issues/2512) - Add device field to inventory item filter form +* [#2388](https://github.com/netbox-community/netbox/issues/2388) - Enable filtering of devices/VMs by region +* [#2427](https://github.com/netbox-community/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID +* [#2512](https://github.com/netbox-community/netbox/issues/2512) - Add device field to inventory item filter form ## Bug Fixes -* [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF -* [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces -* [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled -* [#2528](https://github.com/digitalocean/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API -* [#2549](https://github.com/digitalocean/netbox/issues/2549) - Changed naming of `peer_device` and `peer_interface` on API /dcim/connected-device/ endpoint to use underscores +* [#2502](https://github.com/netbox-community/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF +* [#2514](https://github.com/netbox-community/netbox/issues/2514) - Prevent new connections to already connected interfaces +* [#2515](https://github.com/netbox-community/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled +* [#2528](https://github.com/netbox-community/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API +* [#2549](https://github.com/netbox-community/netbox/issues/2549) - Changed naming of `peer_device` and `peer_interface` on API /dcim/connected-device/ endpoint to use underscores --- @@ -387,17 +692,17 @@ v2.4.6 (2018-10-05) ## Enhancements -* [#2479](https://github.com/digitalocean/netbox/issues/2479) - Add user permissions for creating/modifying API tokens -* [#2487](https://github.com/digitalocean/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` +* [#2479](https://github.com/netbox-community/netbox/issues/2479) - Add user permissions for creating/modifying API tokens +* [#2487](https://github.com/netbox-community/netbox/issues/2487) - Return abbreviated API output when passed `?brief=1` ## Bug Fixes -* [#2393](https://github.com/digitalocean/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2 -* [#2483](https://github.com/digitalocean/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE -* [#2484](https://github.com/digitalocean/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form -* [#2485](https://github.com/digitalocean/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM -* [#2491](https://github.com/digitalocean/netbox/issues/2491) - Fix exception when importing devices with invalid device type -* [#2492](https://github.com/digitalocean/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP +* [#2393](https://github.com/netbox-community/netbox/issues/2393) - Fix Unicode support for CSV import under Python 2 +* [#2483](https://github.com/netbox-community/netbox/issues/2483) - Set max item count of API-populated form fields to MAX_PAGE_SIZE +* [#2484](https://github.com/netbox-community/netbox/issues/2484) - Local config context not available on the Virtual Machine Edit Form +* [#2485](https://github.com/netbox-community/netbox/issues/2485) - Fix cancel button when assigning a service to a device/VM +* [#2491](https://github.com/netbox-community/netbox/issues/2491) - Fix exception when importing devices with invalid device type +* [#2492](https://github.com/netbox-community/netbox/issues/2492) - Sanitize hostname and port values returned through LLDP --- @@ -405,20 +710,20 @@ v2.4.5 (2018-10-02) ## Enhancements -* [#2392](https://github.com/digitalocean/netbox/issues/2392) - Implemented local context data for devices and virtual machines -* [#2402](https://github.com/digitalocean/netbox/issues/2402) - Order and format JSON data in form fields -* [#2432](https://github.com/digitalocean/netbox/issues/2432) - Link remote interface connections to the Interface view -* [#2438](https://github.com/digitalocean/netbox/issues/2438) - API optimizations for tagged objects +* [#2392](https://github.com/netbox-community/netbox/issues/2392) - Implemented local context data for devices and virtual machines +* [#2402](https://github.com/netbox-community/netbox/issues/2402) - Order and format JSON data in form fields +* [#2432](https://github.com/netbox-community/netbox/issues/2432) - Link remote interface connections to the Interface view +* [#2438](https://github.com/netbox-community/netbox/issues/2438) - API optimizations for tagged objects ## Bug Fixes -* [#2406](https://github.com/digitalocean/netbox/issues/2406) - Remove hard-coded limit of 1000 objects from API-populated form fields -* [#2414](https://github.com/digitalocean/netbox/issues/2414) - Tags field missing from device/VM component creation forms -* [#2442](https://github.com/digitalocean/netbox/issues/2442) - Nullify "next" link in API when limit=0 is passed -* [#2443](https://github.com/digitalocean/netbox/issues/2443) - Enforce JSON object format when creating config contexts -* [#2444](https://github.com/digitalocean/netbox/issues/2444) - Improve validation of interface MAC addresses -* [#2455](https://github.com/digitalocean/netbox/issues/2455) - Ignore unique address enforcement for IPs with a shared/virtual role -* [#2470](https://github.com/digitalocean/netbox/issues/2470) - Log the creation of device/VM components as object changes +* [#2406](https://github.com/netbox-community/netbox/issues/2406) - Remove hard-coded limit of 1000 objects from API-populated form fields +* [#2414](https://github.com/netbox-community/netbox/issues/2414) - Tags field missing from device/VM component creation forms +* [#2442](https://github.com/netbox-community/netbox/issues/2442) - Nullify "next" link in API when limit=0 is passed +* [#2443](https://github.com/netbox-community/netbox/issues/2443) - Enforce JSON object format when creating config contexts +* [#2444](https://github.com/netbox-community/netbox/issues/2444) - Improve validation of interface MAC addresses +* [#2455](https://github.com/netbox-community/netbox/issues/2455) - Ignore unique address enforcement for IPs with a shared/virtual role +* [#2470](https://github.com/netbox-community/netbox/issues/2470) - Log the creation of device/VM components as object changes --- @@ -426,21 +731,21 @@ v2.4.4 (2018-08-22) ## Enhancements -* [#2168](https://github.com/digitalocean/netbox/issues/2168) - Added Extreme SummitStack interface form factors -* [#2356](https://github.com/digitalocean/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer -* [#2362](https://github.com/digitalocean/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH -* [#2254](https://github.com/digitalocean/netbox/issues/2254) - Implemented searchability for Rack Groups +* [#2168](https://github.com/netbox-community/netbox/issues/2168) - Added Extreme SummitStack interface form factors +* [#2356](https://github.com/netbox-community/netbox/issues/2356) - Include cluster site as read-only field in VirtualMachine serializer +* [#2362](https://github.com/netbox-community/netbox/issues/2362) - Implemented custom admin site to properly handle BASE_PATH +* [#2254](https://github.com/netbox-community/netbox/issues/2254) - Implemented searchability for Rack Groups ## Bug Fixes -* [#2353](https://github.com/digitalocean/netbox/issues/2353) - Handle `DoesNotExist` exception when deleting a device with connected interfaces -* [#2354](https://github.com/digitalocean/netbox/issues/2354) - Increased maximum MTU for interfaces to 65536 bytes -* [#2355](https://github.com/digitalocean/netbox/issues/2355) - Added item count to inventory tab on device view -* [#2368](https://github.com/digitalocean/netbox/issues/2368) - Record change in device changelog when altering cluster assignment -* [#2369](https://github.com/digitalocean/netbox/issues/2369) - Corrected time zone validation on site API serializer -* [#2370](https://github.com/digitalocean/netbox/issues/2370) - Redirect to parent device after deleting device bays -* [#2374](https://github.com/digitalocean/netbox/issues/2374) - Fix toggling display of IP addresses in virtual machine interfaces list -* [#2378](https://github.com/digitalocean/netbox/issues/2378) - Corrected "edit" link for virtual machine interfaces +* [#2353](https://github.com/netbox-community/netbox/issues/2353) - Handle `DoesNotExist` exception when deleting a device with connected interfaces +* [#2354](https://github.com/netbox-community/netbox/issues/2354) - Increased maximum MTU for interfaces to 65536 bytes +* [#2355](https://github.com/netbox-community/netbox/issues/2355) - Added item count to inventory tab on device view +* [#2368](https://github.com/netbox-community/netbox/issues/2368) - Record change in device changelog when altering cluster assignment +* [#2369](https://github.com/netbox-community/netbox/issues/2369) - Corrected time zone validation on site API serializer +* [#2370](https://github.com/netbox-community/netbox/issues/2370) - Redirect to parent device after deleting device bays +* [#2374](https://github.com/netbox-community/netbox/issues/2374) - Fix toggling display of IP addresses in virtual machine interfaces list +* [#2378](https://github.com/netbox-community/netbox/issues/2378) - Corrected "edit" link for virtual machine interfaces --- @@ -448,17 +753,17 @@ v2.4.3 (2018-08-09) ## Enhancements -* [#2333](https://github.com/digitalocean/netbox/issues/2333) - Added search filters for ConfigContexts +* [#2333](https://github.com/netbox-community/netbox/issues/2333) - Added search filters for ConfigContexts ## Bug Fixes -* [#2334](https://github.com/digitalocean/netbox/issues/2334) - TypeError raised when WritableNestedSerializer receives a non-integer value -* [#2335](https://github.com/digitalocean/netbox/issues/2335) - API requires group field when creating/updating a rack -* [#2336](https://github.com/digitalocean/netbox/issues/2336) - Bulk deleting power outlets and console server ports from a device redirects to home page -* [#2337](https://github.com/digitalocean/netbox/issues/2337) - Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError -* [#2340](https://github.com/digitalocean/netbox/issues/2340) - API requires manufacturer field when creating/updating an inventory item -* [#2342](https://github.com/digitalocean/netbox/issues/2342) - IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM -* [#2344](https://github.com/digitalocean/netbox/issues/2344) - AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site +* [#2334](https://github.com/netbox-community/netbox/issues/2334) - TypeError raised when WritableNestedSerializer receives a non-integer value +* [#2335](https://github.com/netbox-community/netbox/issues/2335) - API requires group field when creating/updating a rack +* [#2336](https://github.com/netbox-community/netbox/issues/2336) - Bulk deleting power outlets and console server ports from a device redirects to home page +* [#2337](https://github.com/netbox-community/netbox/issues/2337) - Attempting to create the next available prefix within a parent assigned to a VRF raises an AssertionError +* [#2340](https://github.com/netbox-community/netbox/issues/2340) - API requires manufacturer field when creating/updating an inventory item +* [#2342](https://github.com/netbox-community/netbox/issues/2342) - IntegrityError raised when attempting to assign an invalid IP address as the primary for a VM +* [#2344](https://github.com/netbox-community/netbox/issues/2344) - AttributeError when assigning VLANs to an interface on a device/VM not assigned to a site --- @@ -466,13 +771,13 @@ v2.4.2 (2018-08-08) ## Bug Fixes -* [#2318](https://github.com/digitalocean/netbox/issues/2318) - ImportError when viewing a report -* [#2319](https://github.com/digitalocean/netbox/issues/2319) - Extend ChoiceField to properly handle true/false choice keys -* [#2320](https://github.com/digitalocean/netbox/issues/2320) - TypeError when dispatching a webhook with a secret key configured -* [#2321](https://github.com/digitalocean/netbox/issues/2321) - Allow explicitly setting a null value on nullable ChoiceFields -* [#2322](https://github.com/digitalocean/netbox/issues/2322) - Webhooks firing on non-enabled event types -* [#2323](https://github.com/digitalocean/netbox/issues/2323) - DoesNotExist raised when deleting devices or virtual machines -* [#2330](https://github.com/digitalocean/netbox/issues/2330) - Incorrect tab link in VRF changelog view +* [#2318](https://github.com/netbox-community/netbox/issues/2318) - ImportError when viewing a report +* [#2319](https://github.com/netbox-community/netbox/issues/2319) - Extend ChoiceField to properly handle true/false choice keys +* [#2320](https://github.com/netbox-community/netbox/issues/2320) - TypeError when dispatching a webhook with a secret key configured +* [#2321](https://github.com/netbox-community/netbox/issues/2321) - Allow explicitly setting a null value on nullable ChoiceFields +* [#2322](https://github.com/netbox-community/netbox/issues/2322) - Webhooks firing on non-enabled event types +* [#2323](https://github.com/netbox-community/netbox/issues/2323) - DoesNotExist raised when deleting devices or virtual machines +* [#2330](https://github.com/netbox-community/netbox/issues/2330) - Incorrect tab link in VRF changelog view --- @@ -480,12 +785,12 @@ v2.4.1 (2018-08-07) ## Bug Fixes -* [#2303](https://github.com/digitalocean/netbox/issues/2303) - Always redirect to parent object when bulk editing/deleting components -* [#2308](https://github.com/digitalocean/netbox/issues/2308) - Custom fields panel absent from object view in UI -* [#2310](https://github.com/digitalocean/netbox/issues/2310) - False validation error on certain nested serializers -* [#2311](https://github.com/digitalocean/netbox/issues/2311) - Redirect to parent after editing interface from device/VM view -* [#2312](https://github.com/digitalocean/netbox/issues/2312) - Running a report yields a ValueError exception -* [#2314](https://github.com/digitalocean/netbox/issues/2314) - Serialized representation of object in change log does not include assigned tags +* [#2303](https://github.com/netbox-community/netbox/issues/2303) - Always redirect to parent object when bulk editing/deleting components +* [#2308](https://github.com/netbox-community/netbox/issues/2308) - Custom fields panel absent from object view in UI +* [#2310](https://github.com/netbox-community/netbox/issues/2310) - False validation error on certain nested serializers +* [#2311](https://github.com/netbox-community/netbox/issues/2311) - Redirect to parent after editing interface from device/VM view +* [#2312](https://github.com/netbox-community/netbox/issues/2312) - Running a report yields a ValueError exception +* [#2314](https://github.com/netbox-community/netbox/issues/2314) - Serialized representation of object in change log does not include assigned tags --- @@ -493,43 +798,43 @@ v2.4.0 (2018-08-06) ## New Features -### Webhooks ([#81](https://github.com/digitalocean/netbox/issues/81)) +### Webhooks ([#81](https://github.com/netbox-community/netbox/issues/81)) Webhooks enable NetBox to send a representation of an object every time one is created, updated, or deleted. Webhooks are sent from NetBox to external services via HTTP, and can be limited by object type. Services which receive a webhook can act on the data provided by NetBox to automate other tasks. Special thanks to [John Anderson](https://github.com/lampwins) for doing the heavy lifting for this feature! -### Tagging ([#132](https://github.com/digitalocean/netbox/issues/132)) +### Tagging ([#132](https://github.com/netbox-community/netbox/issues/132)) Tags are free-form labels which can be assigned to a variety of objects in NetBox. Tags can be used to categorize and filter objects in addition to built-in and custom fields. Objects to which tags apply now include a `tags` field in the API. -### Contextual Configuration Data ([#1349](https://github.com/digitalocean/netbox/issues/1349)) +### Contextual Configuration Data ([#1349](https://github.com/netbox-community/netbox/issues/1349)) Sometimes it is desirable to associate arbitrary data with a group of devices to aid in their configuration. (For example, you might want to associate a set of syslog servers for all devices at a particular site.) Context data enables the association of arbitrary data (expressed in JSON format) to devices and virtual machines grouped by region, site, role, platform, and/or tenancy. Context data is arranged hierarchically, so that data with a higher weight can be entered to override more general lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. -### Change Logging ([#1898](https://github.com/digitalocean/netbox/issues/1898)) +### Change Logging ([#1898](https://github.com/netbox-community/netbox/issues/1898)) When an object is created, updated, or deleted, NetBox now automatically records a serialized representation of that object (similar to how it appears in the REST API) as well the event time and user account associated with the change. ## Enhancements -* [#238](https://github.com/digitalocean/netbox/issues/238) - Allow racks with the same name within a site (but in different groups) -* [#971](https://github.com/digitalocean/netbox/issues/971) - Add a view to show all VLAN IDs available within a group -* [#1673](https://github.com/digitalocean/netbox/issues/1673) - Added object/list views for services -* [#1687](https://github.com/digitalocean/netbox/issues/1687) - Enabled custom fields for services -* [#1739](https://github.com/digitalocean/netbox/issues/1739) - Enabled custom fields for secrets -* [#1794](https://github.com/digitalocean/netbox/issues/1794) - Improved POST/PATCH representation of nested objects -* [#2029](https://github.com/digitalocean/netbox/issues/2029) - Added optional NAPALM arguments to Platform model -* [#2034](https://github.com/digitalocean/netbox/issues/2034) - Include the ID when showing nested interface connections (API change) -* [#2118](https://github.com/digitalocean/netbox/issues/2118) - Added `latitude` and `longitude` fields to Site for GPS coordinates -* [#2131](https://github.com/digitalocean/netbox/issues/2131) - Added `created` and `last_updated` fields to DeviceType -* [#2157](https://github.com/digitalocean/netbox/issues/2157) - Fixed natural ordering of objects when sorted by name -* [#2225](https://github.com/digitalocean/netbox/issues/2225) - Add "view elevations" button for site rack groups +* [#238](https://github.com/netbox-community/netbox/issues/238) - Allow racks with the same name within a site (but in different groups) +* [#971](https://github.com/netbox-community/netbox/issues/971) - Add a view to show all VLAN IDs available within a group +* [#1673](https://github.com/netbox-community/netbox/issues/1673) - Added object/list views for services +* [#1687](https://github.com/netbox-community/netbox/issues/1687) - Enabled custom fields for services +* [#1739](https://github.com/netbox-community/netbox/issues/1739) - Enabled custom fields for secrets +* [#1794](https://github.com/netbox-community/netbox/issues/1794) - Improved POST/PATCH representation of nested objects +* [#2029](https://github.com/netbox-community/netbox/issues/2029) - Added optional NAPALM arguments to Platform model +* [#2034](https://github.com/netbox-community/netbox/issues/2034) - Include the ID when showing nested interface connections (API change) +* [#2118](https://github.com/netbox-community/netbox/issues/2118) - Added `latitude` and `longitude` fields to Site for GPS coordinates +* [#2131](https://github.com/netbox-community/netbox/issues/2131) - Added `created` and `last_updated` fields to DeviceType +* [#2157](https://github.com/netbox-community/netbox/issues/2157) - Fixed natural ordering of objects when sorted by name +* [#2225](https://github.com/netbox-community/netbox/issues/2225) - Add "view elevations" button for site rack groups ## Bug Fixes -* [#2272](https://github.com/digitalocean/netbox/issues/2272) - Allow subdevice_role to be null on DeviceTypeSerializer" -* [#2286](https://github.com/digitalocean/netbox/issues/2286) - Fixed "mark connected" button for PDU outlet connections +* [#2272](https://github.com/netbox-community/netbox/issues/2272) - Allow subdevice_role to be null on DeviceTypeSerializer" +* [#2286](https://github.com/netbox-community/netbox/issues/2286) - Fixed "mark connected" button for PDU outlet connections ## API Changes @@ -544,20 +849,20 @@ When an object is created, updated, or deleted, NetBox now automatically records ### Enhancements -* [#2229](https://github.com/digitalocean/netbox/issues/2229) - Allow mapping of ConfigContexts to tenant groups -* [#2259](https://github.com/digitalocean/netbox/issues/2259) - Add changelog tab to interface view -* [#2264](https://github.com/digitalocean/netbox/issues/2264) - Added "map it" link for site GPS coordinates +* [#2229](https://github.com/netbox-community/netbox/issues/2229) - Allow mapping of ConfigContexts to tenant groups +* [#2259](https://github.com/netbox-community/netbox/issues/2259) - Add changelog tab to interface view +* [#2264](https://github.com/netbox-community/netbox/issues/2264) - Added "map it" link for site GPS coordinates ### Bug Fixes -* [#2137](https://github.com/digitalocean/netbox/issues/2137) - Fixed JSON serialization of dates -* [#2258](https://github.com/digitalocean/netbox/issues/2258) - Include changed object type on home page changelog -* [#2265](https://github.com/digitalocean/netbox/issues/2265) - Include parent regions when filtering applicable ConfigContexts -* [#2288](https://github.com/digitalocean/netbox/issues/2288) - Fix exception when assigning objects to a ConfigContext via the API -* [#2296](https://github.com/digitalocean/netbox/issues/2296) - Fix AttributeError when creating a new object with tags assigned -* [#2300](https://github.com/digitalocean/netbox/issues/2300) - Fix assignment of an interface to an IP address via API PATCH -* [#2301](https://github.com/digitalocean/netbox/issues/2301) - Fix model validation on assignment of ManyToMany fields via API PATCH -* [#2305](https://github.com/digitalocean/netbox/issues/2305) - Make VLAN fields optional when creating a VM interface via the API +* [#2137](https://github.com/netbox-community/netbox/issues/2137) - Fixed JSON serialization of dates +* [#2258](https://github.com/netbox-community/netbox/issues/2258) - Include changed object type on home page changelog +* [#2265](https://github.com/netbox-community/netbox/issues/2265) - Include parent regions when filtering applicable ConfigContexts +* [#2288](https://github.com/netbox-community/netbox/issues/2288) - Fix exception when assigning objects to a ConfigContext via the API +* [#2296](https://github.com/netbox-community/netbox/issues/2296) - Fix AttributeError when creating a new object with tags assigned +* [#2300](https://github.com/netbox-community/netbox/issues/2300) - Fix assignment of an interface to an IP address via API PATCH +* [#2301](https://github.com/netbox-community/netbox/issues/2301) - Fix model validation on assignment of ManyToMany fields via API PATCH +* [#2305](https://github.com/netbox-community/netbox/issues/2305) - Make VLAN fields optional when creating a VM interface via the API --- @@ -565,20 +870,20 @@ v2.3.7 (2018-07-26) ## Enhancements -* [#2166](https://github.com/digitalocean/netbox/issues/2166) - Enable partial matching on device asset_tag during search +* [#2166](https://github.com/netbox-community/netbox/issues/2166) - Enable partial matching on device asset_tag during search ## Bug Fixes -* [#1977](https://github.com/digitalocean/netbox/issues/1977) - Fixed exception when creating a virtual chassis with a non-master device in position 1 -* [#1992](https://github.com/digitalocean/netbox/issues/1992) - Isolate errors when one of multiple NAPALM methods fails -* [#2202](https://github.com/digitalocean/netbox/issues/2202) - Ditched half-baked concept of tenancy inheritance via VRF -* [#2222](https://github.com/digitalocean/netbox/issues/2222) - IP addresses created via the `available-ips` API endpoint should have the same mask as their parent prefix (not /32) -* [#2231](https://github.com/digitalocean/netbox/issues/2231) - Remove `get_absolute_url()` from DeviceRole (can apply to devices or VMs) -* [#2250](https://github.com/digitalocean/netbox/issues/2250) - Include stat counters on report result navigation -* [#2255](https://github.com/digitalocean/netbox/issues/2255) - Corrected display of results in reports list -* [#2256](https://github.com/digitalocean/netbox/issues/2256) - Prevent navigation menu overlap when jumping to test results on report page -* [#2257](https://github.com/digitalocean/netbox/issues/2257) - Corrected casting of RIR utilization stats as floats -* [#2266](https://github.com/digitalocean/netbox/issues/2266) - Permit additional logging of exceptions beyond custom middleware +* [#1977](https://github.com/netbox-community/netbox/issues/1977) - Fixed exception when creating a virtual chassis with a non-master device in position 1 +* [#1992](https://github.com/netbox-community/netbox/issues/1992) - Isolate errors when one of multiple NAPALM methods fails +* [#2202](https://github.com/netbox-community/netbox/issues/2202) - Ditched half-baked concept of tenancy inheritance via VRF +* [#2222](https://github.com/netbox-community/netbox/issues/2222) - IP addresses created via the `available-ips` API endpoint should have the same mask as their parent prefix (not /32) +* [#2231](https://github.com/netbox-community/netbox/issues/2231) - Remove `get_absolute_url()` from DeviceRole (can apply to devices or VMs) +* [#2250](https://github.com/netbox-community/netbox/issues/2250) - Include stat counters on report result navigation +* [#2255](https://github.com/netbox-community/netbox/issues/2255) - Corrected display of results in reports list +* [#2256](https://github.com/netbox-community/netbox/issues/2256) - Prevent navigation menu overlap when jumping to test results on report page +* [#2257](https://github.com/netbox-community/netbox/issues/2257) - Corrected casting of RIR utilization stats as floats +* [#2266](https://github.com/netbox-community/netbox/issues/2266) - Permit additional logging of exceptions beyond custom middleware --- @@ -586,13 +891,13 @@ v2.3.6 (2018-07-16) ## Enhancements -* [#2107](https://github.com/digitalocean/netbox/issues/2107) - Added virtual chassis to global search -* [#2125](https://github.com/digitalocean/netbox/issues/2125) - Show child status in device bay list +* [#2107](https://github.com/netbox-community/netbox/issues/2107) - Added virtual chassis to global search +* [#2125](https://github.com/netbox-community/netbox/issues/2125) - Show child status in device bay list ## Bug Fixes -* [#2214](https://github.com/digitalocean/netbox/issues/2214) - Error when assigning a VLAN to an interface on a VM in a cluster with no assigned site -* [#2239](https://github.com/digitalocean/netbox/issues/2239) - Pin django-filter to version 1.1.0 +* [#2214](https://github.com/netbox-community/netbox/issues/2214) - Error when assigning a VLAN to an interface on a VM in a cluster with no assigned site +* [#2239](https://github.com/netbox-community/netbox/issues/2239) - Pin django-filter to version 1.1.0 --- @@ -600,20 +905,20 @@ v2.3.5 (2018-07-02) ## Enhancements -* [#2159](https://github.com/digitalocean/netbox/issues/2159) - Allow custom choice field to specify a default choice -* [#2177](https://github.com/digitalocean/netbox/issues/2177) - Include device serial number in rack elevation pop-up -* [#2194](https://github.com/digitalocean/netbox/issues/2194) - Added `address` filter to IPAddress model +* [#2159](https://github.com/netbox-community/netbox/issues/2159) - Allow custom choice field to specify a default choice +* [#2177](https://github.com/netbox-community/netbox/issues/2177) - Include device serial number in rack elevation pop-up +* [#2194](https://github.com/netbox-community/netbox/issues/2194) - Added `address` filter to IPAddress model ## Bug Fixes -* [#1826](https://github.com/digitalocean/netbox/issues/1826) - Corrected description of security parameters under API definition -* [#2021](https://github.com/digitalocean/netbox/issues/2021) - Fix recursion error when viewing API docs under Python 3.4 -* [#2064](https://github.com/digitalocean/netbox/issues/2064) - Disable calls to online swagger validator -* [#2173](https://github.com/digitalocean/netbox/issues/2173) - Fixed IndexError when automatically allocating IP addresses from large IPv6 prefixes -* [#2181](https://github.com/digitalocean/netbox/issues/2181) - Raise validation error on invalid `prefix_length` when allocating next-available prefix -* [#2182](https://github.com/digitalocean/netbox/issues/2182) - ValueError can be raised when viewing the interface connections table -* [#2191](https://github.com/digitalocean/netbox/issues/2191) - Added missing static choices to circuits and DCIM API endpoints -* [#2192](https://github.com/digitalocean/netbox/issues/2192) - Prevent a 0U device from being assigned to a rack position +* [#1826](https://github.com/netbox-community/netbox/issues/1826) - Corrected description of security parameters under API definition +* [#2021](https://github.com/netbox-community/netbox/issues/2021) - Fix recursion error when viewing API docs under Python 3.4 +* [#2064](https://github.com/netbox-community/netbox/issues/2064) - Disable calls to online swagger validator +* [#2173](https://github.com/netbox-community/netbox/issues/2173) - Fixed IndexError when automatically allocating IP addresses from large IPv6 prefixes +* [#2181](https://github.com/netbox-community/netbox/issues/2181) - Raise validation error on invalid `prefix_length` when allocating next-available prefix +* [#2182](https://github.com/netbox-community/netbox/issues/2182) - ValueError can be raised when viewing the interface connections table +* [#2191](https://github.com/netbox-community/netbox/issues/2191) - Added missing static choices to circuits and DCIM API endpoints +* [#2192](https://github.com/netbox-community/netbox/issues/2192) - Prevent a 0U device from being assigned to a rack position --- @@ -621,17 +926,17 @@ v2.3.4 (2018-06-07) ## Bug Fixes -* [#2066](https://github.com/digitalocean/netbox/issues/2066) - Catch `AddrFormatError` exception on invalid IP addresses -* [#2075](https://github.com/digitalocean/netbox/issues/2075) - Enable tenant assignment when creating a rack reservation via the API -* [#2083](https://github.com/digitalocean/netbox/issues/2083) - Add missing export button to rack roles list view -* [#2087](https://github.com/digitalocean/netbox/issues/2087) - Don't overwrite existing vc_position of master device when creating a virtual chassis -* [#2093](https://github.com/digitalocean/netbox/issues/2093) - Fix link to circuit termination in device interfaces table -* [#2097](https://github.com/digitalocean/netbox/issues/2097) - Fixed queryset-based bulk deletion of clusters and regions -* [#2098](https://github.com/digitalocean/netbox/issues/2098) - Fixed missing checkboxes for host devices in cluster view -* [#2127](https://github.com/digitalocean/netbox/issues/2127) - Prevent non-conntectable interfaces from being connected -* [#2143](https://github.com/digitalocean/netbox/issues/2143) - Accept null value for empty time zone field -* [#2148](https://github.com/digitalocean/netbox/issues/2148) - Do not force timezone selection when editing sites in bulk -* [#2150](https://github.com/digitalocean/netbox/issues/2150) - Fix display of LLDP neighbors when interface name contains a colon +* [#2066](https://github.com/netbox-community/netbox/issues/2066) - Catch `AddrFormatError` exception on invalid IP addresses +* [#2075](https://github.com/netbox-community/netbox/issues/2075) - Enable tenant assignment when creating a rack reservation via the API +* [#2083](https://github.com/netbox-community/netbox/issues/2083) - Add missing export button to rack roles list view +* [#2087](https://github.com/netbox-community/netbox/issues/2087) - Don't overwrite existing vc_position of master device when creating a virtual chassis +* [#2093](https://github.com/netbox-community/netbox/issues/2093) - Fix link to circuit termination in device interfaces table +* [#2097](https://github.com/netbox-community/netbox/issues/2097) - Fixed queryset-based bulk deletion of clusters and regions +* [#2098](https://github.com/netbox-community/netbox/issues/2098) - Fixed missing checkboxes for host devices in cluster view +* [#2127](https://github.com/netbox-community/netbox/issues/2127) - Prevent non-conntectable interfaces from being connected +* [#2143](https://github.com/netbox-community/netbox/issues/2143) - Accept null value for empty time zone field +* [#2148](https://github.com/netbox-community/netbox/issues/2148) - Do not force timezone selection when editing sites in bulk +* [#2150](https://github.com/netbox-community/netbox/issues/2150) - Fix display of LLDP neighbors when interface name contains a colon --- @@ -639,20 +944,20 @@ v2.3.3 (2018-04-19) ## Enhancements -* [#1990](https://github.com/digitalocean/netbox/issues/1990) - Improved search function when assigning an IP address to an interface +* [#1990](https://github.com/netbox-community/netbox/issues/1990) - Improved search function when assigning an IP address to an interface ## Bug Fixes -* [#1975](https://github.com/digitalocean/netbox/issues/1975) - Correct filtering logic for custom boolean fields -* [#1988](https://github.com/digitalocean/netbox/issues/1988) - Order interfaces naturally when bulk renaming -* [#1993](https://github.com/digitalocean/netbox/issues/1993) - Corrected status choices in site CSV import form -* [#1999](https://github.com/digitalocean/netbox/issues/1999) - Added missing description field to site edit form -* [#2012](https://github.com/digitalocean/netbox/issues/2012) - Fixed deselection of an IP address as the primary IP for its parent device/VM -* [#2014](https://github.com/digitalocean/netbox/issues/2014) - Allow assignment of VLANs to VM interfaces via the API -* [#2019](https://github.com/digitalocean/netbox/issues/2019) - Avoid casting oversized numbers as integers -* [#2022](https://github.com/digitalocean/netbox/issues/2022) - Show 0 for zero-value fields on CSV export -* [#2023](https://github.com/digitalocean/netbox/issues/2023) - Manufacturer should not be a required field when importing platforms -* [#2037](https://github.com/digitalocean/netbox/issues/2037) - Fixed IndexError exception when attempting to create a new rack reservation +* [#1975](https://github.com/netbox-community/netbox/issues/1975) - Correct filtering logic for custom boolean fields +* [#1988](https://github.com/netbox-community/netbox/issues/1988) - Order interfaces naturally when bulk renaming +* [#1993](https://github.com/netbox-community/netbox/issues/1993) - Corrected status choices in site CSV import form +* [#1999](https://github.com/netbox-community/netbox/issues/1999) - Added missing description field to site edit form +* [#2012](https://github.com/netbox-community/netbox/issues/2012) - Fixed deselection of an IP address as the primary IP for its parent device/VM +* [#2014](https://github.com/netbox-community/netbox/issues/2014) - Allow assignment of VLANs to VM interfaces via the API +* [#2019](https://github.com/netbox-community/netbox/issues/2019) - Avoid casting oversized numbers as integers +* [#2022](https://github.com/netbox-community/netbox/issues/2022) - Show 0 for zero-value fields on CSV export +* [#2023](https://github.com/netbox-community/netbox/issues/2023) - Manufacturer should not be a required field when importing platforms +* [#2037](https://github.com/netbox-community/netbox/issues/2037) - Fixed IndexError exception when attempting to create a new rack reservation --- @@ -660,22 +965,22 @@ v2.3.2 (2018-03-22) ## Enhancements -* [#1586](https://github.com/digitalocean/netbox/issues/1586) - Extend bulk interface creation to support alphanumeric characters -* [#1866](https://github.com/digitalocean/netbox/issues/1866) - Introduced AnnotatedMultipleChoiceField for filter forms -* [#1930](https://github.com/digitalocean/netbox/issues/1930) - Switched to drf-yasg for Swagger API documentation -* [#1944](https://github.com/digitalocean/netbox/issues/1944) - Enable assigning VLANs to virtual machine interfaces -* [#1945](https://github.com/digitalocean/netbox/issues/1945) - Implemented a VLAN members view -* [#1949](https://github.com/digitalocean/netbox/issues/1949) - Added a button to view elevations on rack groups list -* [#1952](https://github.com/digitalocean/netbox/issues/1952) - Implemented a more robust mechanism for assigning VLANs to interfaces +* [#1586](https://github.com/netbox-community/netbox/issues/1586) - Extend bulk interface creation to support alphanumeric characters +* [#1866](https://github.com/netbox-community/netbox/issues/1866) - Introduced AnnotatedMultipleChoiceField for filter forms +* [#1930](https://github.com/netbox-community/netbox/issues/1930) - Switched to drf-yasg for Swagger API documentation +* [#1944](https://github.com/netbox-community/netbox/issues/1944) - Enable assigning VLANs to virtual machine interfaces +* [#1945](https://github.com/netbox-community/netbox/issues/1945) - Implemented a VLAN members view +* [#1949](https://github.com/netbox-community/netbox/issues/1949) - Added a button to view elevations on rack groups list +* [#1952](https://github.com/netbox-community/netbox/issues/1952) - Implemented a more robust mechanism for assigning VLANs to interfaces ## Bug Fixes -* [#1948](https://github.com/digitalocean/netbox/issues/1948) - Fix TypeError when attempting to add a member to an existing virtual chassis -* [#1951](https://github.com/digitalocean/netbox/issues/1951) - Fix TypeError exception when importing platforms -* [#1953](https://github.com/digitalocean/netbox/issues/1953) - Ignore duplicate IPs when calculating prefix utilization -* [#1955](https://github.com/digitalocean/netbox/issues/1955) - Require a plaintext value when creating a new secret -* [#1978](https://github.com/digitalocean/netbox/issues/1978) - Include all virtual chassis member interfaces in LLDP neighbors view -* [#1980](https://github.com/digitalocean/netbox/issues/1980) - Fixed bug when trying to nullify a selection custom field under Python 2 +* [#1948](https://github.com/netbox-community/netbox/issues/1948) - Fix TypeError when attempting to add a member to an existing virtual chassis +* [#1951](https://github.com/netbox-community/netbox/issues/1951) - Fix TypeError exception when importing platforms +* [#1953](https://github.com/netbox-community/netbox/issues/1953) - Ignore duplicate IPs when calculating prefix utilization +* [#1955](https://github.com/netbox-community/netbox/issues/1955) - Require a plaintext value when creating a new secret +* [#1978](https://github.com/netbox-community/netbox/issues/1978) - Include all virtual chassis member interfaces in LLDP neighbors view +* [#1980](https://github.com/netbox-community/netbox/issues/1980) - Fixed bug when trying to nullify a selection custom field under Python 2 --- @@ -683,20 +988,20 @@ v2.3.1 (2018-03-01) ## Enhancements -* [#1910](https://github.com/digitalocean/netbox/issues/1910) - Added filters for cluster group and cluster type +* [#1910](https://github.com/netbox-community/netbox/issues/1910) - Added filters for cluster group and cluster type ## Bug Fixes -* [#1915](https://github.com/digitalocean/netbox/issues/1915) - Redirect to device view after deleting a component -* [#1919](https://github.com/digitalocean/netbox/issues/1919) - Prevent exception when attempting to create a virtual machine without selecting devices -* [#1921](https://github.com/digitalocean/netbox/issues/1921) - Ignore ManyToManyFields when validating a new object created via the API -* [#1924](https://github.com/digitalocean/netbox/issues/1924) - Include VID in VLAN lists when editing an interface -* [#1926](https://github.com/digitalocean/netbox/issues/1926) - Prevent reassignment of parent device when bulk editing VC member interfaces -* [#1927](https://github.com/digitalocean/netbox/issues/1927) - Include all VC member interfaces on A side when creating a new interface connection -* [#1928](https://github.com/digitalocean/netbox/issues/1928) - Fixed form validation when modifying VLANs assigned to an interface -* [#1934](https://github.com/digitalocean/netbox/issues/1934) - Fixed exception when rendering export template on an object type with custom fields assigned -* [#1935](https://github.com/digitalocean/netbox/issues/1935) - Correct API validation of VLANs assigned to interfaces -* [#1936](https://github.com/digitalocean/netbox/issues/1936) - Trigger validation error when attempting to create a virtual chassis without specifying member positions +* [#1915](https://github.com/netbox-community/netbox/issues/1915) - Redirect to device view after deleting a component +* [#1919](https://github.com/netbox-community/netbox/issues/1919) - Prevent exception when attempting to create a virtual machine without selecting devices +* [#1921](https://github.com/netbox-community/netbox/issues/1921) - Ignore ManyToManyFields when validating a new object created via the API +* [#1924](https://github.com/netbox-community/netbox/issues/1924) - Include VID in VLAN lists when editing an interface +* [#1926](https://github.com/netbox-community/netbox/issues/1926) - Prevent reassignment of parent device when bulk editing VC member interfaces +* [#1927](https://github.com/netbox-community/netbox/issues/1927) - Include all VC member interfaces on A side when creating a new interface connection +* [#1928](https://github.com/netbox-community/netbox/issues/1928) - Fixed form validation when modifying VLANs assigned to an interface +* [#1934](https://github.com/netbox-community/netbox/issues/1934) - Fixed exception when rendering export template on an object type with custom fields assigned +* [#1935](https://github.com/netbox-community/netbox/issues/1935) - Correct API validation of VLANs assigned to interfaces +* [#1936](https://github.com/netbox-community/netbox/issues/1936) - Trigger validation error when attempting to create a virtual chassis without specifying member positions --- @@ -704,15 +1009,15 @@ v2.3.0 (2018-02-26) ## New Features -### Virtual Chassis ([#99](https://github.com/digitalocean/netbox/issues/99)) +### Virtual Chassis ([#99](https://github.com/netbox-community/netbox/issues/99)) A virtual chassis represents a set of physical devices with a shared control plane; for example, a stack of switches managed as a single device. Viewing the master device of a virtual chassis will show all member interfaces and IP addresses. -### Interface VLAN Assignments ([#150](https://github.com/digitalocean/netbox/issues/150)) +### Interface VLAN Assignments ([#150](https://github.com/netbox-community/netbox/issues/150)) Interfaces can now be assigned an 802.1Q mode (access or trunked) and associated with particular VLANs. Thanks to [John Anderson](https://github.com/lampwins) for his work on this! -### Bulk Object Creation via the API ([#1553](https://github.com/digitalocean/netbox/issues/1553)) +### Bulk Object Creation via the API ([#1553](https://github.com/netbox-community/netbox/issues/1553)) The REST API now supports the creation of multiple objects of the same type using a single POST request. For example, to create multiple devices: @@ -726,7 +1031,7 @@ curl -X POST -H "Authorization: Token " -H "Content-Type: application/jso Bulk creation is all-or-none: If any of the creations fails, the entire operation is rolled back. -### Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/digitalocean/netbox/issues/1694)) +### Automatic Provisioning of Next Available Prefixes ([#1694](https://github.com/netbox-community/netbox/issues/1694)) Similar to IP addresses, NetBox now supports automated provisioning of available prefixes from within a parent prefix. For example, to retrieve the next three available /28s within a parent /24: @@ -740,32 +1045,32 @@ curl -X POST -H "Authorization: Token " -H "Content-Type: application/jso If the parent prefix cannot accommodate all requested prefixes, the operation is cancelled and no new prefixes are created. -### Bulk Renaming of Device/VM Components ([#1781](https://github.com/digitalocean/netbox/issues/1781)) +### Bulk Renaming of Device/VM Components ([#1781](https://github.com/netbox-community/netbox/issues/1781)) Device components (interfaces, console ports, etc.) can now be renamed in bulk via the web interface. This was implemented primarily to support the bulk renumbering of interfaces whose parent is part of a virtual chassis. ## Enhancements -* [#1283](https://github.com/digitalocean/netbox/issues/1283) - Added a `time_zone` field to the site model -* [#1321](https://github.com/digitalocean/netbox/issues/1321) - Added `created` and `last_updated` fields for relevant models to their API serializers -* [#1553](https://github.com/digitalocean/netbox/issues/1553) - Introduced support for bulk object creation via the API -* [#1592](https://github.com/digitalocean/netbox/issues/1592) - Added tenancy assignment for rack reservations -* [#1744](https://github.com/digitalocean/netbox/issues/1744) - Allow associating a platform with a specific manufacturer -* [#1758](https://github.com/digitalocean/netbox/issues/1758) - Added a `status` field to the site model -* [#1821](https://github.com/digitalocean/netbox/issues/1821) - Added a `description` field to the site model -* [#1864](https://github.com/digitalocean/netbox/issues/1864) - Added a `status` field to the circuit model +* [#1283](https://github.com/netbox-community/netbox/issues/1283) - Added a `time_zone` field to the site model +* [#1321](https://github.com/netbox-community/netbox/issues/1321) - Added `created` and `last_updated` fields for relevant models to their API serializers +* [#1553](https://github.com/netbox-community/netbox/issues/1553) - Introduced support for bulk object creation via the API +* [#1592](https://github.com/netbox-community/netbox/issues/1592) - Added tenancy assignment for rack reservations +* [#1744](https://github.com/netbox-community/netbox/issues/1744) - Allow associating a platform with a specific manufacturer +* [#1758](https://github.com/netbox-community/netbox/issues/1758) - Added a `status` field to the site model +* [#1821](https://github.com/netbox-community/netbox/issues/1821) - Added a `description` field to the site model +* [#1864](https://github.com/netbox-community/netbox/issues/1864) - Added a `status` field to the circuit model ## Bug Fixes -* [#1136](https://github.com/digitalocean/netbox/issues/1136) - Enforce model validation during bulk update -* [#1645](https://github.com/digitalocean/netbox/issues/1645) - Simplified interface serialzier for IP addresses and optimized API view queryset -* [#1838](https://github.com/digitalocean/netbox/issues/1838) - Fix KeyError when attempting to create a VirtualChassis with no devices selected -* [#1847](https://github.com/digitalocean/netbox/issues/1847) - RecursionError when a virtual chasis master device has no name -* [#1848](https://github.com/digitalocean/netbox/issues/1848) - Allow null value for interface encapsulation mode -* [#1867](https://github.com/digitalocean/netbox/issues/1867) - Allow filtering on device status with multiple values -* [#1881](https://github.com/digitalocean/netbox/issues/1881)* - Fixed bulk editing of interface 802.1Q settings -* [#1884](https://github.com/digitalocean/netbox/issues/1884)* - Provide additional context to identify devices when creating/editing a virtual chassis -* [#1907](https://github.com/digitalocean/netbox/issues/1907) - Allow removing an IP as the primary for a device when editing the IP directly +* [#1136](https://github.com/netbox-community/netbox/issues/1136) - Enforce model validation during bulk update +* [#1645](https://github.com/netbox-community/netbox/issues/1645) - Simplified interface serialzier for IP addresses and optimized API view queryset +* [#1838](https://github.com/netbox-community/netbox/issues/1838) - Fix KeyError when attempting to create a VirtualChassis with no devices selected +* [#1847](https://github.com/netbox-community/netbox/issues/1847) - RecursionError when a virtual chasis master device has no name +* [#1848](https://github.com/netbox-community/netbox/issues/1848) - Allow null value for interface encapsulation mode +* [#1867](https://github.com/netbox-community/netbox/issues/1867) - Allow filtering on device status with multiple values +* [#1881](https://github.com/netbox-community/netbox/issues/1881)* - Fixed bulk editing of interface 802.1Q settings +* [#1884](https://github.com/netbox-community/netbox/issues/1884)* - Provide additional context to identify devices when creating/editing a virtual chassis +* [#1907](https://github.com/netbox-community/netbox/issues/1907) - Allow removing an IP as the primary for a device when editing the IP directly \* New since v2.3-beta2 @@ -791,19 +1096,19 @@ v2.2.10 (2018-02-21) ## Enhancements -* [#78](https://github.com/digitalocean/netbox/issues/78) - Extended topology maps to support console and power connections -* [#1693](https://github.com/digitalocean/netbox/issues/1693) - Allow specifying loose or exact matching for custom field filters -* [#1714](https://github.com/digitalocean/netbox/issues/1714) - Standardized CSV export functionality for all object lists -* [#1876](https://github.com/digitalocean/netbox/issues/1876) - Added explanatory title text to disabled NAPALM buttons on device view -* [#1885](https://github.com/digitalocean/netbox/issues/1885) - Added a device filter field for primary IP +* [#78](https://github.com/netbox-community/netbox/issues/78) - Extended topology maps to support console and power connections +* [#1693](https://github.com/netbox-community/netbox/issues/1693) - Allow specifying loose or exact matching for custom field filters +* [#1714](https://github.com/netbox-community/netbox/issues/1714) - Standardized CSV export functionality for all object lists +* [#1876](https://github.com/netbox-community/netbox/issues/1876) - Added explanatory title text to disabled NAPALM buttons on device view +* [#1885](https://github.com/netbox-community/netbox/issues/1885) - Added a device filter field for primary IP ## Bug Fixes -* [#1858](https://github.com/digitalocean/netbox/issues/1858) - Include device/VM count for cluster list in global search results -* [#1859](https://github.com/digitalocean/netbox/issues/1859) - Implemented support for line breaks within CSV fields -* [#1860](https://github.com/digitalocean/netbox/issues/1860) - Do not populate initial values for custom fields when editing objects in bulk -* [#1869](https://github.com/digitalocean/netbox/issues/1869) - Corrected ordering of VRFs with duplicate names -* [#1886](https://github.com/digitalocean/netbox/issues/1886) - Allow setting the primary IPv4/v6 address for a virtual machine via the web UI +* [#1858](https://github.com/netbox-community/netbox/issues/1858) - Include device/VM count for cluster list in global search results +* [#1859](https://github.com/netbox-community/netbox/issues/1859) - Implemented support for line breaks within CSV fields +* [#1860](https://github.com/netbox-community/netbox/issues/1860) - Do not populate initial values for custom fields when editing objects in bulk +* [#1869](https://github.com/netbox-community/netbox/issues/1869) - Corrected ordering of VRFs with duplicate names +* [#1886](https://github.com/netbox-community/netbox/issues/1886) - Allow setting the primary IPv4/v6 address for a virtual machine via the web UI --- @@ -811,22 +1116,22 @@ v2.2.9 (2018-01-31) ## Enhancements -* [#144](https://github.com/digitalocean/netbox/issues/144) - Implemented bulk import/edit/delete views for InventoryItems -* [#1073](https://github.com/digitalocean/netbox/issues/1073) - Include prefixes/IPs from all VRFs when viewing the children of a container prefix in the global table -* [#1366](https://github.com/digitalocean/netbox/issues/1366) - Enable searching for regions by name/slug -* [#1406](https://github.com/digitalocean/netbox/issues/1406) - Display tenant description as title text in object tables -* [#1824](https://github.com/digitalocean/netbox/issues/1824) - Add virtual machine count to platforms list -* [#1835](https://github.com/digitalocean/netbox/issues/1835) - Consistent positioning of previous/next rack buttons +* [#144](https://github.com/netbox-community/netbox/issues/144) - Implemented bulk import/edit/delete views for InventoryItems +* [#1073](https://github.com/netbox-community/netbox/issues/1073) - Include prefixes/IPs from all VRFs when viewing the children of a container prefix in the global table +* [#1366](https://github.com/netbox-community/netbox/issues/1366) - Enable searching for regions by name/slug +* [#1406](https://github.com/netbox-community/netbox/issues/1406) - Display tenant description as title text in object tables +* [#1824](https://github.com/netbox-community/netbox/issues/1824) - Add virtual machine count to platforms list +* [#1835](https://github.com/netbox-community/netbox/issues/1835) - Consistent positioning of previous/next rack buttons ## Bug Fixes -* [#1621](https://github.com/digitalocean/netbox/issues/1621) - Tweaked LLDP interface name evaluation logic -* [#1765](https://github.com/digitalocean/netbox/issues/1765) - Improved rendering of null options for model choice fields in filter forms -* [#1807](https://github.com/digitalocean/netbox/issues/1807) - Populate VRF from parent when creating a new prefix -* [#1809](https://github.com/digitalocean/netbox/issues/1809) - Populate tenant assignment from parent when creating a new prefix -* [#1818](https://github.com/digitalocean/netbox/issues/1818) - InventoryItem API serializer no longer requires specifying a null value for items with no parent -* [#1845](https://github.com/digitalocean/netbox/issues/1845) - Correct display of VMs in list with no role assigned -* [#1850](https://github.com/digitalocean/netbox/issues/1850) - Fix TypeError when attempting IP address import if only unnamed devices exist +* [#1621](https://github.com/netbox-community/netbox/issues/1621) - Tweaked LLDP interface name evaluation logic +* [#1765](https://github.com/netbox-community/netbox/issues/1765) - Improved rendering of null options for model choice fields in filter forms +* [#1807](https://github.com/netbox-community/netbox/issues/1807) - Populate VRF from parent when creating a new prefix +* [#1809](https://github.com/netbox-community/netbox/issues/1809) - Populate tenant assignment from parent when creating a new prefix +* [#1818](https://github.com/netbox-community/netbox/issues/1818) - InventoryItem API serializer no longer requires specifying a null value for items with no parent +* [#1845](https://github.com/netbox-community/netbox/issues/1845) - Correct display of VMs in list with no role assigned +* [#1850](https://github.com/netbox-community/netbox/issues/1850) - Fix TypeError when attempting IP address import if only unnamed devices exist --- @@ -834,20 +1139,20 @@ v2.2.8 (2017-12-20) ## Enhancements -* [#1771](https://github.com/digitalocean/netbox/issues/1771) - Added name filter for racks -* [#1772](https://github.com/digitalocean/netbox/issues/1772) - Added position filter for devices -* [#1773](https://github.com/digitalocean/netbox/issues/1773) - Moved child prefixes table to its own view -* [#1774](https://github.com/digitalocean/netbox/issues/1774) - Include a button to refine search results for all object types under global search -* [#1784](https://github.com/digitalocean/netbox/issues/1784) - Added `cluster_type` filters for virtual machines +* [#1771](https://github.com/netbox-community/netbox/issues/1771) - Added name filter for racks +* [#1772](https://github.com/netbox-community/netbox/issues/1772) - Added position filter for devices +* [#1773](https://github.com/netbox-community/netbox/issues/1773) - Moved child prefixes table to its own view +* [#1774](https://github.com/netbox-community/netbox/issues/1774) - Include a button to refine search results for all object types under global search +* [#1784](https://github.com/netbox-community/netbox/issues/1784) - Added `cluster_type` filters for virtual machines ## Bug Fixes -* [#1766](https://github.com/digitalocean/netbox/issues/1766) - Fixed display of "select all" button on device power outlets list -* [#1767](https://github.com/digitalocean/netbox/issues/1767) - Use proper template for 404 responses -* [#1778](https://github.com/digitalocean/netbox/issues/1778) - Preserve initial VRF assignment when adding IP addresses in bulk from a prefix -* [#1783](https://github.com/digitalocean/netbox/issues/1783) - Added `vm_role` filter for device roles -* [#1785](https://github.com/digitalocean/netbox/issues/1785) - Omit filter forms from browsable API -* [#1787](https://github.com/digitalocean/netbox/issues/1787) - Added missing site field to virtualization cluster CSV export +* [#1766](https://github.com/netbox-community/netbox/issues/1766) - Fixed display of "select all" button on device power outlets list +* [#1767](https://github.com/netbox-community/netbox/issues/1767) - Use proper template for 404 responses +* [#1778](https://github.com/netbox-community/netbox/issues/1778) - Preserve initial VRF assignment when adding IP addresses in bulk from a prefix +* [#1783](https://github.com/netbox-community/netbox/issues/1783) - Added `vm_role` filter for device roles +* [#1785](https://github.com/netbox-community/netbox/issues/1785) - Omit filter forms from browsable API +* [#1787](https://github.com/netbox-community/netbox/issues/1787) - Added missing site field to virtualization cluster CSV export --- @@ -855,18 +1160,18 @@ v2.2.7 (2017-12-07) ## Enhancements -* [#1722](https://github.com/digitalocean/netbox/issues/1722) - Added virtual machine count to site view -* [#1737](https://github.com/digitalocean/netbox/issues/1737) - Added a `contains` API filter to find all prefixes containing a given IP or prefix +* [#1722](https://github.com/netbox-community/netbox/issues/1722) - Added virtual machine count to site view +* [#1737](https://github.com/netbox-community/netbox/issues/1737) - Added a `contains` API filter to find all prefixes containing a given IP or prefix ## Bug Fixes -* [#1712](https://github.com/digitalocean/netbox/issues/1712) - Corrected tenant inheritance for new IP addresses created from a parent prefix -* [#1721](https://github.com/digitalocean/netbox/issues/1721) - Differentiated child IP count from utilization percentage for prefixes -* [#1740](https://github.com/digitalocean/netbox/issues/1740) - Delete session_key cookie on logout -* [#1741](https://github.com/digitalocean/netbox/issues/1741) - Fixed Unicode support for secret plaintexts -* [#1743](https://github.com/digitalocean/netbox/issues/1743) - Include number of instances for device types in global search -* [#1751](https://github.com/digitalocean/netbox/issues/1751) - Corrected filtering for IPv6 addresses containing letters -* [#1756](https://github.com/digitalocean/netbox/issues/1756) - Improved natural ordering of console server ports and power outlets +* [#1712](https://github.com/netbox-community/netbox/issues/1712) - Corrected tenant inheritance for new IP addresses created from a parent prefix +* [#1721](https://github.com/netbox-community/netbox/issues/1721) - Differentiated child IP count from utilization percentage for prefixes +* [#1740](https://github.com/netbox-community/netbox/issues/1740) - Delete session_key cookie on logout +* [#1741](https://github.com/netbox-community/netbox/issues/1741) - Fixed Unicode support for secret plaintexts +* [#1743](https://github.com/netbox-community/netbox/issues/1743) - Include number of instances for device types in global search +* [#1751](https://github.com/netbox-community/netbox/issues/1751) - Corrected filtering for IPv6 addresses containing letters +* [#1756](https://github.com/netbox-community/netbox/issues/1756) - Improved natural ordering of console server ports and power outlets --- @@ -874,15 +1179,15 @@ v2.2.6 (2017-11-16) ## Enhancements -* [#1669](https://github.com/digitalocean/netbox/issues/1669) - Clicking "add an IP" from the prefix view will default to the first available IP within the prefix +* [#1669](https://github.com/netbox-community/netbox/issues/1669) - Clicking "add an IP" from the prefix view will default to the first available IP within the prefix ## Bug Fixes -* [#1397](https://github.com/digitalocean/netbox/issues/1397) - Display global search in navigation menu unless display is less than 1200px wide -* [#1599](https://github.com/digitalocean/netbox/issues/1599) - Reduce mobile cut-off for navigation menu to 960px -* [#1715](https://github.com/digitalocean/netbox/issues/1715) - Added missing import buttons on object lists -* [#1717](https://github.com/digitalocean/netbox/issues/1717) - Fixed interface validation for virtual machines -* [#1718](https://github.com/digitalocean/netbox/issues/1718) - Set empty label to "Global" or VRF field in IP assignment form +* [#1397](https://github.com/netbox-community/netbox/issues/1397) - Display global search in navigation menu unless display is less than 1200px wide +* [#1599](https://github.com/netbox-community/netbox/issues/1599) - Reduce mobile cut-off for navigation menu to 960px +* [#1715](https://github.com/netbox-community/netbox/issues/1715) - Added missing import buttons on object lists +* [#1717](https://github.com/netbox-community/netbox/issues/1717) - Fixed interface validation for virtual machines +* [#1718](https://github.com/netbox-community/netbox/issues/1718) - Set empty label to "Global" or VRF field in IP assignment form --- @@ -890,23 +1195,23 @@ v2.2.5 (2017-11-14) ## Enhancements -* [#1512](https://github.com/digitalocean/netbox/issues/1512) - Added a view to search for an IP address being assigned to an interface -* [#1679](https://github.com/digitalocean/netbox/issues/1679) - Added IP address roles to device/VM interface lists -* [#1683](https://github.com/digitalocean/netbox/issues/1683) - Replaced default 500 handler with custom middleware to provide preliminary troubleshooting assistance -* [#1684](https://github.com/digitalocean/netbox/issues/1684) - Replaced prefix `parent` filter with `within` and `within_include` +* [#1512](https://github.com/netbox-community/netbox/issues/1512) - Added a view to search for an IP address being assigned to an interface +* [#1679](https://github.com/netbox-community/netbox/issues/1679) - Added IP address roles to device/VM interface lists +* [#1683](https://github.com/netbox-community/netbox/issues/1683) - Replaced default 500 handler with custom middleware to provide preliminary troubleshooting assistance +* [#1684](https://github.com/netbox-community/netbox/issues/1684) - Replaced prefix `parent` filter with `within` and `within_include` ## Bug Fixes -* [#1471](https://github.com/digitalocean/netbox/issues/1471) - Correct bulk selection of IP addresses within a prefix assigned to a VRF -* [#1642](https://github.com/digitalocean/netbox/issues/1642) - Validate device type classification when creating console server ports and power outlets -* [#1650](https://github.com/digitalocean/netbox/issues/1650) - Correct numeric ordering for interfaces with no alphabetic type -* [#1676](https://github.com/digitalocean/netbox/issues/1676) - Correct filtering of child prefixes upon bulk edit/delete from the parent prefix view -* [#1689](https://github.com/digitalocean/netbox/issues/1689) - Disregard IP address mask when filtering for child IPs of a prefix -* [#1696](https://github.com/digitalocean/netbox/issues/1696) - Fix for NAPALM v2.0+ -* [#1699](https://github.com/digitalocean/netbox/issues/1699) - Correct nested representation in the API of primary IPs for virtual machines and add missing primary_ip property -* [#1701](https://github.com/digitalocean/netbox/issues/1701) - Fixed validation in `extras/0008_reports.py` migration for certain versions of PostgreSQL -* [#1703](https://github.com/digitalocean/netbox/issues/1703) - Added API serializer validation for custom integer fields -* [#1705](https://github.com/digitalocean/netbox/issues/1705) - Fixed filtering of devices with a status of offline +* [#1471](https://github.com/netbox-community/netbox/issues/1471) - Correct bulk selection of IP addresses within a prefix assigned to a VRF +* [#1642](https://github.com/netbox-community/netbox/issues/1642) - Validate device type classification when creating console server ports and power outlets +* [#1650](https://github.com/netbox-community/netbox/issues/1650) - Correct numeric ordering for interfaces with no alphabetic type +* [#1676](https://github.com/netbox-community/netbox/issues/1676) - Correct filtering of child prefixes upon bulk edit/delete from the parent prefix view +* [#1689](https://github.com/netbox-community/netbox/issues/1689) - Disregard IP address mask when filtering for child IPs of a prefix +* [#1696](https://github.com/netbox-community/netbox/issues/1696) - Fix for NAPALM v2.0+ +* [#1699](https://github.com/netbox-community/netbox/issues/1699) - Correct nested representation in the API of primary IPs for virtual machines and add missing primary_ip property +* [#1701](https://github.com/netbox-community/netbox/issues/1701) - Fixed validation in `extras/0008_reports.py` migration for certain versions of PostgreSQL +* [#1703](https://github.com/netbox-community/netbox/issues/1703) - Added API serializer validation for custom integer fields +* [#1705](https://github.com/netbox-community/netbox/issues/1705) - Fixed filtering of devices with a status of offline --- @@ -914,7 +1219,7 @@ v2.2.4 (2017-10-31) ## Bug Fixes -* [#1670](https://github.com/digitalocean/netbox/issues/1670) - Fixed server error when calling certain filters (regression from #1649) +* [#1670](https://github.com/netbox-community/netbox/issues/1670) - Fixed server error when calling certain filters (regression from #1649) --- @@ -922,23 +1227,23 @@ v2.2.3 (2017-10-31) ## Enhancements -* [#999](https://github.com/digitalocean/netbox/issues/999) - Display devices on which circuits are terminated in circuits list -* [#1491](https://github.com/digitalocean/netbox/issues/1491) - Added initial data for the virtualization app -* [#1620](https://github.com/digitalocean/netbox/issues/1620) - Loosen IP address search filter to match all IPs that start with the given string -* [#1631](https://github.com/digitalocean/netbox/issues/1631) - Added a `post_run` method to the Report class -* [#1666](https://github.com/digitalocean/netbox/issues/1666) - Allow modifying the owner of a rack reservation +* [#999](https://github.com/netbox-community/netbox/issues/999) - Display devices on which circuits are terminated in circuits list +* [#1491](https://github.com/netbox-community/netbox/issues/1491) - Added initial data for the virtualization app +* [#1620](https://github.com/netbox-community/netbox/issues/1620) - Loosen IP address search filter to match all IPs that start with the given string +* [#1631](https://github.com/netbox-community/netbox/issues/1631) - Added a `post_run` method to the Report class +* [#1666](https://github.com/netbox-community/netbox/issues/1666) - Allow modifying the owner of a rack reservation ## Bug Fixes -* [#1513](https://github.com/digitalocean/netbox/issues/1513) - Correct filtering of custom field choices -* [#1603](https://github.com/digitalocean/netbox/issues/1603) - Hide selection checkboxes for tables with no available actions -* [#1618](https://github.com/digitalocean/netbox/issues/1618) - Allow bulk deletion of all virtual machines -* [#1619](https://github.com/digitalocean/netbox/issues/1619) - Correct text-based filtering of IP network and address fields -* [#1624](https://github.com/digitalocean/netbox/issues/1624) - Add VM count to device roles table -* [#1634](https://github.com/digitalocean/netbox/issues/1634) - Cluster should not be a required field when importing child devices -* [#1649](https://github.com/digitalocean/netbox/issues/1649) - Correct filtering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ -* [#1653](https://github.com/digitalocean/netbox/issues/1653) - Remove outdated description for DeviceType's `is_network_device` flag -* [#1664](https://github.com/digitalocean/netbox/issues/1664) - Added missing `serial` field in default rack CSV export +* [#1513](https://github.com/netbox-community/netbox/issues/1513) - Correct filtering of custom field choices +* [#1603](https://github.com/netbox-community/netbox/issues/1603) - Hide selection checkboxes for tables with no available actions +* [#1618](https://github.com/netbox-community/netbox/issues/1618) - Allow bulk deletion of all virtual machines +* [#1619](https://github.com/netbox-community/netbox/issues/1619) - Correct text-based filtering of IP network and address fields +* [#1624](https://github.com/netbox-community/netbox/issues/1624) - Add VM count to device roles table +* [#1634](https://github.com/netbox-community/netbox/issues/1634) - Cluster should not be a required field when importing child devices +* [#1649](https://github.com/netbox-community/netbox/issues/1649) - Correct filtering on null values (e.g. ?tenant_id=0) for django-filters v1.1.0+ +* [#1653](https://github.com/netbox-community/netbox/issues/1653) - Remove outdated description for DeviceType's `is_network_device` flag +* [#1664](https://github.com/netbox-community/netbox/issues/1664) - Added missing `serial` field in default rack CSV export --- @@ -946,18 +1251,18 @@ v2.2.2 (2017-10-17) ## Enhancements -* [#1580](https://github.com/digitalocean/netbox/issues/1580) - Allow cluster assignment when bulk importing devices -* [#1587](https://github.com/digitalocean/netbox/issues/1587) - Add primary IP column for virtual machines in global search results +* [#1580](https://github.com/netbox-community/netbox/issues/1580) - Allow cluster assignment when bulk importing devices +* [#1587](https://github.com/netbox-community/netbox/issues/1587) - Add primary IP column for virtual machines in global search results ## Bug Fixes -* [#1498](https://github.com/digitalocean/netbox/issues/1498) - Avoid duplicating nodes when generating topology maps -* [#1579](https://github.com/digitalocean/netbox/issues/1579) - Devices already assigned to a cluster cannot be added to a different cluster -* [#1582](https://github.com/digitalocean/netbox/issues/1582) - Add `virtual_machine` attribute to IPAddress -* [#1584](https://github.com/digitalocean/netbox/issues/1584) - Colorized virtual machine role column -* [#1585](https://github.com/digitalocean/netbox/issues/1585) - Fixed slug-based filtering of virtual machines -* [#1605](https://github.com/digitalocean/netbox/issues/1605) - Added clusters and virtual machines to object list for global search -* [#1609](https://github.com/digitalocean/netbox/issues/1609) - Added missing `virtual_machine` field to IP address interface serializer +* [#1498](https://github.com/netbox-community/netbox/issues/1498) - Avoid duplicating nodes when generating topology maps +* [#1579](https://github.com/netbox-community/netbox/issues/1579) - Devices already assigned to a cluster cannot be added to a different cluster +* [#1582](https://github.com/netbox-community/netbox/issues/1582) - Add `virtual_machine` attribute to IPAddress +* [#1584](https://github.com/netbox-community/netbox/issues/1584) - Colorized virtual machine role column +* [#1585](https://github.com/netbox-community/netbox/issues/1585) - Fixed slug-based filtering of virtual machines +* [#1605](https://github.com/netbox-community/netbox/issues/1605) - Added clusters and virtual machines to object list for global search +* [#1609](https://github.com/netbox-community/netbox/issues/1609) - Added missing `virtual_machine` field to IP address interface serializer --- @@ -965,7 +1270,7 @@ v2.2.1 (2017-10-12) ## Bug Fixes -* [#1576](https://github.com/digitalocean/netbox/issues/1576) - Moved PostgreSQL validation logic into the relevant migration (fixed ImproperlyConfigured exception on init) +* [#1576](https://github.com/netbox-community/netbox/issues/1576) - Moved PostgreSQL validation logic into the relevant migration (fixed ImproperlyConfigured exception on init) --- @@ -977,32 +1282,32 @@ v2.2.0 (2017-10-12) ## New Features -### Virtual Machines and Clusters ([#142](https://github.com/digitalocean/netbox/issues/142)) +### Virtual Machines and Clusters ([#142](https://github.com/netbox-community/netbox/issues/142)) Our second-most popular feature request has arrived! NetBox now supports the creation of virtual machines, which can be assigned virtual interfaces and IP addresses. VMs are arranged into clusters, each of which has a type and (optionally) a group. -### Custom Validation Reports ([#1511](https://github.com/digitalocean/netbox/issues/1511)) +### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. ## Enhancements -* [#494](https://github.com/digitalocean/netbox/issues/494) - Include asset tag in device info pop-up on rack elevation -* [#1444](https://github.com/digitalocean/netbox/issues/1444) - Added a `serial` field to the rack model -* [#1479](https://github.com/digitalocean/netbox/issues/1479) - Added an IP address role for CARP -* [#1506](https://github.com/digitalocean/netbox/issues/1506) - Extended rack facility ID field from 30 to 50 characters -* [#1510](https://github.com/digitalocean/netbox/issues/1510) - Added ability to search by name when adding devices to a cluster -* [#1527](https://github.com/digitalocean/netbox/issues/1527) - Replace deprecated pycrypto library with pycryptodome -* [#1551](https://github.com/digitalocean/netbox/issues/1551) - Added API endpoints listing static field choices for each app -* [#1556](https://github.com/digitalocean/netbox/issues/1556) - Added CPAK, CFP2, and CFP4 100GE interface form factors +* [#494](https://github.com/netbox-community/netbox/issues/494) - Include asset tag in device info pop-up on rack elevation +* [#1444](https://github.com/netbox-community/netbox/issues/1444) - Added a `serial` field to the rack model +* [#1479](https://github.com/netbox-community/netbox/issues/1479) - Added an IP address role for CARP +* [#1506](https://github.com/netbox-community/netbox/issues/1506) - Extended rack facility ID field from 30 to 50 characters +* [#1510](https://github.com/netbox-community/netbox/issues/1510) - Added ability to search by name when adding devices to a cluster +* [#1527](https://github.com/netbox-community/netbox/issues/1527) - Replace deprecated pycrypto library with pycryptodome +* [#1551](https://github.com/netbox-community/netbox/issues/1551) - Added API endpoints listing static field choices for each app +* [#1556](https://github.com/netbox-community/netbox/issues/1556) - Added CPAK, CFP2, and CFP4 100GE interface form factors * Added CSV import views for all object types ## Bug Fixes -* [#1550](https://github.com/digitalocean/netbox/issues/1550) - Corrected interface connections link in navigation menu -* [#1554](https://github.com/digitalocean/netbox/issues/1554) - Don't require form_factor when creating an interface assigned to a virtual machine -* [#1557](https://github.com/digitalocean/netbox/issues/1557) - Added filtering for virtual machine interfaces -* [#1567](https://github.com/digitalocean/netbox/issues/1567) - Prompt user for session key when importing secrets +* [#1550](https://github.com/netbox-community/netbox/issues/1550) - Corrected interface connections link in navigation menu +* [#1554](https://github.com/netbox-community/netbox/issues/1554) - Don't require form_factor when creating an interface assigned to a virtual machine +* [#1557](https://github.com/netbox-community/netbox/issues/1557) - Added filtering for virtual machine interfaces +* [#1567](https://github.com/netbox-community/netbox/issues/1567) - Prompt user for session key when importing secrets ## API Changes @@ -1019,9 +1324,9 @@ v2.1.6 (2017-10-11) ## Enhancements -* [#1548](https://github.com/digitalocean/netbox/issues/1548) - Automatically populate tenant assignment when adding an IP address from the prefix view -* [#1561](https://github.com/digitalocean/netbox/issues/1561) - Added primary IP to the devices table in global search -* [#1563](https://github.com/digitalocean/netbox/issues/1563) - Made necessary updates for Django REST Framework v3.7.0 +* [#1548](https://github.com/netbox-community/netbox/issues/1548) - Automatically populate tenant assignment when adding an IP address from the prefix view +* [#1561](https://github.com/netbox-community/netbox/issues/1561) - Added primary IP to the devices table in global search +* [#1563](https://github.com/netbox-community/netbox/issues/1563) - Made necessary updates for Django REST Framework v3.7.0 --- @@ -1029,21 +1334,21 @@ v2.1.5 (2017-09-25) ## Enhancements -* [#1484](https://github.com/digitalocean/netbox/issues/1484) - Added individual "add VLAN" buttons on the VLAN groups list -* [#1485](https://github.com/digitalocean/netbox/issues/1485) - Added `BANNER_LOGIN` configuration setting to display a banner on the login page -* [#1499](https://github.com/digitalocean/netbox/issues/1499) - Added utilization graph to child prefixes table -* [#1523](https://github.com/digitalocean/netbox/issues/1523) - Improved the natural ordering of interfaces (thanks to [@tarkatronic](https://github.com/tarkatronic)) -* [#1536](https://github.com/digitalocean/netbox/issues/1536) - Improved formatting of aggregate prefix statistics +* [#1484](https://github.com/netbox-community/netbox/issues/1484) - Added individual "add VLAN" buttons on the VLAN groups list +* [#1485](https://github.com/netbox-community/netbox/issues/1485) - Added `BANNER_LOGIN` configuration setting to display a banner on the login page +* [#1499](https://github.com/netbox-community/netbox/issues/1499) - Added utilization graph to child prefixes table +* [#1523](https://github.com/netbox-community/netbox/issues/1523) - Improved the natural ordering of interfaces (thanks to [@tarkatronic](https://github.com/tarkatronic)) +* [#1536](https://github.com/netbox-community/netbox/issues/1536) - Improved formatting of aggregate prefix statistics ## Bug Fixes -* [#1469](https://github.com/digitalocean/netbox/issues/1469) - Allow a NAT IP to be assigned as the primary IP for a device -* [#1472](https://github.com/digitalocean/netbox/issues/1472) - Prevented truncation when displaying secret strings containing HTML characters -* [#1486](https://github.com/digitalocean/netbox/issues/1486) - Ignore subinterface IDs when validating LLDP neighbor connections -* [#1489](https://github.com/digitalocean/netbox/issues/1489) - Corrected server error on validation of empty required custom field -* [#1507](https://github.com/digitalocean/netbox/issues/1507) - Fixed error when creating the next available IP from a prefix within a VRF -* [#1520](https://github.com/digitalocean/netbox/issues/1520) - Redirect on GET request to bulk edit/delete views -* [#1522](https://github.com/digitalocean/netbox/issues/1522) - Removed object create/edit forms from the browsable API +* [#1469](https://github.com/netbox-community/netbox/issues/1469) - Allow a NAT IP to be assigned as the primary IP for a device +* [#1472](https://github.com/netbox-community/netbox/issues/1472) - Prevented truncation when displaying secret strings containing HTML characters +* [#1486](https://github.com/netbox-community/netbox/issues/1486) - Ignore subinterface IDs when validating LLDP neighbor connections +* [#1489](https://github.com/netbox-community/netbox/issues/1489) - Corrected server error on validation of empty required custom field +* [#1507](https://github.com/netbox-community/netbox/issues/1507) - Fixed error when creating the next available IP from a prefix within a VRF +* [#1520](https://github.com/netbox-community/netbox/issues/1520) - Redirect on GET request to bulk edit/delete views +* [#1522](https://github.com/netbox-community/netbox/issues/1522) - Removed object create/edit forms from the browsable API --- @@ -1051,20 +1356,20 @@ v2.1.4 (2017-08-30) ## Enhancements -* [#1326](https://github.com/digitalocean/netbox/issues/1326) - Added dropdown widget with common values for circuit speed fields -* [#1341](https://github.com/digitalocean/netbox/issues/1341) - Added a `MEDIA_ROOT` configuration setting to specify where uploaded files are stored on disk -* [#1376](https://github.com/digitalocean/netbox/issues/1376) - Ignore anycast addresses when detecting duplicate IPs -* [#1402](https://github.com/digitalocean/netbox/issues/1402) - Increased max length of name field for device components -* [#1431](https://github.com/digitalocean/netbox/issues/1431) - Added interface form factor for 10GBASE-CX4 -* [#1432](https://github.com/digitalocean/netbox/issues/1432) - Added a `commit_rate` field to the circuits list search form -* [#1460](https://github.com/digitalocean/netbox/issues/1460) - Hostnames with no domain are now acceptable in custom URL fields +* [#1326](https://github.com/netbox-community/netbox/issues/1326) - Added dropdown widget with common values for circuit speed fields +* [#1341](https://github.com/netbox-community/netbox/issues/1341) - Added a `MEDIA_ROOT` configuration setting to specify where uploaded files are stored on disk +* [#1376](https://github.com/netbox-community/netbox/issues/1376) - Ignore anycast addresses when detecting duplicate IPs +* [#1402](https://github.com/netbox-community/netbox/issues/1402) - Increased max length of name field for device components +* [#1431](https://github.com/netbox-community/netbox/issues/1431) - Added interface form factor for 10GBASE-CX4 +* [#1432](https://github.com/netbox-community/netbox/issues/1432) - Added a `commit_rate` field to the circuits list search form +* [#1460](https://github.com/netbox-community/netbox/issues/1460) - Hostnames with no domain are now acceptable in custom URL fields ## Bug Fixes -* [#1429](https://github.com/digitalocean/netbox/issues/1429) - Fixed uptime formatting on device status page -* [#1433](https://github.com/digitalocean/netbox/issues/1433) - Fixed `devicetype_id` filter for DeviceType components -* [#1443](https://github.com/digitalocean/netbox/issues/1443) - Fixed API validation error involving custom field data -* [#1458](https://github.com/digitalocean/netbox/issues/1458) - Corrected permission name on prefix/VLAN roles list +* [#1429](https://github.com/netbox-community/netbox/issues/1429) - Fixed uptime formatting on device status page +* [#1433](https://github.com/netbox-community/netbox/issues/1433) - Fixed `devicetype_id` filter for DeviceType components +* [#1443](https://github.com/netbox-community/netbox/issues/1443) - Fixed API validation error involving custom field data +* [#1458](https://github.com/netbox-community/netbox/issues/1458) - Corrected permission name on prefix/VLAN roles list --- @@ -1072,13 +1377,13 @@ v2.1.3 (2017-08-15) ## Bug Fixes -* [#1330](https://github.com/digitalocean/netbox/issues/1330) - Raise validation error when assigning an unrelated IP as the primary IP for a device -* [#1389](https://github.com/digitalocean/netbox/issues/1389) - Avoid splitting carat/prefix on prefix list -* [#1400](https://github.com/digitalocean/netbox/issues/1400) - Removed redundant display of assigned device interface from IP address list -* [#1414](https://github.com/digitalocean/netbox/issues/1414) - Selecting a site from the rack filters automatically updates the available rack groups -* [#1419](https://github.com/digitalocean/netbox/issues/1419) - Allow editing image attachments without re-uploading an image -* [#1420](https://github.com/digitalocean/netbox/issues/1420) - Exclude virtual interfaces from device LLDP neighbors view -* [#1421](https://github.com/digitalocean/netbox/issues/1421) - Improved model validation logic for API serializers +* [#1330](https://github.com/netbox-community/netbox/issues/1330) - Raise validation error when assigning an unrelated IP as the primary IP for a device +* [#1389](https://github.com/netbox-community/netbox/issues/1389) - Avoid splitting carat/prefix on prefix list +* [#1400](https://github.com/netbox-community/netbox/issues/1400) - Removed redundant display of assigned device interface from IP address list +* [#1414](https://github.com/netbox-community/netbox/issues/1414) - Selecting a site from the rack filters automatically updates the available rack groups +* [#1419](https://github.com/netbox-community/netbox/issues/1419) - Allow editing image attachments without re-uploading an image +* [#1420](https://github.com/netbox-community/netbox/issues/1420) - Exclude virtual interfaces from device LLDP neighbors view +* [#1421](https://github.com/netbox-community/netbox/issues/1421) - Improved model validation logic for API serializers * Fixed page title capitalization in the browsable API --- @@ -1087,13 +1392,13 @@ v2.1.2 (2017-08-04) ## Enhancements -* [#992](https://github.com/digitalocean/netbox/issues/992) - Allow the creation of multiple services per device with the same protocol and port +* [#992](https://github.com/netbox-community/netbox/issues/992) - Allow the creation of multiple services per device with the same protocol and port * Tweaked navigation menu styling ## Bug Fixes -* [#1388](https://github.com/digitalocean/netbox/issues/1388) - Fixed server error when searching globally for IPs/prefixes (rolled back #1379) -* [#1390](https://github.com/digitalocean/netbox/issues/1390) - Fixed IndexError when viewing available IPs within large IPv6 prefixes +* [#1388](https://github.com/netbox-community/netbox/issues/1388) - Fixed server error when searching globally for IPs/prefixes (rolled back #1379) +* [#1390](https://github.com/netbox-community/netbox/issues/1390) - Fixed IndexError when viewing available IPs within large IPv6 prefixes --- @@ -1101,16 +1406,16 @@ v2.1.1 (2017-08-02) ## Enhancements -* [#893](https://github.com/digitalocean/netbox/issues/893) - Allow filtering by null values for NullCharacterFields (e.g. return only unnamed devices) -* [#1368](https://github.com/digitalocean/netbox/issues/1368) - Render reservations in rack elevations view -* [#1374](https://github.com/digitalocean/netbox/issues/1374) - Added NAPALM_ARGS and NAPALM_TIMEOUT configiuration parameters -* [#1375](https://github.com/digitalocean/netbox/issues/1375) - Renamed `NETBOX_USERNAME` and `NETBOX_PASSWORD` configuration parameters to `NAPALM_USERNAME` and `NAPALM_PASSWORD` -* [#1379](https://github.com/digitalocean/netbox/issues/1379) - Allow searching devices by interface MAC address in global search +* [#893](https://github.com/netbox-community/netbox/issues/893) - Allow filtering by null values for NullCharacterFields (e.g. return only unnamed devices) +* [#1368](https://github.com/netbox-community/netbox/issues/1368) - Render reservations in rack elevations view +* [#1374](https://github.com/netbox-community/netbox/issues/1374) - Added NAPALM_ARGS and NAPALM_TIMEOUT configiuration parameters +* [#1375](https://github.com/netbox-community/netbox/issues/1375) - Renamed `NETBOX_USERNAME` and `NETBOX_PASSWORD` configuration parameters to `NAPALM_USERNAME` and `NAPALM_PASSWORD` +* [#1379](https://github.com/netbox-community/netbox/issues/1379) - Allow searching devices by interface MAC address in global search ## Bug Fixes -* [#461](https://github.com/digitalocean/netbox/issues/461) - Display a validation error when attempting to assigning a new child device to a rack face/position -* [#1385](https://github.com/digitalocean/netbox/issues/1385) - Connected device API endpoint no longer requires authentication if `LOGIN_REQUIRED` is False +* [#461](https://github.com/netbox-community/netbox/issues/461) - Display a validation error when attempting to assigning a new child device to a rack face/position +* [#1385](https://github.com/netbox-community/netbox/issues/1385) - Connected device API endpoint no longer requires authentication if `LOGIN_REQUIRED` is False --- @@ -1118,7 +1423,7 @@ v2.1.0 (2017-07-25) ## New Features -### IP Address Roles ([#819](https://github.com/digitalocean/netbox/issues/819)) +### IP Address Roles ([#819](https://github.com/netbox-community/netbox/issues/819)) The IP address model now supports the assignment of a functional role to help identify special-purpose IPs. These include: @@ -1130,32 +1435,32 @@ The IP address model now supports the assignment of a functional role to help id * HSRP * GLBP -### Automatic Provisioning of Next Available IP ([#1246](https://github.com/digitalocean/netbox/issues/1246)) +### Automatic Provisioning of Next Available IP ([#1246](https://github.com/netbox-community/netbox/issues/1246)) A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A GET request to this endpoint will return a list of available IP addresses within the prefix (up to the pagination limit). A POST request will automatically create and return the next available IP address. -### NAPALM Integration ([#1348](https://github.com/digitalocean/netbox/issues/1348)) +### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. ## Enhancements -* [#838](https://github.com/digitalocean/netbox/issues/838) - Display details of all objects being edited/deleted in bulk -* [#1041](https://github.com/digitalocean/netbox/issues/1041) - Added enabled and MTU fields to the interface model -* [#1121](https://github.com/digitalocean/netbox/issues/1121) - Added asset_tag and description fields to the InventoryItem model -* [#1141](https://github.com/digitalocean/netbox/issues/1141) - Include RD when listing VRFs in a form selection field -* [#1203](https://github.com/digitalocean/netbox/issues/1203) - Implemented query filters for all models -* [#1218](https://github.com/digitalocean/netbox/issues/1218) - Added IEEE 802.11 wireless interface types -* [#1269](https://github.com/digitalocean/netbox/issues/1269) - Added circuit termination to interface serializer -* [#1320](https://github.com/digitalocean/netbox/issues/1320) - Removed checkbox from confirmation dialog +* [#838](https://github.com/netbox-community/netbox/issues/838) - Display details of all objects being edited/deleted in bulk +* [#1041](https://github.com/netbox-community/netbox/issues/1041) - Added enabled and MTU fields to the interface model +* [#1121](https://github.com/netbox-community/netbox/issues/1121) - Added asset_tag and description fields to the InventoryItem model +* [#1141](https://github.com/netbox-community/netbox/issues/1141) - Include RD when listing VRFs in a form selection field +* [#1203](https://github.com/netbox-community/netbox/issues/1203) - Implemented query filters for all models +* [#1218](https://github.com/netbox-community/netbox/issues/1218) - Added IEEE 802.11 wireless interface types +* [#1269](https://github.com/netbox-community/netbox/issues/1269) - Added circuit termination to interface serializer +* [#1320](https://github.com/netbox-community/netbox/issues/1320) - Removed checkbox from confirmation dialog ## Bug Fixes -* [#1079](https://github.com/digitalocean/netbox/issues/1079) - Order interfaces naturally via API -* [#1285](https://github.com/digitalocean/netbox/issues/1285) - Enforce model validation when creating/editing objects via the API -* [#1358](https://github.com/digitalocean/netbox/issues/1358) - Correct VRF example values in IP/prefix import forms -* [#1362](https://github.com/digitalocean/netbox/issues/1362) - Raise validation error when attempting to create an API key that's too short -* [#1371](https://github.com/digitalocean/netbox/issues/1371) - Extend DeviceSerializer.parent_device to include standard fields +* [#1079](https://github.com/netbox-community/netbox/issues/1079) - Order interfaces naturally via API +* [#1285](https://github.com/netbox-community/netbox/issues/1285) - Enforce model validation when creating/editing objects via the API +* [#1358](https://github.com/netbox-community/netbox/issues/1358) - Correct VRF example values in IP/prefix import forms +* [#1362](https://github.com/netbox-community/netbox/issues/1362) - Raise validation error when attempting to create an API key that's too short +* [#1371](https://github.com/netbox-community/netbox/issues/1371) - Extend DeviceSerializer.parent_device to include standard fields ## API changes @@ -1174,11 +1479,11 @@ v2.0.10 (2017-07-14) ## Bug Fixes -* [#1312](https://github.com/digitalocean/netbox/issues/1312) - Catch error when attempting to activate a user key with an invalid private key -* [#1333](https://github.com/digitalocean/netbox/issues/1333) - Corrected label on is_console_server field of DeviceType bulk edit form -* [#1338](https://github.com/digitalocean/netbox/issues/1338) - Allow importing prefixes with "container" status -* [#1339](https://github.com/digitalocean/netbox/issues/1339) - Fixed disappearing checkbox column under django-tables2 v1.7+ -* [#1342](https://github.com/digitalocean/netbox/issues/1342) - Allow designation of users and groups when creating/editing a secret role +* [#1312](https://github.com/netbox-community/netbox/issues/1312) - Catch error when attempting to activate a user key with an invalid private key +* [#1333](https://github.com/netbox-community/netbox/issues/1333) - Corrected label on is_console_server field of DeviceType bulk edit form +* [#1338](https://github.com/netbox-community/netbox/issues/1338) - Allow importing prefixes with "container" status +* [#1339](https://github.com/netbox-community/netbox/issues/1339) - Fixed disappearing checkbox column under django-tables2 v1.7+ +* [#1342](https://github.com/netbox-community/netbox/issues/1342) - Allow designation of users and groups when creating/editing a secret role --- @@ -1186,8 +1491,8 @@ v2.0.9 (2017-07-10) ## Bug Fixes -* [#1319](https://github.com/digitalocean/netbox/issues/1319) - Fixed server error when attempting to create console/power connections -* [#1325](https://github.com/digitalocean/netbox/issues/1325) - Retain interface attachment when editing a circuit termination +* [#1319](https://github.com/netbox-community/netbox/issues/1319) - Fixed server error when attempting to create console/power connections +* [#1325](https://github.com/netbox-community/netbox/issues/1325) - Retain interface attachment when editing a circuit termination --- @@ -1195,19 +1500,19 @@ v2.0.8 (2017-07-05) ## Enhancements -* [#1298](https://github.com/digitalocean/netbox/issues/1298) - Calculate prefix utilization based on its status (container or non-container) -* [#1303](https://github.com/digitalocean/netbox/issues/1303) - Highlight installed interface connections in green on device view -* [#1315](https://github.com/digitalocean/netbox/issues/1315) - Enforce lowercase file extensions for image attachments +* [#1298](https://github.com/netbox-community/netbox/issues/1298) - Calculate prefix utilization based on its status (container or non-container) +* [#1303](https://github.com/netbox-community/netbox/issues/1303) - Highlight installed interface connections in green on device view +* [#1315](https://github.com/netbox-community/netbox/issues/1315) - Enforce lowercase file extensions for image attachments ## Bug Fixes -* [#1279](https://github.com/digitalocean/netbox/issues/1279) - Fix primary_ip assignment during IP address import -* [#1281](https://github.com/digitalocean/netbox/issues/1281) - Show LLDP neighbors tab on device view only if necessary conditions are met -* [#1282](https://github.com/digitalocean/netbox/issues/1282) - Fixed tooltips on "mark connected/planned" toggle buttons for device connections -* [#1288](https://github.com/digitalocean/netbox/issues/1288) - Corrected permission name for deleting image attachments -* [#1289](https://github.com/digitalocean/netbox/issues/1289) - Retain inside NAT assignment when editing an IP address -* [#1297](https://github.com/digitalocean/netbox/issues/1297) - Allow passing custom field choice selection PKs to API as string-quoted integers -* [#1299](https://github.com/digitalocean/netbox/issues/1299) - Corrected permission name for adding services to devices +* [#1279](https://github.com/netbox-community/netbox/issues/1279) - Fix primary_ip assignment during IP address import +* [#1281](https://github.com/netbox-community/netbox/issues/1281) - Show LLDP neighbors tab on device view only if necessary conditions are met +* [#1282](https://github.com/netbox-community/netbox/issues/1282) - Fixed tooltips on "mark connected/planned" toggle buttons for device connections +* [#1288](https://github.com/netbox-community/netbox/issues/1288) - Corrected permission name for deleting image attachments +* [#1289](https://github.com/netbox-community/netbox/issues/1289) - Retain inside NAT assignment when editing an IP address +* [#1297](https://github.com/netbox-community/netbox/issues/1297) - Allow passing custom field choice selection PKs to API as string-quoted integers +* [#1299](https://github.com/netbox-community/netbox/issues/1299) - Corrected permission name for adding services to devices --- @@ -1215,18 +1520,18 @@ v2.0.7 (2017-06-15) ## Enhancements -* [#626](https://github.com/digitalocean/netbox/issues/626) - Added bulk disconnect function for console/power/interface connections on device view +* [#626](https://github.com/netbox-community/netbox/issues/626) - Added bulk disconnect function for console/power/interface connections on device view ## Bug Fixes -* [#1238](https://github.com/digitalocean/netbox/issues/1238) - Fix error when editing an IP with a NAT assignment which has no assigned device -* [#1263](https://github.com/digitalocean/netbox/issues/1263) - Differentiate add and edit permissions for objects -* [#1265](https://github.com/digitalocean/netbox/issues/1265) - Fix console/power/interface connection validation when selecting a device via live search -* [#1266](https://github.com/digitalocean/netbox/issues/1266) - Prevent terminating a circuit to an already-connected interface -* [#1268](https://github.com/digitalocean/netbox/issues/1268) - Fix CSV import error under Python 3 -* [#1273](https://github.com/digitalocean/netbox/issues/1273) - Corrected status choices in IP address import form -* [#1274](https://github.com/digitalocean/netbox/issues/1274) - Exclude unterminated circuits from topology maps -* [#1275](https://github.com/digitalocean/netbox/issues/1275) - Raise validation error on prefix import when multiple VLANs are found +* [#1238](https://github.com/netbox-community/netbox/issues/1238) - Fix error when editing an IP with a NAT assignment which has no assigned device +* [#1263](https://github.com/netbox-community/netbox/issues/1263) - Differentiate add and edit permissions for objects +* [#1265](https://github.com/netbox-community/netbox/issues/1265) - Fix console/power/interface connection validation when selecting a device via live search +* [#1266](https://github.com/netbox-community/netbox/issues/1266) - Prevent terminating a circuit to an already-connected interface +* [#1268](https://github.com/netbox-community/netbox/issues/1268) - Fix CSV import error under Python 3 +* [#1273](https://github.com/netbox-community/netbox/issues/1273) - Corrected status choices in IP address import form +* [#1274](https://github.com/netbox-community/netbox/issues/1274) - Exclude unterminated circuits from topology maps +* [#1275](https://github.com/netbox-community/netbox/issues/1275) - Raise validation error on prefix import when multiple VLANs are found --- @@ -1234,15 +1539,15 @@ v2.0.6 (2017-06-12) ## Enhancements -* [#40](https://github.com/digitalocean/netbox/issues/40) - Added IP utilization graph to prefix list -* [#704](https://github.com/digitalocean/netbox/issues/704) - Allow filtering VLANs by group when editing prefixes -* [#913](https://github.com/digitalocean/netbox/issues/913) - Added headers to object CSV exports -* [#990](https://github.com/digitalocean/netbox/issues/990) - Enable logging configuration in configuration.py -* [#1180](https://github.com/digitalocean/netbox/issues/1180) - Simplified the process of finding related devices when viewing a device +* [#40](https://github.com/netbox-community/netbox/issues/40) - Added IP utilization graph to prefix list +* [#704](https://github.com/netbox-community/netbox/issues/704) - Allow filtering VLANs by group when editing prefixes +* [#913](https://github.com/netbox-community/netbox/issues/913) - Added headers to object CSV exports +* [#990](https://github.com/netbox-community/netbox/issues/990) - Enable logging configuration in configuration.py +* [#1180](https://github.com/netbox-community/netbox/issues/1180) - Simplified the process of finding related devices when viewing a device ## Bug Fixes -* [#1253](https://github.com/digitalocean/netbox/issues/1253) - Improved `upgrade.sh` to allow forcing Python2 +* [#1253](https://github.com/netbox-community/netbox/issues/1253) - Improved `upgrade.sh` to allow forcing Python2 --- @@ -1254,20 +1559,20 @@ The maximum number of objects an API consumer can request has been set to 1000 ( ## Enhancements -* [#655](https://github.com/digitalocean/netbox/issues/655) - Implemented header-based CSV import of objects -* [#1190](https://github.com/digitalocean/netbox/issues/1190) - Allow partial string matching when searching on custom fields -* [#1237](https://github.com/digitalocean/netbox/issues/1237) - Enabled setting limit=0 to disable pagination in API requests; added `MAX_PAGE_SIZE` configuration setting +* [#655](https://github.com/netbox-community/netbox/issues/655) - Implemented header-based CSV import of objects +* [#1190](https://github.com/netbox-community/netbox/issues/1190) - Allow partial string matching when searching on custom fields +* [#1237](https://github.com/netbox-community/netbox/issues/1237) - Enabled setting limit=0 to disable pagination in API requests; added `MAX_PAGE_SIZE` configuration setting ## Bug Fixes -* [#837](https://github.com/digitalocean/netbox/issues/837) - Enforce uniqueness where applicable during bulk import of IP addresses -* [#1226](https://github.com/digitalocean/netbox/issues/1226) - Improved validation for custom field values submitted via the API -* [#1232](https://github.com/digitalocean/netbox/issues/1232) - Improved rack space validation on bulk import of devices (see #655) -* [#1235](https://github.com/digitalocean/netbox/issues/1235) - Fix permission name for adding/editing inventory items -* [#1236](https://github.com/digitalocean/netbox/issues/1236) - Truncate rack names in elevations list; add facility ID -* [#1239](https://github.com/digitalocean/netbox/issues/1239) - Fix server error when creating VLANGroup via API -* [#1243](https://github.com/digitalocean/netbox/issues/1243) - Catch ValueError in IP-based object filters -* [#1244](https://github.com/digitalocean/netbox/issues/1244) - Corrected "device" secrets filter to accept a device name +* [#837](https://github.com/netbox-community/netbox/issues/837) - Enforce uniqueness where applicable during bulk import of IP addresses +* [#1226](https://github.com/netbox-community/netbox/issues/1226) - Improved validation for custom field values submitted via the API +* [#1232](https://github.com/netbox-community/netbox/issues/1232) - Improved rack space validation on bulk import of devices (see #655) +* [#1235](https://github.com/netbox-community/netbox/issues/1235) - Fix permission name for adding/editing inventory items +* [#1236](https://github.com/netbox-community/netbox/issues/1236) - Truncate rack names in elevations list; add facility ID +* [#1239](https://github.com/netbox-community/netbox/issues/1239) - Fix server error when creating VLANGroup via API +* [#1243](https://github.com/netbox-community/netbox/issues/1243) - Catch ValueError in IP-based object filters +* [#1244](https://github.com/netbox-community/netbox/issues/1244) - Corrected "device" secrets filter to accept a device name --- @@ -1275,15 +1580,15 @@ v2.0.4 (2017-05-25) ## Bug Fixes -* [#1206](https://github.com/digitalocean/netbox/issues/1206) - Fix redirection in admin UI after activating secret keys when BASE_PATH is set -* [#1207](https://github.com/digitalocean/netbox/issues/1207) - Include nested LAG serializer when showing interface connections (API) -* [#1210](https://github.com/digitalocean/netbox/issues/1210) - Fix TemplateDoesNotExist errors on browsable API views -* [#1212](https://github.com/digitalocean/netbox/issues/1212) - Allow assigning new VLANs to global VLAN groups -* [#1213](https://github.com/digitalocean/netbox/issues/1213) - Corrected table header ordering links on object list views -* [#1214](https://github.com/digitalocean/netbox/issues/1214) - Add status to list of required fields on child device import form -* [#1219](https://github.com/digitalocean/netbox/issues/1219) - Fix image attachment URLs when BASE_PATH is set -* [#1220](https://github.com/digitalocean/netbox/issues/1220) - Suppressed innocuous warning about untracked migrations under Python 3 -* [#1229](https://github.com/digitalocean/netbox/issues/1229) - Fix validation error on forms where API search is used +* [#1206](https://github.com/netbox-community/netbox/issues/1206) - Fix redirection in admin UI after activating secret keys when BASE_PATH is set +* [#1207](https://github.com/netbox-community/netbox/issues/1207) - Include nested LAG serializer when showing interface connections (API) +* [#1210](https://github.com/netbox-community/netbox/issues/1210) - Fix TemplateDoesNotExist errors on browsable API views +* [#1212](https://github.com/netbox-community/netbox/issues/1212) - Allow assigning new VLANs to global VLAN groups +* [#1213](https://github.com/netbox-community/netbox/issues/1213) - Corrected table header ordering links on object list views +* [#1214](https://github.com/netbox-community/netbox/issues/1214) - Add status to list of required fields on child device import form +* [#1219](https://github.com/netbox-community/netbox/issues/1219) - Fix image attachment URLs when BASE_PATH is set +* [#1220](https://github.com/netbox-community/netbox/issues/1220) - Suppressed innocuous warning about untracked migrations under Python 3 +* [#1229](https://github.com/netbox-community/netbox/issues/1229) - Fix validation error on forms where API search is used --- @@ -1291,21 +1596,21 @@ v2.0.3 (2017-05-18) ## Enhancements -* [#1196](https://github.com/digitalocean/netbox/issues/1196) - Added a lag_id filter to the API interfaces view -* [#1198](https://github.com/digitalocean/netbox/issues/1198) - Allow filtering unracked devices on device list +* [#1196](https://github.com/netbox-community/netbox/issues/1196) - Added a lag_id filter to the API interfaces view +* [#1198](https://github.com/netbox-community/netbox/issues/1198) - Allow filtering unracked devices on device list ## Bug Fixes -* [#1157](https://github.com/digitalocean/netbox/issues/1157) - Hide nav menu search bar on small displays -* [#1186](https://github.com/digitalocean/netbox/issues/1186) - Corrected VLAN edit form so that site assignment is not required -* [#1187](https://github.com/digitalocean/netbox/issues/1187) - Fixed table pagination by introducing a custom table template -* [#1188](https://github.com/digitalocean/netbox/issues/1188) - Serialize interface LAG as nested objected (API) -* [#1189](https://github.com/digitalocean/netbox/issues/1189) - Enforce consistent ordering of objects returned by a global search -* [#1191](https://github.com/digitalocean/netbox/issues/1191) - Bulk selection of IPs under a prefix incorrect when "select all" is used -* [#1195](https://github.com/digitalocean/netbox/issues/1195) - Unable to create an interface connection when searching for peer device -* [#1197](https://github.com/digitalocean/netbox/issues/1197) - Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs -* [#1199](https://github.com/digitalocean/netbox/issues/1199) - Bulk import of secrets does not prompt user to generate a session key -* [#1200](https://github.com/digitalocean/netbox/issues/1200) - Form validation error when connecting power ports to power outlets +* [#1157](https://github.com/netbox-community/netbox/issues/1157) - Hide nav menu search bar on small displays +* [#1186](https://github.com/netbox-community/netbox/issues/1186) - Corrected VLAN edit form so that site assignment is not required +* [#1187](https://github.com/netbox-community/netbox/issues/1187) - Fixed table pagination by introducing a custom table template +* [#1188](https://github.com/netbox-community/netbox/issues/1188) - Serialize interface LAG as nested objected (API) +* [#1189](https://github.com/netbox-community/netbox/issues/1189) - Enforce consistent ordering of objects returned by a global search +* [#1191](https://github.com/netbox-community/netbox/issues/1191) - Bulk selection of IPs under a prefix incorrect when "select all" is used +* [#1195](https://github.com/netbox-community/netbox/issues/1195) - Unable to create an interface connection when searching for peer device +* [#1197](https://github.com/netbox-community/netbox/issues/1197) - Fixed status assignment during bulk import of devices, prefixes, IPs, and VLANs +* [#1199](https://github.com/netbox-community/netbox/issues/1199) - Bulk import of secrets does not prompt user to generate a session key +* [#1200](https://github.com/netbox-community/netbox/issues/1200) - Form validation error when connecting power ports to power outlets --- @@ -1313,24 +1618,24 @@ v2.0.2 (2017-05-15) ## Enhancements -* [#1122](https://github.com/digitalocean/netbox/issues/1122) - Include NAT inside IPs in IP address list -* [#1137](https://github.com/digitalocean/netbox/issues/1137) - Allow filtering devices list by rack -* [#1170](https://github.com/digitalocean/netbox/issues/1170) - Include A and Z sites for circuits in global search results -* [#1172](https://github.com/digitalocean/netbox/issues/1172) - Linkify racks in side-by-side elevations view -* [#1177](https://github.com/digitalocean/netbox/issues/1177) - Render planned connections as dashed lines on topology maps -* [#1179](https://github.com/digitalocean/netbox/issues/1179) - Adjust topology map text color based on node background +* [#1122](https://github.com/netbox-community/netbox/issues/1122) - Include NAT inside IPs in IP address list +* [#1137](https://github.com/netbox-community/netbox/issues/1137) - Allow filtering devices list by rack +* [#1170](https://github.com/netbox-community/netbox/issues/1170) - Include A and Z sites for circuits in global search results +* [#1172](https://github.com/netbox-community/netbox/issues/1172) - Linkify racks in side-by-side elevations view +* [#1177](https://github.com/netbox-community/netbox/issues/1177) - Render planned connections as dashed lines on topology maps +* [#1179](https://github.com/netbox-community/netbox/issues/1179) - Adjust topology map text color based on node background * On all object edit forms, allow filtering the tenant list by tenant group ## Bug Fixes -* [#1158](https://github.com/digitalocean/netbox/issues/1158) - Exception thrown when creating a device component with an invalid name -* [#1159](https://github.com/digitalocean/netbox/issues/1159) - Only superusers can see "edit IP" buttons on the device interfaces list -* [#1160](https://github.com/digitalocean/netbox/issues/1160) - Linkify secrets and tenants in global search results -* [#1161](https://github.com/digitalocean/netbox/issues/1161) - Fix "add another" behavior when creating an API token -* [#1166](https://github.com/digitalocean/netbox/issues/1166) - Fixed bulk IP address creation when assigning tenants -* [#1168](https://github.com/digitalocean/netbox/issues/1168) - Total count of objects missing from list view paginator -* [#1171](https://github.com/digitalocean/netbox/issues/1171) - Allow removing site assignment when bulk editing VLANs -* [#1173](https://github.com/digitalocean/netbox/issues/1173) - Tweak interface manager to fall back to naive ordering +* [#1158](https://github.com/netbox-community/netbox/issues/1158) - Exception thrown when creating a device component with an invalid name +* [#1159](https://github.com/netbox-community/netbox/issues/1159) - Only superusers can see "edit IP" buttons on the device interfaces list +* [#1160](https://github.com/netbox-community/netbox/issues/1160) - Linkify secrets and tenants in global search results +* [#1161](https://github.com/netbox-community/netbox/issues/1161) - Fix "add another" behavior when creating an API token +* [#1166](https://github.com/netbox-community/netbox/issues/1166) - Fixed bulk IP address creation when assigning tenants +* [#1168](https://github.com/netbox-community/netbox/issues/1168) - Total count of objects missing from list view paginator +* [#1171](https://github.com/netbox-community/netbox/issues/1171) - Allow removing site assignment when bulk editing VLANs +* [#1173](https://github.com/netbox-community/netbox/issues/1173) - Tweak interface manager to fall back to naive ordering --- @@ -1338,11 +1643,11 @@ v2.0.1 (2017-05-10) ## Bug Fixes -* [#1149](https://github.com/digitalocean/netbox/issues/1149) - Port list does not populate when creating a console or power connection -* [#1150](https://github.com/digitalocean/netbox/issues/1150) - Error when uploading image attachments with Unicode names under Python 2 -* [#1151](https://github.com/digitalocean/netbox/issues/1151) - Server error: name 'escape' is not defined -* [#1152](https://github.com/digitalocean/netbox/issues/1152) - Unable to edit user keys -* [#1153](https://github.com/digitalocean/netbox/issues/1153) - UnicodeEncodeError when searching for non-ASCII characters on Python 2 +* [#1149](https://github.com/netbox-community/netbox/issues/1149) - Port list does not populate when creating a console or power connection +* [#1150](https://github.com/netbox-community/netbox/issues/1150) - Error when uploading image attachments with Unicode names under Python 2 +* [#1151](https://github.com/netbox-community/netbox/issues/1151) - Server error: name 'escape' is not defined +* [#1152](https://github.com/netbox-community/netbox/issues/1152) - Unable to edit user keys +* [#1153](https://github.com/netbox-community/netbox/issues/1153) - UnicodeEncodeError when searching for non-ASCII characters on Python 2 --- @@ -1350,56 +1655,56 @@ v2.0.0 (2017-05-09) ## New Features -### API 2.0 ([#113](https://github.com/digitalocean/netbox/issues/113)) +### API 2.0 ([#113](https://github.com/netbox-community/netbox/issues/113)) The NetBox API has been completely rewritten and now features full read/write ability. -### Image Attachments ([#152](https://github.com/digitalocean/netbox/issues/152)) +### Image Attachments ([#152](https://github.com/netbox-community/netbox/issues/152)) Users are now able to attach photos and other images to sites, racks, and devices. (Please ensure that the new `media` directory is writable by the system account NetBox runs as.) -### Global Search ([#159](https://github.com/digitalocean/netbox/issues/159)) +### Global Search ([#159](https://github.com/netbox-community/netbox/issues/159)) NetBox now supports searching across all primary object types at once. -### Rack Elevations View ([#951](https://github.com/digitalocean/netbox/issues/951)) +### Rack Elevations View ([#951](https://github.com/netbox-community/netbox/issues/951)) A new view has been introduced to display the elevations of multiple racks side-by-side. ## Enhancements -* [#154](https://github.com/digitalocean/netbox/issues/154) - Expanded device status field to include options other than active/offline -* [#430](https://github.com/digitalocean/netbox/issues/430) - Include circuits when rendering topology maps -* [#578](https://github.com/digitalocean/netbox/issues/578) - Show topology maps not assigned to a site on the home view -* [#1100](https://github.com/digitalocean/netbox/issues/1100) - Add a "view all" link to completed bulk import views is_pool for prefixes) -* [#1110](https://github.com/digitalocean/netbox/issues/1110) - Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) +* [#154](https://github.com/netbox-community/netbox/issues/154) - Expanded device status field to include options other than active/offline +* [#430](https://github.com/netbox-community/netbox/issues/430) - Include circuits when rendering topology maps +* [#578](https://github.com/netbox-community/netbox/issues/578) - Show topology maps not assigned to a site on the home view +* [#1100](https://github.com/netbox-community/netbox/issues/1100) - Add a "view all" link to completed bulk import views is_pool for prefixes) +* [#1110](https://github.com/netbox-community/netbox/issues/1110) - Expand bulk edit forms to include boolean fields (e.g. toggle is_pool for prefixes) ## Bug Fixes From v1.9.6: -* [#403](https://github.com/digitalocean/netbox/issues/403) - Record console/power/interface connects and disconnects as user actions -* [#853](https://github.com/digitalocean/netbox/issues/853) - Added "status" field to device bulk import form -* [#1101](https://github.com/digitalocean/netbox/issues/1101) - Fix AJAX scripting for device component selection forms -* [#1103](https://github.com/digitalocean/netbox/issues/1103) - Correct handling of validation errors when creating IP addresses in bulk -* [#1104](https://github.com/digitalocean/netbox/issues/1104) - Fix VLAN assignment on prefix import -* [#1115](https://github.com/digitalocean/netbox/issues/1115) - Enabled responsive (side-scrolling) tables for small screens -* [#1116](https://github.com/digitalocean/netbox/issues/1116) - Correct object links on recursive deletion error -* [#1125](https://github.com/digitalocean/netbox/issues/1125) - Include MAC addresses on a device's interface list -* [#1144](https://github.com/digitalocean/netbox/issues/1144) - Allow multiple status selections for Prefix, IP address, and VLAN filters +* [#403](https://github.com/netbox-community/netbox/issues/403) - Record console/power/interface connects and disconnects as user actions +* [#853](https://github.com/netbox-community/netbox/issues/853) - Added "status" field to device bulk import form +* [#1101](https://github.com/netbox-community/netbox/issues/1101) - Fix AJAX scripting for device component selection forms +* [#1103](https://github.com/netbox-community/netbox/issues/1103) - Correct handling of validation errors when creating IP addresses in bulk +* [#1104](https://github.com/netbox-community/netbox/issues/1104) - Fix VLAN assignment on prefix import +* [#1115](https://github.com/netbox-community/netbox/issues/1115) - Enabled responsive (side-scrolling) tables for small screens +* [#1116](https://github.com/netbox-community/netbox/issues/1116) - Correct object links on recursive deletion error +* [#1125](https://github.com/netbox-community/netbox/issues/1125) - Include MAC addresses on a device's interface list +* [#1144](https://github.com/netbox-community/netbox/issues/1144) - Allow multiple status selections for Prefix, IP address, and VLAN filters From beta3: -* [#1113](https://github.com/digitalocean/netbox/issues/1113) - Fixed server error when attempting to delete an image attachment -* [#1114](https://github.com/digitalocean/netbox/issues/1114) - Suppress OSError when attempting to access a deleted image attachment -* [#1126](https://github.com/digitalocean/netbox/issues/1126) - Fixed server error when editing a user key via admin UI attachment -* [#1132](https://github.com/digitalocean/netbox/issues/1132) - Prompt user to unlock session key when importing secrets +* [#1113](https://github.com/netbox-community/netbox/issues/1113) - Fixed server error when attempting to delete an image attachment +* [#1114](https://github.com/netbox-community/netbox/issues/1114) - Suppress OSError when attempting to access a deleted image attachment +* [#1126](https://github.com/netbox-community/netbox/issues/1126) - Fixed server error when editing a user key via admin UI attachment +* [#1132](https://github.com/netbox-community/netbox/issues/1132) - Prompt user to unlock session key when importing secrets ## Additional Changes -* The Module DCIM model has been renamed to InventoryItem to better reflect its intended function, and to make room for work on [#824](https://github.com/digitalocean/netbox/issues/824). -* Redundant portions of the admin UI have been removed ([#973](https://github.com/digitalocean/netbox/issues/973)). -* The Docker build components have been moved into [their own repository](https://github.com/digitalocean/netbox-docker). +* The Module DCIM model has been renamed to InventoryItem to better reflect its intended function, and to make room for work on [#824](https://github.com/netbox-community/netbox/issues/824). +* Redundant portions of the admin UI have been removed ([#973](https://github.com/netbox-community/netbox/issues/973)). +* The Docker build components have been moved into [their own repository](https://github.com/netbox-community/netbox-docker). --- @@ -1407,20 +1712,20 @@ v1.9.6 (2017-04-21) ## Improvements -* [#878](https://github.com/digitalocean/netbox/issues/878) - Merged IP addresses with interfaces list on device view -* [#1001](https://github.com/digitalocean/netbox/issues/1001) - Interface assignment can be modified when editing an IP address -* [#1084](https://github.com/digitalocean/netbox/issues/1084) - Include custom fields when creating IP addresses in bulk +* [#878](https://github.com/netbox-community/netbox/issues/878) - Merged IP addresses with interfaces list on device view +* [#1001](https://github.com/netbox-community/netbox/issues/1001) - Interface assignment can be modified when editing an IP address +* [#1084](https://github.com/netbox-community/netbox/issues/1084) - Include custom fields when creating IP addresses in bulk ## Bug Fixes -* [#1057](https://github.com/digitalocean/netbox/issues/1057) - Corrected VLAN validation during prefix import -* [#1061](https://github.com/digitalocean/netbox/issues/1061) - Fixed potential for script injection via create/edit/delete messages -* [#1070](https://github.com/digitalocean/netbox/issues/1070) - Corrected installation instructions for Python3 on CentOS/RHEL -* [#1071](https://github.com/digitalocean/netbox/issues/1071) - Protect assigned circuit termination when an interface is deleted -* [#1072](https://github.com/digitalocean/netbox/issues/1072) - Order LAG interfaces naturally on bulk interface edit form -* [#1074](https://github.com/digitalocean/netbox/issues/1074) - Require ncclient 0.5.3 (Python 3 fix) -* [#1090](https://github.com/digitalocean/netbox/issues/1090) - Improved installation documentation for Python 3 -* [#1092](https://github.com/digitalocean/netbox/issues/1092) - Increase randomness in SECRET_KEY generation tool +* [#1057](https://github.com/netbox-community/netbox/issues/1057) - Corrected VLAN validation during prefix import +* [#1061](https://github.com/netbox-community/netbox/issues/1061) - Fixed potential for script injection via create/edit/delete messages +* [#1070](https://github.com/netbox-community/netbox/issues/1070) - Corrected installation instructions for Python3 on CentOS/RHEL +* [#1071](https://github.com/netbox-community/netbox/issues/1071) - Protect assigned circuit termination when an interface is deleted +* [#1072](https://github.com/netbox-community/netbox/issues/1072) - Order LAG interfaces naturally on bulk interface edit form +* [#1074](https://github.com/netbox-community/netbox/issues/1074) - Require ncclient 0.5.3 (Python 3 fix) +* [#1090](https://github.com/netbox-community/netbox/issues/1090) - Improved installation documentation for Python 3 +* [#1092](https://github.com/netbox-community/netbox/issues/1092) - Increase randomness in SECRET_KEY generation tool --- @@ -1428,14 +1733,14 @@ v1.9.5 (2017-04-06) ## Improvements -* [#1052](https://github.com/digitalocean/netbox/issues/1052) - Added rack reservation list and bulk delete views +* [#1052](https://github.com/netbox-community/netbox/issues/1052) - Added rack reservation list and bulk delete views ## Bug Fixes -* [#1038](https://github.com/digitalocean/netbox/issues/1038) - Suppress upgrading to Django 1.11 (will be supported in v2.0) -* [#1037](https://github.com/digitalocean/netbox/issues/1037) - Fixed error on VLAN import with duplicate VLAN group names -* [#1047](https://github.com/digitalocean/netbox/issues/1047) - Correct ordering of numbered subinterfaces -* [#1051](https://github.com/digitalocean/netbox/issues/1051) - Upgraded django-rest-swagger +* [#1038](https://github.com/netbox-community/netbox/issues/1038) - Suppress upgrading to Django 1.11 (will be supported in v2.0) +* [#1037](https://github.com/netbox-community/netbox/issues/1037) - Fixed error on VLAN import with duplicate VLAN group names +* [#1047](https://github.com/netbox-community/netbox/issues/1047) - Correct ordering of numbered subinterfaces +* [#1051](https://github.com/netbox-community/netbox/issues/1051) - Upgraded django-rest-swagger --- @@ -1443,14 +1748,14 @@ v1.9.4-r1 (2017-04-04) ## Improvements -* [#362](https://github.com/digitalocean/netbox/issues/362) - Added per_page query parameter to control pagination page length +* [#362](https://github.com/netbox-community/netbox/issues/362) - Added per_page query parameter to control pagination page length ## Bug Fixes -* [#991](https://github.com/digitalocean/netbox/issues/991) - Correct server error on "create and connect another" interface connection -* [#1022](https://github.com/digitalocean/netbox/issues/1022) - Record user actions when creating IP addresses in bulk -* [#1027](https://github.com/digitalocean/netbox/issues/1027) - Fixed nav menu highlighting when BASE_PATH is set -* [#1034](https://github.com/digitalocean/netbox/issues/1034) - Added migration missing from v1.9.4 release +* [#991](https://github.com/netbox-community/netbox/issues/991) - Correct server error on "create and connect another" interface connection +* [#1022](https://github.com/netbox-community/netbox/issues/1022) - Record user actions when creating IP addresses in bulk +* [#1027](https://github.com/netbox-community/netbox/issues/1027) - Fixed nav menu highlighting when BASE_PATH is set +* [#1034](https://github.com/netbox-community/netbox/issues/1034) - Added migration missing from v1.9.4 release --- @@ -1458,15 +1763,15 @@ v1.9.3 (2017-03-23) ## Improvements -* [#972](https://github.com/digitalocean/netbox/issues/972) - Add ability to filter connections list by device name -* [#974](https://github.com/digitalocean/netbox/issues/974) - Added MAC address filter to API interfaces list -* [#978](https://github.com/digitalocean/netbox/issues/978) - Allow filtering device types by function and subdevice role -* [#981](https://github.com/digitalocean/netbox/issues/981) - Allow filtering primary objects by a given set of IDs -* [#983](https://github.com/digitalocean/netbox/issues/983) - Include peer device names when listing circuits in device view +* [#972](https://github.com/netbox-community/netbox/issues/972) - Add ability to filter connections list by device name +* [#974](https://github.com/netbox-community/netbox/issues/974) - Added MAC address filter to API interfaces list +* [#978](https://github.com/netbox-community/netbox/issues/978) - Allow filtering device types by function and subdevice role +* [#981](https://github.com/netbox-community/netbox/issues/981) - Allow filtering primary objects by a given set of IDs +* [#983](https://github.com/netbox-community/netbox/issues/983) - Include peer device names when listing circuits in device view ## Bug Fixes -* [#967](https://github.com/digitalocean/netbox/issues/967) - Fix error when assigning a new interface to a LAG +* [#967](https://github.com/netbox-community/netbox/issues/967) - Fix error when assigning a new interface to a LAG --- @@ -1474,11 +1779,11 @@ v1.9.2 (2017-03-14) ## Bug Fixes -* [#950](https://github.com/digitalocean/netbox/issues/950) - Fix site_id error on child device import -* [#956](https://github.com/digitalocean/netbox/issues/956) - Correct bug affecting unnamed rackless devices -* [#957](https://github.com/digitalocean/netbox/issues/957) - Correct device site filter count to include unracked devices -* [#963](https://github.com/digitalocean/netbox/issues/963) - Fix bug in IPv6 address range expansion -* [#964](https://github.com/digitalocean/netbox/issues/964) - Fix bug when bulk editing/deleting filtered set of objects +* [#950](https://github.com/netbox-community/netbox/issues/950) - Fix site_id error on child device import +* [#956](https://github.com/netbox-community/netbox/issues/956) - Correct bug affecting unnamed rackless devices +* [#957](https://github.com/netbox-community/netbox/issues/957) - Correct device site filter count to include unracked devices +* [#963](https://github.com/netbox-community/netbox/issues/963) - Fix bug in IPv6 address range expansion +* [#964](https://github.com/netbox-community/netbox/issues/964) - Fix bug when bulk editing/deleting filtered set of objects --- @@ -1486,15 +1791,15 @@ v1.9.1 (2017-03-08) ## Improvements -* [#945](https://github.com/digitalocean/netbox/issues/945) - Display the current user in the navigation menu -* [#946](https://github.com/digitalocean/netbox/issues/946) - Disregard mask length when filtering IP addresses by a parent prefix +* [#945](https://github.com/netbox-community/netbox/issues/945) - Display the current user in the navigation menu +* [#946](https://github.com/netbox-community/netbox/issues/946) - Disregard mask length when filtering IP addresses by a parent prefix ## Bug Fixes -* [#941](https://github.com/digitalocean/netbox/issues/941) - Corrected old references to rack.site on Device -* [#943](https://github.com/digitalocean/netbox/issues/943) - Child prefixes missing on Python 3 -* [#944](https://github.com/digitalocean/netbox/issues/944) - Corrected console and power connection form behavior -* [#948](https://github.com/digitalocean/netbox/issues/948) - Region name should be hyperlinked to site list +* [#941](https://github.com/netbox-community/netbox/issues/941) - Corrected old references to rack.site on Device +* [#943](https://github.com/netbox-community/netbox/issues/943) - Child prefixes missing on Python 3 +* [#944](https://github.com/netbox-community/netbox/issues/944) - Corrected console and power connection form behavior +* [#948](https://github.com/netbox-community/netbox/issues/948) - Region name should be hyperlinked to site list --- @@ -1502,43 +1807,43 @@ v1.9.0-r1 (2017-03-03) ## New Features -### Rack Reservations ([#36](https://github.com/digitalocean/netbox/issues/36)) +### Rack Reservations ([#36](https://github.com/netbox-community/netbox/issues/36)) Users can now reserve an arbitrary number of units within a rack, adding a comment noting their intentions. Reservations do not interfere with installed devices: It is possible to reserve a unit for future use even if it is currently occupied by a device. -### Interface Groups ([#105](https://github.com/digitalocean/netbox/issues/105)) +### Interface Groups ([#105](https://github.com/netbox-community/netbox/issues/105)) A new Link Aggregation Group (LAG) virtual form factor has been added. Physical interfaces can be assigned to a parent LAG interface to represent a port-channel or similar logical bundling of links. -### Regions ([#164](https://github.com/digitalocean/netbox/issues/164)) +### Regions ([#164](https://github.com/netbox-community/netbox/issues/164)) A new region model has been introduced to allow for the geographic organization of sites. Regions can be nested recursively to form a hierarchy. -### Rackless Devices ([#198](https://github.com/digitalocean/netbox/issues/198)) +### Rackless Devices ([#198](https://github.com/netbox-community/netbox/issues/198)) Previous releases required each device to be assigned to a particular rack within a site. This requirement has been relaxed so that devices must only be assigned to a site, and may optionally be assigned to a rack. -### Global VLANs ([#235](https://github.com/digitalocean/netbox/issues/235)) +### Global VLANs ([#235](https://github.com/netbox-community/netbox/issues/235)) Assignment of VLANs and VLAN groups to sites is now optional, allowing for the representation of a VLAN spanning multiple sites. ## Improvements -* [#862](https://github.com/digitalocean/netbox/issues/862) - Show both IPv6 and IPv4 primary IPs in device list -* [#894](https://github.com/digitalocean/netbox/issues/894) - Expand device name max length to 64 characters -* [#898](https://github.com/digitalocean/netbox/issues/898) - Expanded circuits list in provider view rack face -* [#901](https://github.com/digitalocean/netbox/issues/901) - Support for filtering prefixes and IP addresses by mask length +* [#862](https://github.com/netbox-community/netbox/issues/862) - Show both IPv6 and IPv4 primary IPs in device list +* [#894](https://github.com/netbox-community/netbox/issues/894) - Expand device name max length to 64 characters +* [#898](https://github.com/netbox-community/netbox/issues/898) - Expanded circuits list in provider view rack face +* [#901](https://github.com/netbox-community/netbox/issues/901) - Support for filtering prefixes and IP addresses by mask length ## Bug Fixes -* [#872](https://github.com/digitalocean/netbox/issues/872) - Fixed TypeError on bulk IP address creation (Python 3) -* [#884](https://github.com/digitalocean/netbox/issues/884) - Preserve selected rack unit when changing a device's rack face -* [#892](https://github.com/digitalocean/netbox/issues/892) - Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object -* [#897](https://github.com/digitalocean/netbox/issues/897) - Fixed power connections CSV export -* [#903](https://github.com/digitalocean/netbox/issues/903) - Only alert on missing critical connections if present in the parent device type -* [#935](https://github.com/digitalocean/netbox/issues/935) - Fix form validation error when connecting an interface using live search -* [#937](https://github.com/digitalocean/netbox/issues/937) - Region assignment should be optional when creating a site -* [#938](https://github.com/digitalocean/netbox/issues/938) - Provider view yields an error if one or more circuits is assigned to a tenant +* [#872](https://github.com/netbox-community/netbox/issues/872) - Fixed TypeError on bulk IP address creation (Python 3) +* [#884](https://github.com/netbox-community/netbox/issues/884) - Preserve selected rack unit when changing a device's rack face +* [#892](https://github.com/netbox-community/netbox/issues/892) - Restored missing edit/delete buttons when viewing child prefixes and IP addresses from a parent object +* [#897](https://github.com/netbox-community/netbox/issues/897) - Fixed power connections CSV export +* [#903](https://github.com/netbox-community/netbox/issues/903) - Only alert on missing critical connections if present in the parent device type +* [#935](https://github.com/netbox-community/netbox/issues/935) - Fix form validation error when connecting an interface using live search +* [#937](https://github.com/netbox-community/netbox/issues/937) - Region assignment should be optional when creating a site +* [#938](https://github.com/netbox-community/netbox/issues/938) - Provider view yields an error if one or more circuits is assigned to a tenant --- @@ -1546,15 +1851,15 @@ v1.8.4 (2017-02-03) ## Improvements -* [#856](https://github.com/digitalocean/netbox/issues/856) - Strip whitespace from fields during CSV import +* [#856](https://github.com/netbox-community/netbox/issues/856) - Strip whitespace from fields during CSV import ## Bug Fixes -* [#851](https://github.com/digitalocean/netbox/issues/851) - Resolve encoding issues during import/export (Python 3) -* [#854](https://github.com/digitalocean/netbox/issues/854) - Correct processing of get_return_url() in ObjectDeleteView -* [#859](https://github.com/digitalocean/netbox/issues/859) - Fix Javascript for connection status toggle button on device view -* [#861](https://github.com/digitalocean/netbox/issues/861) - Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses -* [#865](https://github.com/digitalocean/netbox/issues/865) - Fix server error when attempting to delete a protected object parent (Python 3) +* [#851](https://github.com/netbox-community/netbox/issues/851) - Resolve encoding issues during import/export (Python 3) +* [#854](https://github.com/netbox-community/netbox/issues/854) - Correct processing of get_return_url() in ObjectDeleteView +* [#859](https://github.com/netbox-community/netbox/issues/859) - Fix Javascript for connection status toggle button on device view +* [#861](https://github.com/netbox-community/netbox/issues/861) - Avoid overwriting device primary IP assignment from alternate family during bulk import of IP addresses +* [#865](https://github.com/netbox-community/netbox/issues/865) - Fix server error when attempting to delete a protected object parent (Python 3) --- @@ -1562,21 +1867,21 @@ v1.8.3 (2017-01-26) ## Improvements -* [#782](https://github.com/digitalocean/netbox/issues/782) - Allow filtering devices list by manufacturer -* [#820](https://github.com/digitalocean/netbox/issues/820) - Add VLAN column to parent prefixes table on IP address view -* [#821](https://github.com/digitalocean/netbox/issues/821) - Support for comma separation in bulk IP/interface creation -* [#827](https://github.com/digitalocean/netbox/issues/827) - **Introduced support for Python 3** -* [#836](https://github.com/digitalocean/netbox/issues/836) - Add "deprecated" status for IP addresses -* [#841](https://github.com/digitalocean/netbox/issues/841) - Merged search and filter forms on all object lists +* [#782](https://github.com/netbox-community/netbox/issues/782) - Allow filtering devices list by manufacturer +* [#820](https://github.com/netbox-community/netbox/issues/820) - Add VLAN column to parent prefixes table on IP address view +* [#821](https://github.com/netbox-community/netbox/issues/821) - Support for comma separation in bulk IP/interface creation +* [#827](https://github.com/netbox-community/netbox/issues/827) - **Introduced support for Python 3** +* [#836](https://github.com/netbox-community/netbox/issues/836) - Add "deprecated" status for IP addresses +* [#841](https://github.com/netbox-community/netbox/issues/841) - Merged search and filter forms on all object lists ## Bug Fixes -* [#816](https://github.com/digitalocean/netbox/issues/816) - Redirect back to parent prefix view after deleting child prefixes termination -* [#817](https://github.com/digitalocean/netbox/issues/817) - Update last_updated time of a circuit when editing a child termination -* [#830](https://github.com/digitalocean/netbox/issues/830) - Redirect user to device view after editing a device component -* [#840](https://github.com/digitalocean/netbox/issues/840) - Correct API path resolution for secrets when BASE_PATH is configured -* [#844](https://github.com/digitalocean/netbox/issues/844) - Apply order_naturally() to API interfaces list -* [#845](https://github.com/digitalocean/netbox/issues/845) - Fix missing edit/delete buttons on object tables for non-superusers +* [#816](https://github.com/netbox-community/netbox/issues/816) - Redirect back to parent prefix view after deleting child prefixes termination +* [#817](https://github.com/netbox-community/netbox/issues/817) - Update last_updated time of a circuit when editing a child termination +* [#830](https://github.com/netbox-community/netbox/issues/830) - Redirect user to device view after editing a device component +* [#840](https://github.com/netbox-community/netbox/issues/840) - Correct API path resolution for secrets when BASE_PATH is configured +* [#844](https://github.com/netbox-community/netbox/issues/844) - Apply order_naturally() to API interfaces list +* [#845](https://github.com/netbox-community/netbox/issues/845) - Fix missing edit/delete buttons on object tables for non-superusers --- @@ -1585,21 +1890,21 @@ v1.8.2 (2017-01-18) ## Improvements -* [#284](https://github.com/digitalocean/netbox/issues/284) - Enabled toggling of interface display order per device type -* [#760](https://github.com/digitalocean/netbox/issues/760) - Redirect user back to device view after deleting an assigned IP address -* [#783](https://github.com/digitalocean/netbox/issues/783) - Add a description field to the Circuit model -* [#797](https://github.com/digitalocean/netbox/issues/797) - Add description column to VLANs table -* [#803](https://github.com/digitalocean/netbox/issues/803) - Clarify that no child objects are deleted when deleting a prefix -* [#805](https://github.com/digitalocean/netbox/issues/805) - Linkify site column in device table +* [#284](https://github.com/netbox-community/netbox/issues/284) - Enabled toggling of interface display order per device type +* [#760](https://github.com/netbox-community/netbox/issues/760) - Redirect user back to device view after deleting an assigned IP address +* [#783](https://github.com/netbox-community/netbox/issues/783) - Add a description field to the Circuit model +* [#797](https://github.com/netbox-community/netbox/issues/797) - Add description column to VLANs table +* [#803](https://github.com/netbox-community/netbox/issues/803) - Clarify that no child objects are deleted when deleting a prefix +* [#805](https://github.com/netbox-community/netbox/issues/805) - Linkify site column in device table ## Bug Fixes -* [#776](https://github.com/digitalocean/netbox/issues/776) - Prevent circuits from appearing twice while searching -* [#778](https://github.com/digitalocean/netbox/issues/778) - Corrected an issue preventing multiple interfaces with the same position ID from appearing in a device's interface list -* [#785](https://github.com/digitalocean/netbox/issues/785) - Trigger validation error when importing a prefix assigned to a nonexistent VLAN -* [#802](https://github.com/digitalocean/netbox/issues/802) - Fixed enforcement of ENFORCE_GLOBAL_UNIQUE for prefixes -* [#807](https://github.com/digitalocean/netbox/issues/807) - Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked -* [#810](https://github.com/digitalocean/netbox/issues/810) - Suppress unique IP validation on invalid IP addresses and prefixes +* [#776](https://github.com/netbox-community/netbox/issues/776) - Prevent circuits from appearing twice while searching +* [#778](https://github.com/netbox-community/netbox/issues/778) - Corrected an issue preventing multiple interfaces with the same position ID from appearing in a device's interface list +* [#785](https://github.com/netbox-community/netbox/issues/785) - Trigger validation error when importing a prefix assigned to a nonexistent VLAN +* [#802](https://github.com/netbox-community/netbox/issues/802) - Fixed enforcement of ENFORCE_GLOBAL_UNIQUE for prefixes +* [#807](https://github.com/netbox-community/netbox/issues/807) - Redirect user back to form when adding IP addresses in bulk and "create and add another" is clicked +* [#810](https://github.com/netbox-community/netbox/issues/810) - Suppress unique IP validation on invalid IP addresses and prefixes --- @@ -1607,14 +1912,14 @@ v1.8.1 (2017-01-04) ## Improvements -* [#771](https://github.com/digitalocean/netbox/issues/771) - Don't automatically redirect user when only one object is returned in a list +* [#771](https://github.com/netbox-community/netbox/issues/771) - Don't automatically redirect user when only one object is returned in a list ## Bug Fixes -* [#764](https://github.com/digitalocean/netbox/issues/764) - Encapsulate in double quotes values containing commas when exporting to CSV -* [#767](https://github.com/digitalocean/netbox/issues/767) - Fixes xconnect_id error when searching for circuits -* [#769](https://github.com/digitalocean/netbox/issues/769) - Show default value for boolean custom fields -* [#772](https://github.com/digitalocean/netbox/issues/772) - Fixes TypeError in API RackUnitListView when no device is excluded +* [#764](https://github.com/netbox-community/netbox/issues/764) - Encapsulate in double quotes values containing commas when exporting to CSV +* [#767](https://github.com/netbox-community/netbox/issues/767) - Fixes xconnect_id error when searching for circuits +* [#769](https://github.com/netbox-community/netbox/issues/769) - Show default value for boolean custom fields +* [#772](https://github.com/netbox-community/netbox/issues/772) - Fixes TypeError in API RackUnitListView when no device is excluded --- @@ -1622,33 +1927,33 @@ v1.8.0 (2017-01-03) ## New Features -### Point-to-Point Circuits ([#49](https://github.com/digitalocean/netbox/issues/49)) +### Point-to-Point Circuits ([#49](https://github.com/netbox-community/netbox/issues/49)) Until now, NetBox has supported tracking only one end of a data circuit. This is fine for Internet connections where you don't care (or know) much about the provider side of the circuit, but many users need the ability to track inter-site circuits as well. This release expands circuit modeling so that each circuit can have an A and/or Z side. Each endpoint must be terminated to a site, and may optionally be terminated to a specific device and interface within that site. -### L4 Services ([#539](https://github.com/digitalocean/netbox/issues/539)) +### L4 Services ([#539](https://github.com/netbox-community/netbox/issues/539)) Our first major community contribution introduces the ability to track discrete TCP and UDP services associated with a device (for example, SSH or HTTP). Each service can optionally be assigned to one or more specific IP addresses belonging to the device. Thanks to [@if-fi](https://github.com/if-fi) for the addition! ## Improvements -* [#122](https://github.com/digitalocean/netbox/issues/122) - Added comments field to device types -* [#181](https://github.com/digitalocean/netbox/issues/181) - Implemented support for bulk IP address creation -* [#613](https://github.com/digitalocean/netbox/issues/613) - Added prefixes column to VLAN list; added VLAN column to prefix list -* [#716](https://github.com/digitalocean/netbox/issues/716) - Add ASN field to site bulk edit form -* [#722](https://github.com/digitalocean/netbox/issues/722) - Enabled custom fields for device types -* [#743](https://github.com/digitalocean/netbox/issues/743) - Enabled bulk creation of all device components -* [#756](https://github.com/digitalocean/netbox/issues/756) - Added contact details to site model +* [#122](https://github.com/netbox-community/netbox/issues/122) - Added comments field to device types +* [#181](https://github.com/netbox-community/netbox/issues/181) - Implemented support for bulk IP address creation +* [#613](https://github.com/netbox-community/netbox/issues/613) - Added prefixes column to VLAN list; added VLAN column to prefix list +* [#716](https://github.com/netbox-community/netbox/issues/716) - Add ASN field to site bulk edit form +* [#722](https://github.com/netbox-community/netbox/issues/722) - Enabled custom fields for device types +* [#743](https://github.com/netbox-community/netbox/issues/743) - Enabled bulk creation of all device components +* [#756](https://github.com/netbox-community/netbox/issues/756) - Added contact details to site model ## Bug Fixes -* [#563](https://github.com/digitalocean/netbox/issues/563) - Allow a device to be flipped from one rack face to the other without moving it -* [#658](https://github.com/digitalocean/netbox/issues/658) - Enabled conditional treatment of network/broadcast IPs for a prefix by defining it as a pool -* [#741](https://github.com/digitalocean/netbox/issues/741) - Hide "select all" button for users without edit permissions -* [#744](https://github.com/digitalocean/netbox/issues/744) - Fixed export of sites without an AS number -* [#747](https://github.com/digitalocean/netbox/issues/747) - Fixed natural_order_by integer cast error on large numbers -* [#751](https://github.com/digitalocean/netbox/issues/751) - Fixed python-cryptography installation issue on Debian -* [#763](https://github.com/digitalocean/netbox/issues/763) - Added missing fields to CSV exports for racks and prefixes +* [#563](https://github.com/netbox-community/netbox/issues/563) - Allow a device to be flipped from one rack face to the other without moving it +* [#658](https://github.com/netbox-community/netbox/issues/658) - Enabled conditional treatment of network/broadcast IPs for a prefix by defining it as a pool +* [#741](https://github.com/netbox-community/netbox/issues/741) - Hide "select all" button for users without edit permissions +* [#744](https://github.com/netbox-community/netbox/issues/744) - Fixed export of sites without an AS number +* [#747](https://github.com/netbox-community/netbox/issues/747) - Fixed natural_order_by integer cast error on large numbers +* [#751](https://github.com/netbox-community/netbox/issues/751) - Fixed python-cryptography installation issue on Debian +* [#763](https://github.com/netbox-community/netbox/issues/763) - Added missing fields to CSV exports for racks and prefixes --- @@ -1656,11 +1961,11 @@ v1.7.3 (2016-12-08) ## Bug Fixes -* [#724](https://github.com/digitalocean/netbox/issues/724) - Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true -* [#729](https://github.com/digitalocean/netbox/issues/729) - Corrected cancellation links when editing secondary objects -* [#732](https://github.com/digitalocean/netbox/issues/732) - Allow custom select field values to be deselected if the field is not required -* [#733](https://github.com/digitalocean/netbox/issues/733) - Fixed MAC address filter on device list -* [#734](https://github.com/digitalocean/netbox/issues/734) - Corrected display of device type when editing a device +* [#724](https://github.com/netbox-community/netbox/issues/724) - Exempt API views from LoginRequiredMiddleware to enable basic HTTP authentication when LOGIN_REQUIRED is true +* [#729](https://github.com/netbox-community/netbox/issues/729) - Corrected cancellation links when editing secondary objects +* [#732](https://github.com/netbox-community/netbox/issues/732) - Allow custom select field values to be deselected if the field is not required +* [#733](https://github.com/netbox-community/netbox/issues/733) - Fixed MAC address filter on device list +* [#734](https://github.com/netbox-community/netbox/issues/734) - Corrected display of device type when editing a device --- @@ -1668,20 +1973,20 @@ v1.7.2-r1 (2016-12-06) ## Improvements -* [#663](https://github.com/digitalocean/netbox/issues/663) - Added MAC address search field to device list -* [#672](https://github.com/digitalocean/netbox/issues/672) - Increased the selection of available colors for rack and device roles -* [#695](https://github.com/digitalocean/netbox/issues/695) - Added is_private field to RIR +* [#663](https://github.com/netbox-community/netbox/issues/663) - Added MAC address search field to device list +* [#672](https://github.com/netbox-community/netbox/issues/672) - Increased the selection of available colors for rack and device roles +* [#695](https://github.com/netbox-community/netbox/issues/695) - Added is_private field to RIR ## Bug Fixes -* [#677](https://github.com/digitalocean/netbox/issues/677) - Fix setuptools installation error on Debian 8.6 -* [#696](https://github.com/digitalocean/netbox/issues/696) - Corrected link to VRF in prefix and IP address breadcrumbs -* [#702](https://github.com/digitalocean/netbox/issues/702) - Improved Unicode support for custom fields -* [#712](https://github.com/digitalocean/netbox/issues/712) - Corrected export of tenants which are not assigned to a group -* [#713](https://github.com/digitalocean/netbox/issues/713) - Include a label for the comments field when editing circuits, providers, or racks in bulk -* [#718](https://github.com/digitalocean/netbox/issues/718) - Restore is_primary field on IP assignment form -* [#723](https://github.com/digitalocean/netbox/issues/723) - API documentation is now accessible when using BASE_PATH -* [#727](https://github.com/digitalocean/netbox/issues/727) - Corrected error in rack elevation display (v1.7.2) +* [#677](https://github.com/netbox-community/netbox/issues/677) - Fix setuptools installation error on Debian 8.6 +* [#696](https://github.com/netbox-community/netbox/issues/696) - Corrected link to VRF in prefix and IP address breadcrumbs +* [#702](https://github.com/netbox-community/netbox/issues/702) - Improved Unicode support for custom fields +* [#712](https://github.com/netbox-community/netbox/issues/712) - Corrected export of tenants which are not assigned to a group +* [#713](https://github.com/netbox-community/netbox/issues/713) - Include a label for the comments field when editing circuits, providers, or racks in bulk +* [#718](https://github.com/netbox-community/netbox/issues/718) - Restore is_primary field on IP assignment form +* [#723](https://github.com/netbox-community/netbox/issues/723) - API documentation is now accessible when using BASE_PATH +* [#727](https://github.com/netbox-community/netbox/issues/727) - Corrected error in rack elevation display (v1.7.2) --- @@ -1689,16 +1994,16 @@ v1.7.1 (2016-11-15) ## Improvements -* [#667](https://github.com/digitalocean/netbox/issues/667) - Added prefix utilization statistics to the RIR list view -* [#685](https://github.com/digitalocean/netbox/issues/685) - When assigning an IP to a device, automatically select the interface if only one exists +* [#667](https://github.com/netbox-community/netbox/issues/667) - Added prefix utilization statistics to the RIR list view +* [#685](https://github.com/netbox-community/netbox/issues/685) - When assigning an IP to a device, automatically select the interface if only one exists ## Bug Fixes -* [#674](https://github.com/digitalocean/netbox/issues/674) - Fix assignment of status to imported IP addresses -* [#676](https://github.com/digitalocean/netbox/issues/676) - Server error when bulk editing device types -* [#678](https://github.com/digitalocean/netbox/issues/678) - Server error on device import specifying an invalid device type -* [#691](https://github.com/digitalocean/netbox/issues/691) - Allow the assignment of power ports to PDUs -* [#692](https://github.com/digitalocean/netbox/issues/692) - Form errors are not displayed on checkbox fields +* [#674](https://github.com/netbox-community/netbox/issues/674) - Fix assignment of status to imported IP addresses +* [#676](https://github.com/netbox-community/netbox/issues/676) - Server error when bulk editing device types +* [#678](https://github.com/netbox-community/netbox/issues/678) - Server error on device import specifying an invalid device type +* [#691](https://github.com/netbox-community/netbox/issues/691) - Allow the assignment of power ports to PDUs +* [#692](https://github.com/netbox-community/netbox/issues/692) - Form errors are not displayed on checkbox fields --- @@ -1706,27 +2011,27 @@ v1.7.0 (2016-11-03) ## New Features -### IP address statuses ([#87](https://github.com/digitalocean/netbox/issues/87)) +### IP address statuses ([#87](https://github.com/netbox-community/netbox/issues/87)) An IP address can now be designated as active, reserved, or DHCP. The DHCP status implies that the IP address is part of a DHCP pool and may or may not be assigned to a DHCP client. -### Top-to-bottom rack numbering ([#191](https://github.com/digitalocean/netbox/issues/191)) +### Top-to-bottom rack numbering ([#191](https://github.com/netbox-community/netbox/issues/191)) Racks can now be set to have descending rack units, with U1 at the top of the rack. When adding a device to a rack with descending units, be sure to position it in the **lowest-numbered** unit which it occupies (this will be physically the topmost unit). ## Improvements -* [#211](https://github.com/digitalocean/netbox/issues/211) - Allow device assignment and removal from IP address view -* [#630](https://github.com/digitalocean/netbox/issues/630) - Added a custom 404 page -* [#652](https://github.com/digitalocean/netbox/issues/652) - Use password input controls when editing secrets -* [#654](https://github.com/digitalocean/netbox/issues/654) - Added Cisco FlexStack and FlexStack Plus form factors -* [#661](https://github.com/digitalocean/netbox/issues/661) - Display relevant IP addressing when viewing a circuit +* [#211](https://github.com/netbox-community/netbox/issues/211) - Allow device assignment and removal from IP address view +* [#630](https://github.com/netbox-community/netbox/issues/630) - Added a custom 404 page +* [#652](https://github.com/netbox-community/netbox/issues/652) - Use password input controls when editing secrets +* [#654](https://github.com/netbox-community/netbox/issues/654) - Added Cisco FlexStack and FlexStack Plus form factors +* [#661](https://github.com/netbox-community/netbox/issues/661) - Display relevant IP addressing when viewing a circuit ## Bug Fixes -* [#632](https://github.com/digitalocean/netbox/issues/632) - Use semicolons instead of commas to separate regexes in topology maps -* [#647](https://github.com/digitalocean/netbox/issues/647) - Extend form used when assigning an IP to a device -* [#657](https://github.com/digitalocean/netbox/issues/657) - Unicode error when adding device modules -* [#660](https://github.com/digitalocean/netbox/issues/660) - Corrected calculation of utilized space in rack list -* [#664](https://github.com/digitalocean/netbox/issues/664) - Fixed bulk creation of interfaces across multiple devices +* [#632](https://github.com/netbox-community/netbox/issues/632) - Use semicolons instead of commas to separate regexes in topology maps +* [#647](https://github.com/netbox-community/netbox/issues/647) - Extend form used when assigning an IP to a device +* [#657](https://github.com/netbox-community/netbox/issues/657) - Unicode error when adding device modules +* [#660](https://github.com/netbox-community/netbox/issues/660) - Corrected calculation of utilized space in rack list +* [#664](https://github.com/netbox-community/netbox/issues/664) - Fixed bulk creation of interfaces across multiple devices --- @@ -1734,18 +2039,18 @@ v1.6.3 (2016-10-19) ## Improvements -* [#353](https://github.com/digitalocean/netbox/issues/353) - Bulk editing of device and device type interfaces -* [#527](https://github.com/digitalocean/netbox/issues/527) - Support for nullification of fields when bulk editing -* [#592](https://github.com/digitalocean/netbox/issues/592) - Allow space-delimited lists of ALLOWED_HOSTS in Docker -* [#608](https://github.com/digitalocean/netbox/issues/608) - Added "select all" button for device and device type components +* [#353](https://github.com/netbox-community/netbox/issues/353) - Bulk editing of device and device type interfaces +* [#527](https://github.com/netbox-community/netbox/issues/527) - Support for nullification of fields when bulk editing +* [#592](https://github.com/netbox-community/netbox/issues/592) - Allow space-delimited lists of ALLOWED_HOSTS in Docker +* [#608](https://github.com/netbox-community/netbox/issues/608) - Added "select all" button for device and device type components ## Bug Fixes -* [#602](https://github.com/digitalocean/netbox/issues/602) - Correct display of custom integer fields with value of 0 or 1 -* [#604](https://github.com/digitalocean/netbox/issues/604) - Correct display of unnamed devices in form selection fields -* [#611](https://github.com/digitalocean/netbox/issues/611) - Power/console/interface connection import: status field should be case-insensitive -* [#615](https://github.com/digitalocean/netbox/issues/615) - Account for BASE_PATH in static URLs and during login -* [#616](https://github.com/digitalocean/netbox/issues/616) - Correct display of custom URL fields +* [#602](https://github.com/netbox-community/netbox/issues/602) - Correct display of custom integer fields with value of 0 or 1 +* [#604](https://github.com/netbox-community/netbox/issues/604) - Correct display of unnamed devices in form selection fields +* [#611](https://github.com/netbox-community/netbox/issues/611) - Power/console/interface connection import: status field should be case-insensitive +* [#615](https://github.com/netbox-community/netbox/issues/615) - Account for BASE_PATH in static URLs and during login +* [#616](https://github.com/netbox-community/netbox/issues/616) - Correct display of custom URL fields --- @@ -1753,39 +2058,39 @@ v1.6.2-r1 (2016-10-04) ## Improvements -* [#212](https://github.com/digitalocean/netbox/issues/212) - Introduced the `BASE_PATH` configuration setting to allow running NetBox in a URL subdirectory -* [#345](https://github.com/digitalocean/netbox/issues/345) - Bulk edit: allow user to select all objects on page or all matching query -* [#475](https://github.com/digitalocean/netbox/issues/475) - Display "add" buttons at top and bottom of all device/device type panels -* [#480](https://github.com/digitalocean/netbox/issues/480) - Improved layout on mobile devices -* [#481](https://github.com/digitalocean/netbox/issues/481) - Require interface creation before trying to assign an IP to a device -* [#575](https://github.com/digitalocean/netbox/issues/575) - Allow all valid URL schemes in custom fields -* [#579](https://github.com/digitalocean/netbox/issues/579) - Add a description field to export templates +* [#212](https://github.com/netbox-community/netbox/issues/212) - Introduced the `BASE_PATH` configuration setting to allow running NetBox in a URL subdirectory +* [#345](https://github.com/netbox-community/netbox/issues/345) - Bulk edit: allow user to select all objects on page or all matching query +* [#475](https://github.com/netbox-community/netbox/issues/475) - Display "add" buttons at top and bottom of all device/device type panels +* [#480](https://github.com/netbox-community/netbox/issues/480) - Improved layout on mobile devices +* [#481](https://github.com/netbox-community/netbox/issues/481) - Require interface creation before trying to assign an IP to a device +* [#575](https://github.com/netbox-community/netbox/issues/575) - Allow all valid URL schemes in custom fields +* [#579](https://github.com/netbox-community/netbox/issues/579) - Add a description field to export templates ## Bug Fixes -* [#466](https://github.com/digitalocean/netbox/issues/466) - Validate available free space for all instances when increasing the U height of a device type -* [#571](https://github.com/digitalocean/netbox/issues/571) - Correct rack group filter on device list -* [#576](https://github.com/digitalocean/netbox/issues/576) - Delete all relevant CustomFieldValues when deleting a CustomFieldChoice -* [#581](https://github.com/digitalocean/netbox/issues/581) - Correct initialization of custom boolean and select fields -* [#591](https://github.com/digitalocean/netbox/issues/591) - Correct display of component creation buttons in device type view +* [#466](https://github.com/netbox-community/netbox/issues/466) - Validate available free space for all instances when increasing the U height of a device type +* [#571](https://github.com/netbox-community/netbox/issues/571) - Correct rack group filter on device list +* [#576](https://github.com/netbox-community/netbox/issues/576) - Delete all relevant CustomFieldValues when deleting a CustomFieldChoice +* [#581](https://github.com/netbox-community/netbox/issues/581) - Correct initialization of custom boolean and select fields +* [#591](https://github.com/netbox-community/netbox/issues/591) - Correct display of component creation buttons in device type view --- v1.6.1-r1 (2016-09-21) ## Improvements -* [#415](https://github.com/digitalocean/netbox/issues/415) - Add an expand/collapse toggle button to the prefix list -* [#552](https://github.com/digitalocean/netbox/issues/552) - Allow filtering on custom select fields by "none" -* [#561](https://github.com/digitalocean/netbox/issues/561) - Make custom fields accessible from within export templates +* [#415](https://github.com/netbox-community/netbox/issues/415) - Add an expand/collapse toggle button to the prefix list +* [#552](https://github.com/netbox-community/netbox/issues/552) - Allow filtering on custom select fields by "none" +* [#561](https://github.com/netbox-community/netbox/issues/561) - Make custom fields accessible from within export templates ## Bug Fixes -* [#493](https://github.com/digitalocean/netbox/issues/493) - CSV import support for UTF-8 -* [#531](https://github.com/digitalocean/netbox/issues/531) - Order prefix list by VRF assignment -* [#542](https://github.com/digitalocean/netbox/issues/542) - Add LDAP support in Docker -* [#557](https://github.com/digitalocean/netbox/issues/557) - Add 'global' choice to VRF filter for prefixes and IP addresses -* [#558](https://github.com/digitalocean/netbox/issues/558) - Update slug field when name is populated without a key press -* [#562](https://github.com/digitalocean/netbox/issues/562) - Fixed bulk interface creation -* [#564](https://github.com/digitalocean/netbox/issues/564) - Display custom fields for all applicable objects +* [#493](https://github.com/netbox-community/netbox/issues/493) - CSV import support for UTF-8 +* [#531](https://github.com/netbox-community/netbox/issues/531) - Order prefix list by VRF assignment +* [#542](https://github.com/netbox-community/netbox/issues/542) - Add LDAP support in Docker +* [#557](https://github.com/netbox-community/netbox/issues/557) - Add 'global' choice to VRF filter for prefixes and IP addresses +* [#558](https://github.com/netbox-community/netbox/issues/558) - Update slug field when name is populated without a key press +* [#562](https://github.com/netbox-community/netbox/issues/562) - Fixed bulk interface creation +* [#564](https://github.com/netbox-community/netbox/issues/564) - Display custom fields for all applicable objects --- @@ -1793,28 +2098,28 @@ v1.6.0 (2016-09-13) ## New Features -### Custom Fields ([#129](https://github.com/digitalocean/netbox/issues/129)) +### Custom Fields ([#129](https://github.com/netbox-community/netbox/issues/129)) Users can now create custom fields to associate arbitrary data with core NetBox objects. For example, you might want to add a geolocation tag to IP prefixes, or a ticket number to each device. Text, integer, boolean, date, URL, and selection fields are supported. ## Improvements -* [#489](https://github.com/digitalocean/netbox/issues/489) - Docker file now builds from a `python:2.7-wheezy` base instead of `ubuntu:14.04` -* [#540](https://github.com/digitalocean/netbox/issues/540) - Add links for VLAN roles under VLAN navigation menu +* [#489](https://github.com/netbox-community/netbox/issues/489) - Docker file now builds from a `python:2.7-wheezy` base instead of `ubuntu:14.04` +* [#540](https://github.com/netbox-community/netbox/issues/540) - Add links for VLAN roles under VLAN navigation menu * Added new interface form factors * Added address family filters to aggregate and prefix lists ## Bug Fixes -* [#476](https://github.com/digitalocean/netbox/issues/476) - Corrected rack import instructions -* [#484](https://github.com/digitalocean/netbox/issues/484) - Allow bulk deletion of >1K objects -* [#486](https://github.com/digitalocean/netbox/issues/486) - Prompt for secret key only if updating a secret's value -* [#490](https://github.com/digitalocean/netbox/issues/490) - Corrected display of circuit commit rate -* [#495](https://github.com/digitalocean/netbox/issues/495) - Include tenant in prefix and IP CSV export -* [#507](https://github.com/digitalocean/netbox/issues/507) - Corrected rendering of nav menu on screens narrower than 1200px -* [#515](https://github.com/digitalocean/netbox/issues/515) - Clarified instructions for the "face" field when importing devices -* [#522](https://github.com/digitalocean/netbox/issues/522) - Remove obsolete check for staff status when bulk deleting objects -* [#544](https://github.com/digitalocean/netbox/issues/544) - Strip CRLF-style line terminators from rendered export templates +* [#476](https://github.com/netbox-community/netbox/issues/476) - Corrected rack import instructions +* [#484](https://github.com/netbox-community/netbox/issues/484) - Allow bulk deletion of >1K objects +* [#486](https://github.com/netbox-community/netbox/issues/486) - Prompt for secret key only if updating a secret's value +* [#490](https://github.com/netbox-community/netbox/issues/490) - Corrected display of circuit commit rate +* [#495](https://github.com/netbox-community/netbox/issues/495) - Include tenant in prefix and IP CSV export +* [#507](https://github.com/netbox-community/netbox/issues/507) - Corrected rendering of nav menu on screens narrower than 1200px +* [#515](https://github.com/netbox-community/netbox/issues/515) - Clarified instructions for the "face" field when importing devices +* [#522](https://github.com/netbox-community/netbox/issues/522) - Remove obsolete check for staff status when bulk deleting objects +* [#544](https://github.com/netbox-community/netbox/issues/544) - Strip CRLF-style line terminators from rendered export templates --- @@ -1822,12 +2127,12 @@ v1.5.2 (2016-08-16) ## Bug Fixes -* [#460](https://github.com/digitalocean/netbox/issues/460) - Corrected ordering of IP addresses with differing prefix lengths -* [#463](https://github.com/digitalocean/netbox/issues/463) - Prevent pre-population of livesearch field with '---------' -* [#467](https://github.com/digitalocean/netbox/issues/467) - Include prefixes and IPs which inherit tenancy from their VRF in tenant stats -* [#468](https://github.com/digitalocean/netbox/issues/468) - Don't allow connected interfaces to be changed to the "virtual" form factor -* [#469](https://github.com/digitalocean/netbox/issues/469) - Added missing import buttons to list views -* [#472](https://github.com/digitalocean/netbox/issues/472) - Hide the connection button for interfaces which have a circuit terminated to them +* [#460](https://github.com/netbox-community/netbox/issues/460) - Corrected ordering of IP addresses with differing prefix lengths +* [#463](https://github.com/netbox-community/netbox/issues/463) - Prevent pre-population of livesearch field with '---------' +* [#467](https://github.com/netbox-community/netbox/issues/467) - Include prefixes and IPs which inherit tenancy from their VRF in tenant stats +* [#468](https://github.com/netbox-community/netbox/issues/468) - Don't allow connected interfaces to be changed to the "virtual" form factor +* [#469](https://github.com/netbox-community/netbox/issues/469) - Added missing import buttons to list views +* [#472](https://github.com/netbox-community/netbox/issues/472) - Hide the connection button for interfaces which have a circuit terminated to them --- @@ -1835,14 +2140,14 @@ v1.5.1 (2016-08-11) ## Improvements -* [#421](https://github.com/digitalocean/netbox/issues/421) - Added an asset tag field to devices -* [#456](https://github.com/digitalocean/netbox/issues/456) - Added IP search box to home page +* [#421](https://github.com/netbox-community/netbox/issues/421) - Added an asset tag field to devices +* [#456](https://github.com/netbox-community/netbox/issues/456) - Added IP search box to home page * Colorized rack and device roles ## Bug Fixes -* [#454](https://github.com/digitalocean/netbox/issues/454) - Corrected error on rack export -* [#457](https://github.com/digitalocean/netbox/issues/457) - Added role field to rack edit form +* [#454](https://github.com/netbox-community/netbox/issues/454) - Corrected error on rack export +* [#457](https://github.com/netbox-community/netbox/issues/457) - Added role field to rack edit form --- @@ -1850,24 +2155,24 @@ v1.5.0 (2016-08-10) ## New Features -### Rack Enhancements ([#180](https://github.com/digitalocean/netbox/issues/180), [#241](https://github.com/digitalocean/netbox/issues/241)) +### Rack Enhancements ([#180](https://github.com/netbox-community/netbox/issues/180), [#241](https://github.com/netbox-community/netbox/issues/241)) Like devices, racks can now be assigned to functional roles. This allows users to group racks by designated function as well as by physical location (rack groups). Additionally, rack can now have a defined rail-to-rail width (19 or 23 inches) and a type (two-post-rack, cabinet, etc.). ## Improvements -* [#149](https://github.com/digitalocean/netbox/issues/149) - Added discrete upstream speed field for circuits -* [#157](https://github.com/digitalocean/netbox/issues/157) - Added manufacturer field for device modules +* [#149](https://github.com/netbox-community/netbox/issues/149) - Added discrete upstream speed field for circuits +* [#157](https://github.com/netbox-community/netbox/issues/157) - Added manufacturer field for device modules * We have a logo! * Upgraded to Django 1.10 ## Bug Fixes -* [#433](https://github.com/digitalocean/netbox/issues/433) - Corrected form validation when editing child devices -* [#442](https://github.com/digitalocean/netbox/issues/442) - Corrected child device import instructions -* [#443](https://github.com/digitalocean/netbox/issues/443) - Correctly display and initialize VRF for creation of new IP addresses -* [#444](https://github.com/digitalocean/netbox/issues/444) - Corrected prefix model validation -* [#445](https://github.com/digitalocean/netbox/issues/445) - Limit rack height to between 1U and 100U (inclusive) +* [#433](https://github.com/netbox-community/netbox/issues/433) - Corrected form validation when editing child devices +* [#442](https://github.com/netbox-community/netbox/issues/442) - Corrected child device import instructions +* [#443](https://github.com/netbox-community/netbox/issues/443) - Correctly display and initialize VRF for creation of new IP addresses +* [#444](https://github.com/netbox-community/netbox/issues/444) - Corrected prefix model validation +* [#445](https://github.com/netbox-community/netbox/issues/445) - Limit rack height to between 1U and 100U (inclusive) --- @@ -1875,16 +2180,16 @@ v1.4.2 (2016-08-06) ## Improvements -* [#167](https://github.com/digitalocean/netbox/issues/167) - Added new interface form factors -* [#253](https://github.com/digitalocean/netbox/issues/253) - Added new interface form factors -* [#434](https://github.com/digitalocean/netbox/issues/434) - Restored admin UI access to user action history (however bulk deletion is disabled) -* [#435](https://github.com/digitalocean/netbox/issues/435) - Added an "add prefix" button to the VLAN view +* [#167](https://github.com/netbox-community/netbox/issues/167) - Added new interface form factors +* [#253](https://github.com/netbox-community/netbox/issues/253) - Added new interface form factors +* [#434](https://github.com/netbox-community/netbox/issues/434) - Restored admin UI access to user action history (however bulk deletion is disabled) +* [#435](https://github.com/netbox-community/netbox/issues/435) - Added an "add prefix" button to the VLAN view ## Bug Fixes -* [#425](https://github.com/digitalocean/netbox/issues/425) - Ignore leading and trailing periods when generating a slug -* [#427](https://github.com/digitalocean/netbox/issues/427) - Prevent error when duplicate IPs are present in a prefix's IP list -* [#429](https://github.com/digitalocean/netbox/issues/429) - Correct redirection of user when adding a secret to a device +* [#425](https://github.com/netbox-community/netbox/issues/425) - Ignore leading and trailing periods when generating a slug +* [#427](https://github.com/netbox-community/netbox/issues/427) - Prevent error when duplicate IPs are present in a prefix's IP list +* [#429](https://github.com/netbox-community/netbox/issues/429) - Correct redirection of user when adding a secret to a device --- @@ -1892,17 +2197,17 @@ v1.4.1 (2016-08-03) ## Improvements -* [#289](https://github.com/digitalocean/netbox/issues/289) - Annotate available ranges in prefix IP list -* [#412](https://github.com/digitalocean/netbox/issues/412) - Tenant group assignment is no longer mandatory -* [#422](https://github.com/digitalocean/netbox/issues/422) - CSV import now supports double-quoting values which contain commas +* [#289](https://github.com/netbox-community/netbox/issues/289) - Annotate available ranges in prefix IP list +* [#412](https://github.com/netbox-community/netbox/issues/412) - Tenant group assignment is no longer mandatory +* [#422](https://github.com/netbox-community/netbox/issues/422) - CSV import now supports double-quoting values which contain commas ## Bug Fixes -* [#395](https://github.com/digitalocean/netbox/issues/395) - Show child prefixes from all VRFs if the parent belongs to the global table -* [#406](https://github.com/digitalocean/netbox/issues/406) - Fixed circuit list rendring when filtering on port speed or commit rate -* [#409](https://github.com/digitalocean/netbox/issues/409) - Filter IPs and prefixes by tenant slug rather than by its PK -* [#411](https://github.com/digitalocean/netbox/issues/411) - Corrected title of secret roles view -* [#419](https://github.com/digitalocean/netbox/issues/419) - Fixed a potential database performance issue when gathering tenant statistics +* [#395](https://github.com/netbox-community/netbox/issues/395) - Show child prefixes from all VRFs if the parent belongs to the global table +* [#406](https://github.com/netbox-community/netbox/issues/406) - Fixed circuit list rendring when filtering on port speed or commit rate +* [#409](https://github.com/netbox-community/netbox/issues/409) - Filter IPs and prefixes by tenant slug rather than by its PK +* [#411](https://github.com/netbox-community/netbox/issues/411) - Corrected title of secret roles view +* [#419](https://github.com/netbox-community/netbox/issues/419) - Fixed a potential database performance issue when gathering tenant statistics --- @@ -1910,21 +2215,21 @@ v1.4.0 (2016-08-01) ## New Features -### Multitenancy ([#16](https://github.com/digitalocean/netbox/issues/16)) +### Multitenancy ([#16](https://github.com/netbox-community/netbox/issues/16)) NetBox now supports tenants and tenant groups. Sites, racks, devices, VRFs, prefixes, IP addresses, VLANs, and circuits can be assigned to tenants to track the allocation of these resources among customers or internal departments. If a prefix or IP address does not have a tenant assigned, it will fall back to the tenant assigned to its parent VRF (where applicable). ## Improvements -* [#176](https://github.com/digitalocean/netbox/issues/176) - Introduced seed data for new installs -* [#358](https://github.com/digitalocean/netbox/issues/358) - Improved search for all objects -* [#394](https://github.com/digitalocean/netbox/issues/394) - Improved VRF selection during bulk editing of prefixes and IP addresses +* [#176](https://github.com/netbox-community/netbox/issues/176) - Introduced seed data for new installs +* [#358](https://github.com/netbox-community/netbox/issues/358) - Improved search for all objects +* [#394](https://github.com/netbox-community/netbox/issues/394) - Improved VRF selection during bulk editing of prefixes and IP addresses * Miscellaneous cosmetic improvements to the UI ## Bug Fixes -* [#392](https://github.com/digitalocean/netbox/issues/392) - Don't include child devices in non-racked devices table -* [#397](https://github.com/digitalocean/netbox/issues/397) - Only include child IPs which belong to the same VRF as the parent prefix +* [#392](https://github.com/netbox-community/netbox/issues/392) - Don't include child devices in non-racked devices table +* [#397](https://github.com/netbox-community/netbox/issues/397) - Only include child IPs which belong to the same VRF as the parent prefix --- @@ -1932,18 +2237,18 @@ v1.3.2 (2016-07-26) ## Improvements -* [#292](https://github.com/digitalocean/netbox/issues/292) - Added part_number field to DeviceType -* [#363](https://github.com/digitalocean/netbox/issues/363) - Added a description field to the VLAN model -* [#374](https://github.com/digitalocean/netbox/issues/374) - Increased VLAN name length to 64 characters +* [#292](https://github.com/netbox-community/netbox/issues/292) - Added part_number field to DeviceType +* [#363](https://github.com/netbox-community/netbox/issues/363) - Added a description field to the VLAN model +* [#374](https://github.com/netbox-community/netbox/issues/374) - Increased VLAN name length to 64 characters * Enabled bulk deletion of interfaces from devices ## Bug Fixes -* [#359](https://github.com/digitalocean/netbox/issues/359) - Corrected the DCIM API endpoint for finding related connections -* [#370](https://github.com/digitalocean/netbox/issues/370) - Notify user when secret decryption fails -* [#381](https://github.com/digitalocean/netbox/issues/381) - Fix 'u_consumed' error on rack import -* [#384](https://github.com/digitalocean/netbox/issues/384) - Fixed description field's maximum length on IPAM bulk edit forms -* [#385](https://github.com/digitalocean/netbox/issues/385) - Fixed error when deleting a user with one or more associated UserActions +* [#359](https://github.com/netbox-community/netbox/issues/359) - Corrected the DCIM API endpoint for finding related connections +* [#370](https://github.com/netbox-community/netbox/issues/370) - Notify user when secret decryption fails +* [#381](https://github.com/netbox-community/netbox/issues/381) - Fix 'u_consumed' error on rack import +* [#384](https://github.com/netbox-community/netbox/issues/384) - Fixed description field's maximum length on IPAM bulk edit forms +* [#385](https://github.com/netbox-community/netbox/issues/385) - Fixed error when deleting a user with one or more associated UserActions --- @@ -1951,14 +2256,14 @@ v1.3.1 (2016-07-21) ## Improvements -* [#258](https://github.com/digitalocean/netbox/issues/258) - Add an API endpoint to list interface connections -* [#303](https://github.com/digitalocean/netbox/issues/303) - Improved numeric ordering of sites, racks, and devices -* [#304](https://github.com/digitalocean/netbox/issues/304) - Display utilization percentage on rack list -* [#327](https://github.com/digitalocean/netbox/issues/327) - Disable rack assignment for installed child devices +* [#258](https://github.com/netbox-community/netbox/issues/258) - Add an API endpoint to list interface connections +* [#303](https://github.com/netbox-community/netbox/issues/303) - Improved numeric ordering of sites, racks, and devices +* [#304](https://github.com/netbox-community/netbox/issues/304) - Display utilization percentage on rack list +* [#327](https://github.com/netbox-community/netbox/issues/327) - Disable rack assignment for installed child devices ## Bug Fixes -* [#331](https://github.com/digitalocean/netbox/issues/331) - Add group field to VLAN bulk edit form +* [#331](https://github.com/netbox-community/netbox/issues/331) - Add group field to VLAN bulk edit form * Miscellaneous improvements to Unicode handling --- @@ -1967,21 +2272,21 @@ v1.3.0 (2016-07-18) ## New Features -* [#42](https://github.com/digitalocean/netbox/issues/42) - Allow assignment of VLAN on prefix import -* [#43](https://github.com/digitalocean/netbox/issues/43) - Toggling of IP space uniqueness within a VRF -* [#111](https://github.com/digitalocean/netbox/issues/111) - Introduces VLAN groups -* [#227](https://github.com/digitalocean/netbox/issues/227) - Support for bulk import of child devices +* [#42](https://github.com/netbox-community/netbox/issues/42) - Allow assignment of VLAN on prefix import +* [#43](https://github.com/netbox-community/netbox/issues/43) - Toggling of IP space uniqueness within a VRF +* [#111](https://github.com/netbox-community/netbox/issues/111) - Introduces VLAN groups +* [#227](https://github.com/netbox-community/netbox/issues/227) - Support for bulk import of child devices ## Bug Fixes -* [#301](https://github.com/digitalocean/netbox/issues/301) - Prevent deletion of DeviceBay when installed device is deleted -* [#306](https://github.com/digitalocean/netbox/issues/306) - Fixed device import to allow an unspecified rack face -* [#307](https://github.com/digitalocean/netbox/issues/307) - Catch `RelatedObjectDoesNotExist` when an invalid device type is defined during device import -* [#308](https://github.com/digitalocean/netbox/issues/308) - Update rack assignment for all child devices when moving a parent device -* [#311](https://github.com/digitalocean/netbox/issues/311) - Fix assignment of primary_ip on IP address import -* [#317](https://github.com/digitalocean/netbox/issues/317) - Rack elevation display fix for device types greater than 42U in height -* [#320](https://github.com/digitalocean/netbox/issues/320) - Disallow import of prefixes with host masks -* [#322](https://github.com/digitalocean/netbox/issues/320) - Corrected VLAN import behavior +* [#301](https://github.com/netbox-community/netbox/issues/301) - Prevent deletion of DeviceBay when installed device is deleted +* [#306](https://github.com/netbox-community/netbox/issues/306) - Fixed device import to allow an unspecified rack face +* [#307](https://github.com/netbox-community/netbox/issues/307) - Catch `RelatedObjectDoesNotExist` when an invalid device type is defined during device import +* [#308](https://github.com/netbox-community/netbox/issues/308) - Update rack assignment for all child devices when moving a parent device +* [#311](https://github.com/netbox-community/netbox/issues/311) - Fix assignment of primary_ip on IP address import +* [#317](https://github.com/netbox-community/netbox/issues/317) - Rack elevation display fix for device types greater than 42U in height +* [#320](https://github.com/netbox-community/netbox/issues/320) - Disallow import of prefixes with host masks +* [#322](https://github.com/netbox-community/netbox/issues/320) - Corrected VLAN import behavior --- @@ -1989,15 +2294,15 @@ v1.2.2 (2016-07-14) ## Improvements -* [#174](https://github.com/digitalocean/netbox/issues/174) - Added search and site filter to provider list -* [#270](https://github.com/digitalocean/netbox/issues/270) - Added the ability to filter devices by rack group +* [#174](https://github.com/netbox-community/netbox/issues/174) - Added search and site filter to provider list +* [#270](https://github.com/netbox-community/netbox/issues/270) - Added the ability to filter devices by rack group ## Bug Fixes -* [#115](https://github.com/digitalocean/netbox/issues/115) - Fix deprecated django.core.context_processors reference -* [#268](https://github.com/digitalocean/netbox/issues/268) - Added support for entire 32-bit ASN space -* [#282](https://github.com/digitalocean/netbox/issues/282) - De-select "all" checkbox if one or more objects are deselected -* [#290](https://github.com/digitalocean/netbox/issues/290) - Always display management interfaces for a device type (even if `is_network_device` is not set) +* [#115](https://github.com/netbox-community/netbox/issues/115) - Fix deprecated django.core.context_processors reference +* [#268](https://github.com/netbox-community/netbox/issues/268) - Added support for entire 32-bit ASN space +* [#282](https://github.com/netbox-community/netbox/issues/282) - De-select "all" checkbox if one or more objects are deselected +* [#290](https://github.com/netbox-community/netbox/issues/290) - Always display management interfaces for a device type (even if `is_network_device` is not set) --- @@ -2007,14 +2312,14 @@ v1.2.1 (2016-07-13) ## Improvements -* [#285](https://github.com/digitalocean/netbox/issues/285) - Added the ability to prefer IPv4 over IPv6 for primary device IPs +* [#285](https://github.com/netbox-community/netbox/issues/285) - Added the ability to prefer IPv4 over IPv6 for primary device IPs ## Bug Fixes -* [#243](https://github.com/digitalocean/netbox/issues/243) - Improved ordering of device object lists -* [#271](https://github.com/digitalocean/netbox/issues/271) - Fixed primary_ip bug in secrets API -* [#274](https://github.com/digitalocean/netbox/issues/274) - Fixed primary_ip bug in DCIM admin UI -* [#275](https://github.com/digitalocean/netbox/issues/275) - Fixed bug preventing the expansion of an existing aggregate +* [#243](https://github.com/netbox-community/netbox/issues/243) - Improved ordering of device object lists +* [#271](https://github.com/netbox-community/netbox/issues/271) - Fixed primary_ip bug in secrets API +* [#274](https://github.com/netbox-community/netbox/issues/274) - Fixed primary_ip bug in DCIM admin UI +* [#275](https://github.com/netbox-community/netbox/issues/275) - Fixed bug preventing the expansion of an existing aggregate --- @@ -2022,16 +2327,16 @@ v1.2.0 (2016-07-12) ## New Features -* [#73](https://github.com/digitalocean/netbox/issues/73) - Added optional persistent banner -* [#93](https://github.com/digitalocean/netbox/issues/73) - Ability to set both IPv4 and IPv6 primary IPs for devices -* [#203](https://github.com/digitalocean/netbox/issues/203) - Introduced support for LDAP +* [#73](https://github.com/netbox-community/netbox/issues/73) - Added optional persistent banner +* [#93](https://github.com/netbox-community/netbox/issues/73) - Ability to set both IPv4 and IPv6 primary IPs for devices +* [#203](https://github.com/netbox-community/netbox/issues/203) - Introduced support for LDAP ## Bug Fixes -* [#162](https://github.com/digitalocean/netbox/issues/228) - Fixed support for Unicode characters in rack/device/VLAN names -* [#228](https://github.com/digitalocean/netbox/issues/228) - Corrected conditional inclusion of device bay templates -* [#246](https://github.com/digitalocean/netbox/issues/246) - Corrected Docker build instructions -* [#260](https://github.com/digitalocean/netbox/issues/260) - Fixed error on admin UI device type list +* [#162](https://github.com/netbox-community/netbox/issues/228) - Fixed support for Unicode characters in rack/device/VLAN names +* [#228](https://github.com/netbox-community/netbox/issues/228) - Corrected conditional inclusion of device bay templates +* [#246](https://github.com/netbox-community/netbox/issues/246) - Corrected Docker build instructions +* [#260](https://github.com/netbox-community/netbox/issues/260) - Fixed error on admin UI device type list * Miscellaneous layout improvements for mobile devices --- @@ -2040,36 +2345,36 @@ v1.1.0 (2016-07-07) ## New Features -* [#107](https://github.com/digitalocean/netbox/pull/107) - Docker support -* [#91](https://github.com/digitalocean/netbox/issues/91) - Support for subdevices within a device -* [#170](https://github.com/digitalocean/netbox/pull/170) - Added MAC address field to interfaces +* [#107](https://github.com/netbox-community/netbox/pull/107) - Docker support +* [#91](https://github.com/netbox-community/netbox/issues/91) - Support for subdevices within a device +* [#170](https://github.com/netbox-community/netbox/pull/170) - Added MAC address field to interfaces ## Bug Fixes -* [#169](https://github.com/digitalocean/netbox/issues/169) - Fix rendering of cancellation URL when editing objects -* [#183](https://github.com/digitalocean/netbox/issues/183) - Ignore vi swap files -* [#209](https://github.com/digitalocean/netbox/issues/209) - Corrected error when not confirming component template deletions -* [#214](https://github.com/digitalocean/netbox/issues/214) - Fixed redundant message on bulk interface creation -* [#68](https://github.com/digitalocean/netbox/issues/68) - Improved permissions-related error reporting for secrets +* [#169](https://github.com/netbox-community/netbox/issues/169) - Fix rendering of cancellation URL when editing objects +* [#183](https://github.com/netbox-community/netbox/issues/183) - Ignore vi swap files +* [#209](https://github.com/netbox-community/netbox/issues/209) - Corrected error when not confirming component template deletions +* [#214](https://github.com/netbox-community/netbox/issues/214) - Fixed redundant message on bulk interface creation +* [#68](https://github.com/netbox-community/netbox/issues/68) - Improved permissions-related error reporting for secrets --- v1.0.7-r1 (2016-07-05) -* [#199](https://github.com/digitalocean/netbox/issues/199) - Correct IP address validation +* [#199](https://github.com/netbox-community/netbox/issues/199) - Correct IP address validation --- v1.0.7 (2016-06-30) **Note:** If upgrading from a previous release, be sure to run ./upgrade.sh after downloading the new code. -* [#135](https://github.com/digitalocean/netbox/issues/135): Fixed display of navigation menu on mobile screens -* [#141](https://github.com/digitalocean/netbox/issues/141): Fixed rendering of "getting started" guide +* [#135](https://github.com/netbox-community/netbox/issues/135): Fixed display of navigation menu on mobile screens +* [#141](https://github.com/netbox-community/netbox/issues/141): Fixed rendering of "getting started" guide * Modified upgrade.sh to use sudo for pip installations -* [#109](https://github.com/digitalocean/netbox/issues/109): Hide the navigation menu from anonymous users if login is required -* [#143](https://github.com/digitalocean/netbox/issues/143): Add help_text to Device.position -* [#136](https://github.com/digitalocean/netbox/issues/136): Prefixes which have host bits set will trigger an error instead of being silently corrected -* [#140](https://github.com/digitalocean/netbox/issues/140): Improved support for Unicode in object names +* [#109](https://github.com/netbox-community/netbox/issues/109): Hide the navigation menu from anonymous users if login is required +* [#143](https://github.com/netbox-community/netbox/issues/143): Add help_text to Device.position +* [#136](https://github.com/netbox-community/netbox/issues/136): Prefixes which have host bits set will trigger an error instead of being silently corrected +* [#140](https://github.com/netbox-community/netbox/issues/140): Improved support for Unicode in object names --- diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 546e1de09..f27317deb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,11 +16,11 @@ For real-time discussion, you can join the #netbox Slack channel on [NetworkToCo ## Reporting Bugs -* First, ensure that you've installed the [latest stable version](https://github.com/digitalocean/netbox/releases) +* First, ensure that you've installed the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's possible that the bug has already been fixed. -* Next, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +* Next, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues) to see if the bug you've found has already been reported. If you think you may be experiencing a reported issue that hasn't already been resolved, please click "add a reaction" in the top right corner of the issue and add a thumbs @@ -51,7 +51,7 @@ your issue. ## Feature Requests -* First, check the GitHub [issues list](https://github.com/digitalocean/netbox/issues) +* First, check the GitHub [issues list](https://github.com/netbox-community/netbox/issues) to see if the feature you're requesting is already listed. (Be sure to search closed issues as well, since some feature requests have been rejected.) If the feature you'd like to see has already been requested and is open, click "add a diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..e6dc6408a --- /dev/null +++ b/NOTICE @@ -0,0 +1 @@ +Copyrighted and licensed under Apache License 2.0 by DigitalOcean, LLC. diff --git a/README.md b/README.md index 8b9df7f2c..04b2ff830 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ to address the needs of network and infrastructure engineers. NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a -complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/digitalocean/netbox). +complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). @@ -32,7 +32,7 @@ or join us in the #netbox Slack channel on [NetworkToCode](https://networktocode # Installation Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for -instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/digitalocean/netbox/releases) +instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) and run `upgrade.sh`. ## Alternative Installations diff --git a/base_requirements.txt b/base_requirements.txt index 3d1578400..f0f6cfe38 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -2,6 +2,10 @@ # https://github.com/django/django Django +# Django caching using Redis +# https://github.com/Suor/django-cacheops +django-cacheops + # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers django-cors-headers @@ -18,6 +22,14 @@ django-filter # https://github.com/django-mptt/django-mptt django-mptt +# Django integration for RQ (Reqis queuing) +# https://github.com/rq/django-rq +django-rq + +# Prometheus metrics library for Django +# https://github.com/korfuri/django-prometheus +django-prometheus + # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2 django-tables2 diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md new file mode 100644 index 000000000..32ac77cbf --- /dev/null +++ b/docs/additional-features/caching.md @@ -0,0 +1,21 @@ +# Caching + +To improve performance, NetBox supports caching for most object and list views. Caching is implemented using Redis, +and [django-cacheops](https://github.com/Suor/django-cacheops) + +Several management commands are avaliable for administrators to manaully invalidate cache entries in extenuating circumstances. + +To invalidate a specifc model instance (for example a Device with ID 34): +``` +python netbox/manage.py invalidate dcim.Device.34 +``` + +To invalidate all instance of a model: +``` +python netbox/manage.py invalidate dcim.Device +``` + +To flush the entire cache database: +``` +python netbox/manage.py invalidate all +``` diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md new file mode 100644 index 000000000..0aa944b74 --- /dev/null +++ b/docs/additional-features/prometheus-metrics.md @@ -0,0 +1,34 @@ +# Prometheus Metrics + +NetBox supports optionally exposing native Prometheus metrics from the application. [Prometheus](https://prometheus.io/) is a popular time series metric platform used for monitoring. + +NetBox exposes metrics at the `/metrics` HTTP endpoint, e.g. `https://netbox.local/metrics`. Metric exposition can be toggled with the `METRICS_ENABLED` configuration setting. Metrics are not exposed by default. + +## Metric Types + +NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-prometheus) library to export a number of different types of metrics, including: + +- Per model insert, update, and delete counters +- Per view request counters +- Per view request latency histograms +- Request body size histograms +- Response body size histograms +- Response code counters +- Database connection, execution, and error counters +- Cache hit, miss, and invalidation counters +- Django middleware latency histograms +- Other Django related metadata metrics + +For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on your NetBox instance. + +## Multi Processing Notes + +When deploying NetBox in a multiprocess mannor--such as using Gunicorn as recomented in the installation docs--the Prometheus client library requires the use of a shared directory +to collect metrics from all the worker processes. This can be any arbitrary directory to which the processes have read/write access. This directory is then made available by use of the +`prometheus_multiproc_dir` environment variable. + +This can be setup by first creating a shared directory and then adding this line (with the appropriate directory) to the `[program:netbox]` section of the supervisor config file. + +``` +environment=prometheus_multiproc_dir=/tmp/prometheus_metrics +``` diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 68f342e88..0e74640fa 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -4,14 +4,6 @@ A webhook defines an HTTP request that is sent to an external application when c 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. -## Installation - -If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below. - -* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages) -* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration) -* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation) - ## Requests The webhook POST request is structured as so (assuming `application/json` as the Content-Type): diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 08e11fe56..6dd686594 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -30,7 +30,7 @@ psql -c 'create database netbox' psql netbox < netbox.sql ``` -Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. +Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. ## Export the Database Schema diff --git a/docs/api/overview.md b/docs/api/overview.md index 00ff9c27e..e74a12371 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -104,24 +104,37 @@ The base serializer is used to represent the default view of a model. This inclu } ``` -Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model. +## Related Objects -When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object. +Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to display the object to a user. When performing write API actions (`POST`, `PUT`, and `PATCH`), related objects may be specified by either numeric ID (primary key), or by a set of attributes sufficiently unique to return the desired object. + +For example, when creating a new device, its rack can be specified by NetBox ID (PK): ``` { - "id": 1201, - "site": 7, - "group": 4, - "vid": 102, - "name": "Users-Floor2", - "tenant": null, - "status": 1, - "role": 9, - "description": "" + "name": "MyNewDevice", + "rack": 123, + ... } ``` +Or by a set of nested attributes used to identify the rack: + +``` +{ + "name": "MyNewDevice", + "rack": { + "site": { + "name": "Equinix DC6" + }, + "name": "R204" + }, + ... +} +``` + +Note that if the provided parameters do not return exactly one object, a validation error is raised. + ## Brief Format Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form. @@ -261,12 +274,31 @@ A list of objects retrieved via the API can be filtered by passing one or more q GET /api/ipam/prefixes/?status=1 ``` -Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: +The choices available for fixed choice fields such as `status` are exposed in the API under a special `_choices` endpoint for each NetBox app. For example, the available choices for `Prefix.status` are listed at `/api/ipam/_choices/` under the key `prefix:status`: ``` -GET /api/ipam/prefixes/?status=1&status=2 +"prefix:status": [ + { + "label": "Container", + "value": 0 + }, + { + "label": "Active", + "value": 1 + }, + { + "label": "Reserved", + "value": 2 + }, + { + "label": "Deprecated", + "value": 3 + } +], ``` +For most fields, when a filter is passed multiple times, objects matching _any_ of the provided values will be returned. For example, `GET /api/dcim/sites/?name=Foo&name=Bar` will return all sites named "Foo" _or_ "Bar". The exception to this rule is ManyToManyFields which may have multiple values assigned. Tags are the most common example of a ManyToManyField. For example, `GET /api/dcim/sites/?tag=foo&tag=bar` will return only sites tagged with both "foo" _and_ "bar". + ## Custom Fields To filter on a custom field, prepend `cf_` to the field name. For example, the following query will return only sites where a custom field named `foo` is equal to 123: diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index f8bd70e88..4ebb56290 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -44,6 +44,14 @@ BASE_PATH = 'netbox/' --- +## CACHE_TIMEOUT + +Default: 900 + +The number of seconds to retain cache entries before automatically invalidating them. + +--- + ## CHANGELOG_RETENTION Default: 90 @@ -64,7 +72,13 @@ If True, cross-origin resource sharing (CORS) requests will be accepted from all ## CORS_ORIGIN_REGEX_WHITELIST -These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) +These settings specify a list of origins that are authorized to make cross-site API requests. Use `CORS_ORIGIN_WHITELIST` to define a list of exact hostnames, or `CORS_ORIGIN_REGEX_WHITELIST` to define a set of regular expressions. (These settings have no effect if `CORS_ORIGIN_ALLOW_ALL` is True.) For example: + +``` +CORS_ORIGIN_WHITELIST = [ + 'https://example.com', +] +``` --- @@ -89,6 +103,30 @@ In order to send email, NetBox needs an email server configured. The following i --- +## EXEMPT_VIEW_PERMISSIONS + +Default: Empty list + +A list of models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users and by anonymous users. + +List models in the form `.`. For example: + +``` +EXEMPT_VIEW_PERMISSIONS = [ + 'dcim.site', + 'dcim.region', + 'ipam.prefix', +] +``` + +To exempt _all_ models from view permission enforcement, set the following. (Note that `EXEMPT_VIEW_PERMISSIONS` must be an iterable.) + +``` +EXEMPT_VIEW_PERMISSIONS = ['*'] +``` + +--- + # ENFORCE_GLOBAL_UNIQUE Default: False @@ -165,6 +203,14 @@ The file path to the location where media files (such as image attachments) are --- +## METRICS_ENABLED + +Default: False + +Toggle exposing Prometheus metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics/) documentation for more details. + +--- + ## NAPALM_USERNAME ## NAPALM_PASSWORD @@ -269,56 +315,3 @@ SHORT_TIME_FORMAT = 'H:i:s' # 13:23:00 DATETIME_FORMAT = 'N j, Y g:i a' # June 26, 2016 1:23 p.m. SHORT_DATETIME_FORMAT = 'Y-m-d H:i' # 2016-06-27 13:23 ``` - ---- - -## Redis Connection Settings - -[Redis](https://redis.io/) is a key-value store which functions as a very lightweight database. It is required when enabling NetBox [webhooks](../additional-features/webhooks/). A Redis connection is configured using a dictionary similar to the following: - -``` -REDIS = { - 'HOST': 'localhost', - 'PORT': 6379, - 'PASSWORD': '', - 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, - 'SSL': False, -} -``` - -### DATABASE - -Default: 0 - -The Redis database ID. - -### DEFAULT_TIMEOUT - -Default: 300 - -The timeout value to use when connecting to the Redis server (in seconds). - -### HOST - -Default: localhost - -The hostname or IP address of the Redis server. - -### PORT - -Default: 6379 - -The TCP port to use when connecting to the Redis server. - -### PASSWORD - -Default: None - -The password to use when authenticating to the Redis server (optional). - -### SSL - -Default: False - -Use secure sockets layer to encrypt the connections to the Redis server. diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index fb08e643b..9c3ea4d4f 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -43,3 +43,44 @@ This is a secret cryptographic key is used to improve the security of cookies an Please note that this key is **not** used for hashing user passwords or for the encrypted storage of secret data in NetBox. `SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `netbox/generate_secret_key.py` may be used to generate a suitable key. + +--- + +## REDIS + +[Redis](https://redis.io/) is an in-memory data store similar to memcached. While Redis has been an optional component of +NetBox since the introduction of webhooks in version 2.4, it is required starting in 2.6 to support NetBox's caching +functionality (as well as other planned features). + +Redis is configured using a configuration setting similar to `DATABASE`: + +* HOST - Name or IP address of the Redis server (use `localhost` if running locally) +* PORT - TCP port of the Redis service; leave blank for default port (6379) +* PASSWORD - Redis password (if set) +* DATABASE - Numeric database ID for webhooks +* CACHE_DATABASE - Numeric database ID for caching +* DEFAULT_TIMEOUT - Connection timeout in seconds +* SSL - Use SSL connection to Redis + +Example: + +``` +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'CACHE_DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, +} +``` + +!!! note: + If you were using these settings in a prior release with webhooks, the `DATABASE` setting remains the same but + an additional `CACHE_DATABASE` setting has been added with a default value of 1 to support the caching backend. The + `DATABASE` setting will be renamed in a future release of NetBox to better relay the meaning of the setting. + +!!! warning: + It is highly recommended to keep the webhook and cache databases seperate. Using the same database number for both may result in webhook + processing data being lost in cache flushing events. diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 176a70676..d170b374e 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -81,7 +81,7 @@ Power ports connect only to power outlets. Power connections can be marked as ei Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. Each type of connection can be classified as either *planned* or *connected*. -Each interface is a assigned a form factor denoting its physical properties. Two special form factors exist: the "virtual" form factor can be used to designate logical interfaces (such as SVIs), and the "LAG" form factor can be used to desinate link aggregation groups to which physical interfaces can be assigned. +Each interface is a assigned a type denoting its physical properties. Two special types exist: the "virtual" type can be used to designate logical interfaces (such as SVIs), and the "LAG" type can be used to desinate link aggregation groups to which physical interfaces can be assigned. Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Fields are also provided to store an interface's MTU and MAC address. @@ -95,7 +95,7 @@ Pass-through ports can also be used to model "bump in the wire" devices, such as ### Device Bays -Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations, but they are included in the "Non-Racked Devices" list within the rack view. +Device bays represent the ability of a device to house child devices. For example, you might install four blade servers into a 2U chassis. The chassis would appear in the rack elevation as a 2U device with four device bays. Each server within it would be defined as a 0U device installed in one of the device bays. Child devices do not appear within rack elevations or the "Non-Racked Devices" list within the rack view. Child devices are first-class Devices in their own right: that is, fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and interfaces. You cannot create a LAG between interfaces in different child devices. diff --git a/docs/development/index.md b/docs/development/index.md index 5830da765..88428562f 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -1,12 +1,12 @@ # NetBox Development -NetBox is maintained as a [GitHub project](https://github.com/digitalocean/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. +NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. ## Communication Communication among developers should always occur via public channels: -* [GitHub issues](https://github.com/digitalocean/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. +* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. * [The mailing list](https://groups.google.com/forum/#!forum/netbox-discuss) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. * [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 5e1f92fcc..14cecf2ec 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -29,6 +29,7 @@ Update the following static libraries to their most recent stable release: * Bootstrap 3 * Font Awesome 4 +* Select2 * jQuery * jQuery UI @@ -60,7 +61,7 @@ Once CI has completed on the PR, merge it. ## Create a New Release -Draft a [new release](https://github.com/digitalocean/netbox/releases/new) with the following parameters. +Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters. * **Tag:** Current version (e.g. `v2.3.4`) * **Target:** `master` diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 8941a4949..30894670e 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -1,18 +1,18 @@ # Installation -This section of the documentation discusses installing and configuring the NetBox application. +This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies: **Ubuntu** ```no-highlight -# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3 python3-pip python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev redis-server zlib1g-dev ``` **CentOS** ```no-highlight # yum install -y epel-release -# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config +# yum install -y gcc python36 python36-devel python36-setuptools libxml2-devel libxslt-devel libffi-devel graphviz openssl-devel redhat-rpm-config redis # easy_install-3.6 pip # ln -s /usr/bin/python36 /usr/bin/python3 ``` @@ -21,10 +21,10 @@ You may opt to install NetBox either from a numbered release or by cloning the m ## Option A: Download a Release -Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`. +Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox`. ```no-highlight -# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz +# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz # tar -xzf vX.Y.Z.tar.gz -C /opt # cd /opt/ # ln -s netbox-X.Y.Z/ netbox @@ -56,7 +56,7 @@ If `git` is not already installed, install it: Next, clone the **master** branch of the NetBox GitHub repository into the current directory: ```no-highlight -# git clone -b master https://github.com/digitalocean/netbox.git . +# git clone -b master https://github.com/netbox-community/netbox.git . Cloning into '.'... remote: Counting objects: 1994, done. remote: Compressing objects: 100% (150/150), done. @@ -90,28 +90,6 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` -## Webhooks (Optional) - -[Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one. - -**Ubuntu** - -```no-highlight -# apt-get install -y redis-server -``` - -**CentOS** - -```no-highlight -# yum install -y redis -``` - -Enabling webhooks also requires installing the [`django-rq`](https://github.com/ui/django-rq) package. This allows NetBox to use the Redis database as a queue for outgoing webhooks. - -```no-highlight -# pip3 install django-rq -``` - # Configuration Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. diff --git a/docs/installation/migrating-to-python3.md b/docs/installation/migrating-to-python3.md index 1d5ceb977..2c2c2b917 100644 --- a/docs/installation/migrating-to-python3.md +++ b/docs/installation/migrating-to-python3.md @@ -1,7 +1,7 @@ # Migration !!! warning - Beginning with v2.5, NetBox will no longer support Python 2. It is strongly recommended that you upgrade to Python 3 as soon as possible. + As of version 2.5, NetBox no longer supports Python 2. Python 3 is required to run any 2.5 release or later. ## Ubuntu @@ -36,9 +36,3 @@ If using LDAP authentication, install the `django-auth-ldap` package: ```no-highlight # pip3 install django-auth-ldap ``` - -If using Webhooks, install the `django-rq` package: - -```no-highlight -# pip3 install django-rq -``` diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 963dc0cb1..3de4b319b 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -4,12 +4,12 @@ As with the initial installation, you can upgrade NetBox by either downloading t ## Option A: Download a Release -Download the [latest stable release](https://github.com/digitalocean/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`. +Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive. Extract it to your desired path. In this example, we'll use `/opt/netbox`. Download and extract the latest version: ```no-highlight -# wget https://github.com/digitalocean/netbox/archive/vX.Y.Z.tar.gz +# wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz # tar -xzf vX.Y.Z.tar.gz -C /opt # cd /opt/ # ln -sfn netbox-X.Y.Z/ netbox diff --git a/mkdocs.yml b/mkdocs.yml index a0185e56e..99f77d06c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,6 +1,6 @@ site_name: NetBox theme: readthedocs -repo_url: https://github.com/digitalocean/netbox +repo_url: https://github.com/netbox-community/netbox pages: - Introduction: 'index.md' @@ -36,6 +36,8 @@ pages: - Reports: 'additional-features/reports.md' - Webhooks: 'additional-features/webhooks.md' - Change Logging: 'additional-features/change-logging.md' + - Caching: 'additional-features/caching.md' + - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Administration: - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 211dc4007..067b82282 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -17,10 +17,11 @@ __all__ = [ class NestedProviderSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'circuit_count'] # @@ -29,10 +30,11 @@ class NestedProviderSerializer(WritableNestedSerializer): class NestedCircuitTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'circuit_count'] class NestedCircuitSerializer(WritableNestedSerializer): diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index e94875c21..39a0b6b26 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,3 +1,4 @@ +from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES @@ -16,12 +17,13 @@ from .nested_serializers import * class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): tags = TagListSerializerField(required=False) + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ 'id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', + 'custom_fields', 'created', 'last_updated', 'circuit_count', ] @@ -30,10 +32,11 @@ class ProviderSerializer(TaggitSerializer, CustomFieldModelSerializer): # class CircuitTypeSerializer(ValidatedModelSerializer): + circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'circuit_count'] class CircuitSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 877d85f85..ad48174e6 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -1,3 +1,4 @@ +from django.db.models import Count from django.shortcuts import get_object_or_404 from rest_framework.decorators import action from rest_framework.response import Response @@ -27,7 +28,9 @@ class CircuitsFieldChoicesViewSet(FieldChoicesViewSet): # class ProviderViewSet(CustomFieldModelViewSet): - queryset = Provider.objects.prefetch_related('tags') + queryset = Provider.objects.prefetch_related('tags').annotate( + circuit_count=Count('circuits') + ) serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilter @@ -47,7 +50,9 @@ class ProviderViewSet(CustomFieldModelViewSet): # class CircuitTypeViewSet(ModelViewSet): - queryset = CircuitType.objects.all() + queryset = CircuitType.objects.annotate( + circuit_count=Count('circuits') + ) serializer_class = serializers.CircuitTypeSerializer filterset_class = filters.CircuitTypeFilter diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 12955eeca..4323feafc 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -3,13 +3,13 @@ from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from .constants import CIRCUIT_STATUS_CHOICES from .models import Provider, Circuit, CircuitTermination, CircuitType -class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): +class ProviderFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -51,10 +51,10 @@ class CircuitTypeFilter(NameSlugSearchFilterSet): class Meta: model = CircuitType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): +class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -87,16 +87,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=CIRCUIT_STATUS_CHOICES, null_value=None ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site', queryset=Site.objects.all(), diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4deee57c9..100c6334f 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -4,6 +4,7 @@ from taggit.forms import TagField from dcim.models import Site from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, @@ -265,8 +266,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ] -class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): +class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit + field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate'] q = forms.CharField( required=False, label='Search' @@ -292,16 +294,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', diff --git a/netbox/circuits/migrations/0015_custom_tag_models.py b/netbox/circuits/migrations/0015_custom_tag_models.py new file mode 100644 index 000000000..11bde72ff --- /dev/null +++ b/netbox/circuits/migrations/0015_custom_tag_models.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0014_circuittermination_description'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='provider', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index b558d5007..0297790b5 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -6,7 +6,7 @@ from taggit.managers import TaggableManager from dcim.constants import CONNECTION_STATUS_CHOICES, STATUS_CLASSES from dcim.fields import ASNField from dcim.models import CableTermination -from extras.models import CustomFieldModel, ObjectChange +from extras.models import CustomFieldModel, ObjectChange, TaggedItem from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .constants import CIRCUIT_STATUS_ACTIVE, CIRCUIT_STATUS_CHOICES, TERM_SIDE_CHOICES @@ -55,7 +55,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] @@ -165,7 +165,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -274,11 +274,16 @@ class CircuitTermination(CableTermination): """ Reference the parent circuit when recording the change. """ + try: + related_object = self.circuit + except Circuit.DoesNotExist: + # Parent circuit has been deleted + related_object = None ObjectChange( user=user, request_id=request_id, changed_object=self, - related_object=self.circuit, + related_object=related_object, action=action, object_data=serialize_object(self) ).save() diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 60b6a7f7c..2bb442179 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -20,15 +20,6 @@ STATUS_LABEL = """ """ -class CircuitTerminationColumn(tables.Column): - - def render(self, value): - return mark_safe('{}'.format( - value.site.get_absolute_url(), - value.site - )) - - # # Providers # @@ -77,9 +68,13 @@ class CircuitTable(BaseTable): provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')]) status = tables.TemplateColumn(template_code=STATUS_LABEL, verbose_name='Status') tenant = tables.TemplateColumn(template_code=COL_TENANT) - termination_a = CircuitTerminationColumn(orderable=False, verbose_name='A Side') - termination_z = CircuitTerminationColumn(orderable=False, verbose_name='Z Side') + a_side = tables.Column( + verbose_name='A Side' + ) + z_side = tables.Column( + verbose_name='Z Side' + ) class Meta(BaseTable.Meta): model = Circuit - fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'termination_a', 'termination_z', 'description') + fields = ('pk', 'cid', 'status', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 0810f0ff9..e53c2c402 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -61,7 +61,7 @@ class ProviderTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['circuit_count', 'id', 'name', 'slug', 'url'] ) def test_create_provider(self): @@ -162,7 +162,7 @@ class CircuitTypeTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['circuit_count', 'id', 'name', 'slug', 'url'] ) def test_create_circuittype(self): diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 65ae6d7db..cb0ea0a32 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -4,13 +4,15 @@ from django.test import Client, TestCase from django.urls import reverse from circuits.models import Circuit, CircuitType, Provider +from utilities.testing import create_test_user class ProviderTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_provider']) self.client = Client() + self.client.force_login(user) Provider.objects.bulk_create([ Provider(name='Provider 1', slug='provider-1', asn=65001), @@ -38,8 +40,9 @@ class ProviderTestCase(TestCase): class CircuitTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_circuittype']) self.client = Client() + self.client.force_login(user) CircuitType.objects.bulk_create([ CircuitType(name='Circuit Type 1', slug='circuit-type-1'), @@ -58,8 +61,9 @@ class CircuitTypeTestCase(TestCase): class CircuitTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['circuits.view_circuit']) self.client = Client() + self.client.force_login(user) provider = Provider(name='Provider 1', slug='provider-1', asn=65001) provider.save() @@ -84,8 +88,8 @@ class CircuitTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) self.assertEqual(response.status_code, 200) - def test_provider(self): + def test_circuit(self): - provider = Provider.objects.first() - response = self.client.get(provider.get_absolute_url()) + circuit = Circuit.objects.first() + response = self.client.get(circuit.get_absolute_url()) self.assertEqual(response.status_code, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index be1106308..c142a831a 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from dcim.views import CableCreateView, CableTraceView from extras.views import ObjectChangeLogView @@ -9,41 +9,42 @@ app_name = 'circuits' urlpatterns = [ # Providers - url(r'^providers/$', views.ProviderListView.as_view(), name='provider_list'), - url(r'^providers/add/$', views.ProviderCreateView.as_view(), name='provider_add'), - url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'), - url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), - url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - url(r'^providers/(?P[\w-]+)/$', views.ProviderView.as_view(), name='provider'), - url(r'^providers/(?P[\w-]+)/edit/$', views.ProviderEditView.as_view(), name='provider_edit'), - url(r'^providers/(?P[\w-]+)/delete/$', views.ProviderDeleteView.as_view(), name='provider_delete'), - url(r'^providers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path(r'providers/', views.ProviderListView.as_view(), name='provider_list'), + path(r'providers/add/', views.ProviderCreateView.as_view(), name='provider_add'), + path(r'providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), + path(r'providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), + path(r'providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), + path(r'providers//', views.ProviderView.as_view(), name='provider'), + path(r'providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path(r'providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path(r'providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), # Circuit types - url(r'^circuit-types/$', views.CircuitTypeListView.as_view(), name='circuittype_list'), - url(r'^circuit-types/add/$', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), - url(r'^circuit-types/import/$', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), - url(r'^circuit-types/delete/$', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - url(r'^circuit-types/(?P[\w-]+)/edit/$', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - url(r'^circuit-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path(r'circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), + path(r'circuit-types/add/', views.CircuitTypeCreateView.as_view(), name='circuittype_add'), + path(r'circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path(r'circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), + path(r'circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path(r'circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits - url(r'^circuits/$', views.CircuitListView.as_view(), name='circuit_list'), - url(r'^circuits/add/$', views.CircuitCreateView.as_view(), name='circuit_add'), - url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'), - url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), - url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), - url(r'^circuits/(?P\d+)/$', views.CircuitView.as_view(), name='circuit'), - url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), - url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), - url(r'^circuits/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), - url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), + path(r'circuits/', views.CircuitListView.as_view(), name='circuit_list'), + path(r'circuits/add/', views.CircuitCreateView.as_view(), name='circuit_add'), + path(r'circuits/import/', views.CircuitBulkImportView.as_view(), name='circuit_import'), + path(r'circuits/edit/', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), + path(r'circuits/delete/', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), + path(r'circuits//', views.CircuitView.as_view(), name='circuit'), + path(r'circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), + path(r'circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), + path(r'circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path(r'circuits//terminations/swap/', views.circuit_terminations_swap, name='circuit_terminations_swap'), # Circuit terminations - url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), - url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), - url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - url(r'^circuit-terminations/(?P\d+)/connect/$', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), - url(r'^circuit-terminations/(?P\d+)/trace/$', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), + + path(r'circuits//terminations/add/', views.CircuitTerminationCreateView.as_view(), name='circuittermination_add'), + path(r'circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + path(r'circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), + path(r'circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), + path(r'circuit-terminations//trace/', CableTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 661f78e8e..2f3881818 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -2,7 +2,7 @@ from django.contrib import messages from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.db import transaction -from django.db.models import Count +from django.db.models import Count, OuterRef, Subquery from django.shortcuts import get_object_or_404, redirect, render from django.views.generic import View @@ -20,7 +20,8 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider # Providers # -class ProviderListView(ObjectListView): +class ProviderListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_provider' queryset = Provider.objects.annotate(count_circuits=Count('circuits')) filter = filters.ProviderFilter filter_form = forms.ProviderFilterForm @@ -28,7 +29,8 @@ class ProviderListView(ObjectListView): template_name = 'circuits/provider_list.html' -class ProviderView(View): +class ProviderView(PermissionRequiredMixin, View): + permission_required = 'circuits.view_provider' def get(self, request, slug): @@ -93,7 +95,8 @@ class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuit Types # -class CircuitTypeListView(ObjectListView): +class CircuitTypeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_circuittype' queryset = CircuitType.objects.annotate(circuit_count=Count('circuits')) table = tables.CircuitTypeTable template_name = 'circuits/circuittype_list.html' @@ -128,11 +131,16 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Circuits # -class CircuitListView(ObjectListView): +class CircuitListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'circuits.view_circuit' + _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) queryset = Circuit.objects.select_related( 'provider', 'type', 'tenant' ).prefetch_related( 'terminations__site' + ).annotate( + a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), + z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), ) filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm @@ -140,7 +148,8 @@ class CircuitListView(ObjectListView): template_name = 'circuits/circuit_list.html' -class CircuitView(View): +class CircuitView(PermissionRequiredMixin, View): + permission_required = 'circuits.view_circuit' def get(self, request, pk): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index e53259e94..cf22916ad 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, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, RearPort, RearPortTemplate, - Region, Site, VirtualChassis, + Interface, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerPanel, PowerPort, Rack, RackGroup, RackRole, + RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) from utilities.api import ChoiceField, WritableNestedSerializer @@ -21,7 +21,9 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedManufacturerSerializer', 'NestedPlatformSerializer', + 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', + 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedRackGroupSerializer', 'NestedRackRoleSerializer', @@ -40,10 +42,11 @@ __all__ = [ class NestedRegionSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') + site_count = serializers.IntegerField(read_only=True) class Meta: model = Region - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'site_count'] class NestedSiteSerializer(WritableNestedSerializer): @@ -60,26 +63,29 @@ class NestedSiteSerializer(WritableNestedSerializer): class NestedRackGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'rack_count'] class NestedRackRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'rack_count'] class NestedRackSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') + device_count = serializers.IntegerField(read_only=True) class Meta: model = Rack - fields = ['id', 'url', 'name', 'display_name'] + fields = ['id', 'url', 'name', 'display_name', 'device_count'] # @@ -88,19 +94,21 @@ class NestedRackSerializer(WritableNestedSerializer): class NestedManufacturerSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') + devicetype_count = serializers.IntegerField(read_only=True) class Meta: model = Manufacturer - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] class NestedDeviceTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer(read_only=True) + device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name'] + fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] class NestedRearPortTemplateSerializer(WritableNestedSerializer): @@ -125,18 +133,22 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): class NestedDeviceRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedPlatformSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Platform - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedDeviceSerializer(WritableNestedSerializer): @@ -243,7 +255,29 @@ class NestedCableSerializer(serializers.ModelSerializer): class NestedVirtualChassisSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer() + member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'url', 'master'] + fields = ['id', 'url', 'master', 'member_count'] + + +# +# Power panels/feeds +# + +class NestedPowerPanelSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') + powerfeed_count = serializers.IntegerField(read_only=True) + + class Meta: + model = PowerPanel + fields = ['id', 'url', 'name', 'powerfeed_count'] + + +class NestedPowerFeedSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') + + class Meta: + model = PowerFeed + fields = ['id', 'url', 'name'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d8bf68e12..e9526fa41 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,4 +1,5 @@ from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField @@ -7,8 +8,9 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) from extras.api.customfields import CustomFieldModelSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -36,6 +38,7 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): ) return None + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_connected_endpoint(self, obj): """ Return the appropriate serializer for the type of connected object. @@ -56,10 +59,11 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): class RegionSerializer(serializers.ModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) class Meta: model = Region - fields = ['id', 'name', 'slug', 'parent'] + fields = ['id', 'name', 'slug', 'parent', 'site_count'] class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -68,19 +72,20 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) time_zone = TimeZoneField(required=False) tags = TagListSerializerField(required=False) - count_prefixes = serializers.IntegerField(read_only=True) - count_vlans = serializers.IntegerField(read_only=True) - count_racks = serializers.IntegerField(read_only=True) - count_devices = serializers.IntegerField(read_only=True) - count_circuits = serializers.IntegerField(read_only=True) + circuit_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) + prefix_count = serializers.IntegerField(read_only=True) + rack_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', - 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', + 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', ] @@ -90,17 +95,19 @@ class SiteSerializer(TaggitSerializer, CustomFieldModelSerializer): class RackGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer() + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site', 'rack_count'] class RackRoleSerializer(ValidatedModelSerializer): + rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color', 'rack_count'] class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -113,13 +120,15 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): width = ChoiceField(choices=RACK_WIDTH_CHOICES, required=False) outer_unit = ChoiceField(choices=RACK_DIMENSION_UNIT_CHOICES, required=False) tags = TagListSerializerField(required=False) + device_count = serializers.IntegerField(read_only=True) + powerfeed_count = serializers.IntegerField(read_only=True) class Meta: model = Rack fields = [ 'id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', ] # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This # prevents facility_id from being interpreted as a required field. @@ -166,23 +175,26 @@ class RackReservationSerializer(ValidatedModelSerializer): # class ManufacturerSerializer(ValidatedModelSerializer): + devicetype_count = serializers.IntegerField(read_only=True) + inventoryitem_count = serializers.IntegerField(read_only=True) + platform_count = serializers.IntegerField(read_only=True) class Meta: model = Manufacturer - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'devicetype_count', 'inventoryitem_count', 'platform_count'] class DeviceTypeSerializer(TaggitSerializer, CustomFieldModelSerializer): manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SUBDEVICE_ROLE_CHOICES, required=False, allow_null=True) - instance_count = serializers.IntegerField(source='instances.count', read_only=True) tags = TagListSerializerField(required=False) + device_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceType fields = [ 'id', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'instance_count', + 'subdevice_role', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', ] @@ -207,24 +219,34 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() + power_port = PowerPortTemplateSerializer( + required=False + ) + feed_leg = ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + allow_null=True + ) class Meta: model = PowerOutletTemplate - fields = ['id', 'device_type', 'name'] + fields = ['id', 'device_type', 'name', 'power_port', 'feed_leg'] class InterfaceTemplateSerializer(ValidatedModelSerializer): device_type = NestedDeviceTypeSerializer() - form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) + type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) + # TODO: Remove in v2.7 (backward-compatibility for form_factor) + form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) class Meta: model = InterfaceTemplate - fields = ['id', 'device_type', 'name', 'form_factor', 'mgmt_only'] + fields = ['id', 'device_type', 'name', 'type', 'form_factor', 'mgmt_only'] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -259,18 +281,25 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): # class DeviceRoleSerializer(ValidatedModelSerializer): + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = DeviceRole - fields = ['id', 'name', 'slug', 'color', 'vm_role'] + fields = ['id', 'name', 'slug', 'color', 'vm_role', 'device_count', 'virtualmachine_count'] class PlatformSerializer(ValidatedModelSerializer): manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Platform - fields = ['id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args'] + fields = [ + 'id', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'device_count', + 'virtualmachine_count', + ] class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -313,6 +342,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): return data + @swagger_serializer_method(serializer_or_field=NestedDeviceSerializer) def get_parent_device(self, obj): try: device_bay = obj.parent_bay @@ -335,6 +365,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 'custom_fields', 'config_context', 'created', 'last_updated', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_config_context(self, obj): return obj.get_config_context() @@ -347,8 +378,8 @@ class ConsoleServerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer) class Meta: model = ConsoleServerPort fields = [ - 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', - 'tags', + 'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', + 'cable', 'tags', ] @@ -360,21 +391,33 @@ class ConsolePortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = ConsolePort fields = [ - 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', - 'tags', + 'id', 'device', 'name', 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', + 'cable', 'tags', ] class PowerOutletSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() - cable = NestedCableSerializer(read_only=True) - tags = TagListSerializerField(required=False) + power_port = NestedPowerPortSerializer( + required=False + ) + feed_leg = ChoiceField( + choices=POWERFEED_LEG_CHOICES, + required=False, + allow_null=True + ) + cable = NestedCableSerializer( + read_only=True + ) + tags = TagListSerializerField( + required=False + ) class Meta: model = PowerOutlet fields = [ - 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', - 'tags', + 'id', 'device', 'name', 'power_port', 'feed_leg', 'description', 'connected_endpoint_type', + 'connected_endpoint', 'connection_status', 'cable', 'tags', ] @@ -386,14 +429,16 @@ class PowerPortSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = PowerPort fields = [ - 'id', 'device', 'name', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', - 'tags', + 'id', 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connected_endpoint_type', + 'connected_endpoint', 'connection_status', 'cable', 'tags', ] class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): device = NestedDeviceSerializer() - form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) + type = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) + # TODO: Remove in v2.7 (backward-compatibility for form_factor) + form_factor = ChoiceField(choices=IFACE_TYPE_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -409,9 +454,9 @@ class InterfaceSerializer(TaggitSerializer, ConnectedEndpointSerializer): class Meta: model = Interface fields = [ - 'id', 'device', 'name', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', 'description', - 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', 'untagged_vlan', - 'tagged_vlans', 'tags', 'count_ipaddresses', + 'id', 'device', 'name', 'type', 'form_factor', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', + 'description', 'connected_endpoint_type', 'connected_endpoint', 'connection_status', 'cable', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses', ] # TODO: This validation should be handled by Interface.clean() @@ -476,7 +521,7 @@ class DeviceBaySerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = DeviceBay - fields = ['id', 'device', 'name', 'installed_device', 'tags'] + fields = ['id', 'device', 'name', 'description', 'installed_device', 'tags'] # @@ -536,9 +581,11 @@ class CableSerializer(ValidatedModelSerializer): return data + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_termination_a(self, obj): return self._get_termination(obj, 'a') + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_termination_b(self, obj): return self._get_termination(obj, 'b') @@ -569,6 +616,7 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): model = Interface fields = ['interface_a', 'interface_b', 'connection_status'] + @swagger_serializer_method(serializer_or_field=NestedInterfaceSerializer) def get_interface_a(self, obj): context = {'request': self.context['request']} return NestedInterfaceSerializer(instance=obj, context=context).data @@ -581,7 +629,61 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): class VirtualChassisSerializer(TaggitSerializer, ValidatedModelSerializer): master = NestedDeviceSerializer() tags = TagListSerializerField(required=False) + member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'master', 'domain', 'tags'] + fields = ['id', 'master', 'domain', 'tags', 'member_count'] + + +# +# Power panels +# + +class PowerPanelSerializer(ValidatedModelSerializer): + site = NestedSiteSerializer() + rack_group = NestedRackGroupSerializer( + required=False, + allow_null=True, + default=None + ) + powerfeed_count = serializers.IntegerField(read_only=True) + + class Meta: + model = PowerPanel + fields = ['id', 'site', 'rack_group', 'name', 'powerfeed_count'] + + +class PowerFeedSerializer(TaggitSerializer, CustomFieldModelSerializer): + power_panel = NestedPowerPanelSerializer() + rack = NestedRackSerializer( + required=False, + allow_null=True, + default=None + ) + type = ChoiceField( + choices=POWERFEED_TYPE_CHOICES, + default=POWERFEED_TYPE_PRIMARY + ) + status = ChoiceField( + choices=POWERFEED_STATUS_CHOICES, + default=POWERFEED_STATUS_ACTIVE + ) + supply = ChoiceField( + choices=POWERFEED_SUPPLY_CHOICES, + default=POWERFEED_SUPPLY_AC + ) + phase = ChoiceField( + choices=POWERFEED_PHASE_CHOICES, + default=POWERFEED_PHASE_SINGLE + ) + tags = TagListSerializerField( + required=False + ) + + class Meta: + model = PowerFeed + fields = [ + 'id', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 006a61bad..fd55d9b05 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -68,6 +68,10 @@ router.register(r'cables', views.CableViewSet) # Virtual chassis router.register(r'virtual-chassis', views.VirtualChassisViewSet) +# Power +router.register(r'power-panels', views.PowerPanelViewSet) +router.register(r'power-feeds', views.PowerFeedViewSet) + # Miscellaneous router.register(r'connected-device', views.ConnectedDeviceViewSet, basename='connected-device') diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8964e7fcb..af5ccae4a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,7 @@ from collections import OrderedDict from django.conf import settings -from django.db.models import F +from django.db.models import Count, F from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -12,19 +12,24 @@ from rest_framework.mixins import ListModelMixin from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ViewSet +from circuits.models import Circuit from dcim import filters from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) from extras.api.serializers import RenderedGraphSerializer from extras.api.views import CustomFieldModelViewSet from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) +from utilities.utils import get_subquery +from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -41,8 +46,10 @@ class DCIMFieldChoicesViewSet(FieldChoicesViewSet): (DeviceType, ['subdevice_role']), (FrontPort, ['type']), (FrontPortTemplate, ['type']), - (Interface, ['form_factor', 'mode']), - (InterfaceTemplate, ['form_factor']), + (Interface, ['type', 'mode']), + (InterfaceTemplate, ['type']), + (PowerOutlet, ['feed_leg']), + (PowerOutletTemplate, ['feed_leg']), (PowerPort, ['connection_status']), (Rack, ['outer_unit', 'status', 'type', 'width']), (RearPort, ['type']), @@ -90,7 +97,9 @@ class CableTraceMixin(object): # class RegionViewSet(ModelViewSet): - queryset = Region.objects.all() + queryset = Region.objects.annotate( + site_count=Count('sites') + ) serializer_class = serializers.RegionSerializer filterset_class = filters.RegionFilter @@ -100,7 +109,18 @@ class RegionViewSet(ModelViewSet): # class SiteViewSet(CustomFieldModelViewSet): - queryset = Site.objects.select_related('region', 'tenant').prefetch_related('tags') + queryset = Site.objects.select_related( + 'region', 'tenant' + ).prefetch_related( + 'tags' + ).annotate( + device_count=get_subquery(Device, 'site'), + rack_count=get_subquery(Rack, 'site'), + prefix_count=get_subquery(Prefix, 'site'), + vlan_count=get_subquery(VLAN, 'site'), + circuit_count=get_subquery(Circuit, 'terminations__site'), + virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'), + ) serializer_class = serializers.SiteSerializer filterset_class = filters.SiteFilter @@ -120,7 +140,9 @@ class SiteViewSet(CustomFieldModelViewSet): # class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.select_related('site') + queryset = RackGroup.objects.select_related('site').annotate( + rack_count=Count('racks') + ) serializer_class = serializers.RackGroupSerializer filterset_class = filters.RackGroupFilter @@ -130,7 +152,9 @@ class RackGroupViewSet(ModelViewSet): # class RackRoleViewSet(ModelViewSet): - queryset = RackRole.objects.all() + queryset = RackRole.objects.annotate( + rack_count=Count('racks') + ) serializer_class = serializers.RackRoleSerializer filterset_class = filters.RackRoleFilter @@ -140,7 +164,14 @@ class RackRoleViewSet(ModelViewSet): # class RackViewSet(CustomFieldModelViewSet): - queryset = Rack.objects.select_related('site', 'group__site', 'tenant').prefetch_related('tags') + queryset = Rack.objects.select_related( + 'site', 'group__site', 'role', 'tenant' + ).prefetch_related( + 'tags' + ).annotate( + device_count=get_subquery(Device, 'rack'), + powerfeed_count=get_subquery(PowerFeed, 'rack') + ) serializer_class = serializers.RackSerializer filterset_class = filters.RackFilter @@ -189,7 +220,11 @@ class RackReservationViewSet(ModelViewSet): # class ManufacturerViewSet(ModelViewSet): - queryset = Manufacturer.objects.all() + queryset = Manufacturer.objects.annotate( + devicetype_count=get_subquery(DeviceType, 'manufacturer'), + inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), + platform_count=get_subquery(Platform, 'manufacturer') + ) serializer_class = serializers.ManufacturerSerializer filterset_class = filters.ManufacturerFilter @@ -199,7 +234,9 @@ class ManufacturerViewSet(ModelViewSet): # class DeviceTypeViewSet(CustomFieldModelViewSet): - queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags') + queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('tags').annotate( + device_count=Count('instances') + ) serializer_class = serializers.DeviceTypeSerializer filterset_class = filters.DeviceTypeFilter @@ -261,7 +298,10 @@ class DeviceBayTemplateViewSet(ModelViewSet): # class DeviceRoleViewSet(ModelViewSet): - queryset = DeviceRole.objects.all() + queryset = DeviceRole.objects.annotate( + device_count=get_subquery(Device, 'device_role'), + virtualmachine_count=get_subquery(VirtualMachine, 'role') + ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilter @@ -271,7 +311,10 @@ class DeviceRoleViewSet(ModelViewSet): # class PlatformViewSet(ModelViewSet): - queryset = Platform.objects.all() + queryset = Platform.objects.annotate( + device_count=get_subquery(Device, 'platform'), + virtualmachine_count=get_subquery(VirtualMachine, 'platform') + ) serializer_class = serializers.PlatformSerializer filterset_class = filters.PlatformFilter @@ -291,16 +334,23 @@ class DeviceViewSet(CustomFieldModelViewSet): def get_serializer_class(self): """ - Include rendered config context when retrieving a single Device. + Select the specific serializer based on the request context. + + If the `brief` query param equates to True, return the NestedDeviceSerializer + + If the `exclude` query param includes `config_context` as a value, return the DeviceSerializer + + Else, return the DeviceWithConfigContextSerializer """ - if self.action == 'retrieve': - return serializers.DeviceWithConfigContextSerializer request = self.get_serializer_context()['request'] if request.query_params.get('brief', False): return serializers.NestedDeviceSerializer - return serializers.DeviceSerializer + elif 'config_context' in request.query_params.get('exclude', []): + return serializers.DeviceSerializer + + return serializers.DeviceWithConfigContextSerializer @action(detail=True, url_path='napalm') def napalm(self, request, pk): @@ -400,7 +450,7 @@ class ConsoleServerPortViewSet(CableTraceMixin, ModelViewSet): class PowerPortViewSet(CableTraceMixin, ModelViewSet): queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device', 'cable' + 'device', '_connected_poweroutlet__device', '_connected_powerfeed', 'cable' ).prefetch_related( 'tags' ) @@ -490,7 +540,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet): queryset = PowerPort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerConnectionFilter @@ -525,8 +575,39 @@ class CableViewSet(ModelViewSet): # class VirtualChassisViewSet(ModelViewSet): - queryset = VirtualChassis.objects.prefetch_related('tags') + queryset = VirtualChassis.objects.prefetch_related('tags').annotate( + member_count=Count('members') + ) serializer_class = serializers.VirtualChassisSerializer + filterset_class = filters.VirtualChassisFilter + + +# +# Power panels +# + +class PowerPanelViewSet(ModelViewSet): + queryset = PowerPanel.objects.select_related( + 'site', 'rack_group' + ).annotate( + powerfeed_count=Count('powerfeeds') + ) + serializer_class = serializers.PowerPanelSerializer + filterset_class = filters.PowerPanelFilter + + +# +# Power feeds +# + +class PowerFeedViewSet(CustomFieldModelViewSet): + queryset = PowerFeed.objects.select_related( + 'power_panel', 'rack' + ).prefetch_related( + 'tags' + ) + serializer_class = serializers.PowerFeedSerializer + filterset_class = filters.PowerFeedFilter # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 926b97130..58df29914 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -66,200 +66,204 @@ IFACE_ORDERING_CHOICES = [ [IFACE_ORDERING_NAME, 'Name (alphabetically)'] ] -# Interface form factors +# Interface types # Virtual -IFACE_FF_VIRTUAL = 0 -IFACE_FF_LAG = 200 +IFACE_TYPE_VIRTUAL = 0 +IFACE_TYPE_LAG = 200 # Ethernet -IFACE_FF_100ME_FIXED = 800 -IFACE_FF_1GE_FIXED = 1000 -IFACE_FF_1GE_GBIC = 1050 -IFACE_FF_1GE_SFP = 1100 -IFACE_FF_10GE_FIXED = 1150 -IFACE_FF_10GE_CX4 = 1170 -IFACE_FF_10GE_SFP_PLUS = 1200 -IFACE_FF_10GE_XFP = 1300 -IFACE_FF_10GE_XENPAK = 1310 -IFACE_FF_10GE_X2 = 1320 -IFACE_FF_25GE_SFP28 = 1350 -IFACE_FF_40GE_QSFP_PLUS = 1400 -IFACE_FF_50GE_QSFP28 = 1420 -IFACE_FF_100GE_CFP = 1500 -IFACE_FF_100GE_CFP2 = 1510 -IFACE_FF_100GE_CFP4 = 1520 -IFACE_FF_100GE_CPAK = 1550 -IFACE_FF_100GE_QSFP28 = 1600 -IFACE_FF_200GE_CFP2 = 1650 -IFACE_FF_200GE_QSFP56 = 1700 -IFACE_FF_400GE_QSFP_DD = 1750 +IFACE_TYPE_100ME_FIXED = 800 +IFACE_TYPE_1GE_FIXED = 1000 +IFACE_TYPE_1GE_GBIC = 1050 +IFACE_TYPE_1GE_SFP = 1100 +IFACE_TYPE_2GE_FIXED = 1120 +IFACE_TYPE_5GE_FIXED = 1130 +IFACE_TYPE_10GE_FIXED = 1150 +IFACE_TYPE_10GE_CX4 = 1170 +IFACE_TYPE_10GE_SFP_PLUS = 1200 +IFACE_TYPE_10GE_XFP = 1300 +IFACE_TYPE_10GE_XENPAK = 1310 +IFACE_TYPE_10GE_X2 = 1320 +IFACE_TYPE_25GE_SFP28 = 1350 +IFACE_TYPE_40GE_QSFP_PLUS = 1400 +IFACE_TYPE_50GE_QSFP28 = 1420 +IFACE_TYPE_100GE_CFP = 1500 +IFACE_TYPE_100GE_CFP2 = 1510 +IFACE_TYPE_100GE_CFP4 = 1520 +IFACE_TYPE_100GE_CPAK = 1550 +IFACE_TYPE_100GE_QSFP28 = 1600 +IFACE_TYPE_200GE_CFP2 = 1650 +IFACE_TYPE_200GE_QSFP56 = 1700 +IFACE_TYPE_400GE_QSFP_DD = 1750 # Wireless -IFACE_FF_80211A = 2600 -IFACE_FF_80211G = 2610 -IFACE_FF_80211N = 2620 -IFACE_FF_80211AC = 2630 -IFACE_FF_80211AD = 2640 +IFACE_TYPE_80211A = 2600 +IFACE_TYPE_80211G = 2610 +IFACE_TYPE_80211N = 2620 +IFACE_TYPE_80211AC = 2630 +IFACE_TYPE_80211AD = 2640 # Cellular -IFACE_FF_GSM = 2810 -IFACE_FF_CDMA = 2820 -IFACE_FF_LTE = 2830 +IFACE_TYPE_GSM = 2810 +IFACE_TYPE_CDMA = 2820 +IFACE_TYPE_LTE = 2830 # SONET -IFACE_FF_SONET_OC3 = 6100 -IFACE_FF_SONET_OC12 = 6200 -IFACE_FF_SONET_OC48 = 6300 -IFACE_FF_SONET_OC192 = 6400 -IFACE_FF_SONET_OC768 = 6500 -IFACE_FF_SONET_OC1920 = 6600 -IFACE_FF_SONET_OC3840 = 6700 +IFACE_TYPE_SONET_OC3 = 6100 +IFACE_TYPE_SONET_OC12 = 6200 +IFACE_TYPE_SONET_OC48 = 6300 +IFACE_TYPE_SONET_OC192 = 6400 +IFACE_TYPE_SONET_OC768 = 6500 +IFACE_TYPE_SONET_OC1920 = 6600 +IFACE_TYPE_SONET_OC3840 = 6700 # Fibrechannel -IFACE_FF_1GFC_SFP = 3010 -IFACE_FF_2GFC_SFP = 3020 -IFACE_FF_4GFC_SFP = 3040 -IFACE_FF_8GFC_SFP_PLUS = 3080 -IFACE_FF_16GFC_SFP_PLUS = 3160 -IFACE_FF_32GFC_SFP28 = 3320 -IFACE_FF_128GFC_QSFP28 = 3400 +IFACE_TYPE_1GFC_SFP = 3010 +IFACE_TYPE_2GFC_SFP = 3020 +IFACE_TYPE_4GFC_SFP = 3040 +IFACE_TYPE_8GFC_SFP_PLUS = 3080 +IFACE_TYPE_16GFC_SFP_PLUS = 3160 +IFACE_TYPE_32GFC_SFP28 = 3320 +IFACE_TYPE_128GFC_QSFP28 = 3400 # Serial -IFACE_FF_T1 = 4000 -IFACE_FF_E1 = 4010 -IFACE_FF_T3 = 4040 -IFACE_FF_E3 = 4050 +IFACE_TYPE_T1 = 4000 +IFACE_TYPE_E1 = 4010 +IFACE_TYPE_T3 = 4040 +IFACE_TYPE_E3 = 4050 # Stacking -IFACE_FF_STACKWISE = 5000 -IFACE_FF_STACKWISE_PLUS = 5050 -IFACE_FF_FLEXSTACK = 5100 -IFACE_FF_FLEXSTACK_PLUS = 5150 -IFACE_FF_JUNIPER_VCP = 5200 -IFACE_FF_SUMMITSTACK = 5300 -IFACE_FF_SUMMITSTACK128 = 5310 -IFACE_FF_SUMMITSTACK256 = 5320 -IFACE_FF_SUMMITSTACK512 = 5330 +IFACE_TYPE_STACKWISE = 5000 +IFACE_TYPE_STACKWISE_PLUS = 5050 +IFACE_TYPE_FLEXSTACK = 5100 +IFACE_TYPE_FLEXSTACK_PLUS = 5150 +IFACE_TYPE_JUNIPER_VCP = 5200 +IFACE_TYPE_SUMMITSTACK = 5300 +IFACE_TYPE_SUMMITSTACK128 = 5310 +IFACE_TYPE_SUMMITSTACK256 = 5320 +IFACE_TYPE_SUMMITSTACK512 = 5330 # Other -IFACE_FF_OTHER = 32767 +IFACE_TYPE_OTHER = 32767 -IFACE_FF_CHOICES = [ +IFACE_TYPE_CHOICES = [ [ 'Virtual interfaces', [ - [IFACE_FF_VIRTUAL, 'Virtual'], - [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'], + [IFACE_TYPE_VIRTUAL, 'Virtual'], + [IFACE_TYPE_LAG, 'Link Aggregation Group (LAG)'], ], ], [ 'Ethernet (fixed)', [ - [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], - [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], - [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], - [IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'], + [IFACE_TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'], + [IFACE_TYPE_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'], + [IFACE_TYPE_5GE_FIXED, '5GBASE-T (5GE)'], + [IFACE_TYPE_10GE_FIXED, '10GBASE-T (10GE)'], + [IFACE_TYPE_10GE_CX4, '10GBASE-CX4 (10GE)'], ] ], [ 'Ethernet (modular)', [ - [IFACE_FF_1GE_GBIC, 'GBIC (1GE)'], - [IFACE_FF_1GE_SFP, 'SFP (1GE)'], - [IFACE_FF_10GE_SFP_PLUS, 'SFP+ (10GE)'], - [IFACE_FF_10GE_XFP, 'XFP (10GE)'], - [IFACE_FF_10GE_XENPAK, 'XENPAK (10GE)'], - [IFACE_FF_10GE_X2, 'X2 (10GE)'], - [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], - [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], - [IFACE_FF_50GE_QSFP28, 'QSFP28 (50GE)'], - [IFACE_FF_100GE_CFP, 'CFP (100GE)'], - [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], - [IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'], - [IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'], - [IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'], - [IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'], - [IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'], - [IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'], + [IFACE_TYPE_1GE_GBIC, 'GBIC (1GE)'], + [IFACE_TYPE_1GE_SFP, 'SFP (1GE)'], + [IFACE_TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'], + [IFACE_TYPE_10GE_XFP, 'XFP (10GE)'], + [IFACE_TYPE_10GE_XENPAK, 'XENPAK (10GE)'], + [IFACE_TYPE_10GE_X2, 'X2 (10GE)'], + [IFACE_TYPE_25GE_SFP28, 'SFP28 (25GE)'], + [IFACE_TYPE_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_TYPE_50GE_QSFP28, 'QSFP28 (50GE)'], + [IFACE_TYPE_100GE_CFP, 'CFP (100GE)'], + [IFACE_TYPE_100GE_CFP2, 'CFP2 (100GE)'], + [IFACE_TYPE_200GE_CFP2, 'CFP2 (200GE)'], + [IFACE_TYPE_100GE_CFP4, 'CFP4 (100GE)'], + [IFACE_TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'], + [IFACE_TYPE_100GE_QSFP28, 'QSFP28 (100GE)'], + [IFACE_TYPE_200GE_QSFP56, 'QSFP56 (200GE)'], + [IFACE_TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'], ] ], [ 'Wireless', [ - [IFACE_FF_80211A, 'IEEE 802.11a'], - [IFACE_FF_80211G, 'IEEE 802.11b/g'], - [IFACE_FF_80211N, 'IEEE 802.11n'], - [IFACE_FF_80211AC, 'IEEE 802.11ac'], - [IFACE_FF_80211AD, 'IEEE 802.11ad'], + [IFACE_TYPE_80211A, 'IEEE 802.11a'], + [IFACE_TYPE_80211G, 'IEEE 802.11b/g'], + [IFACE_TYPE_80211N, 'IEEE 802.11n'], + [IFACE_TYPE_80211AC, 'IEEE 802.11ac'], + [IFACE_TYPE_80211AD, 'IEEE 802.11ad'], ] ], [ 'Cellular', [ - [IFACE_FF_GSM, 'GSM'], - [IFACE_FF_CDMA, 'CDMA'], - [IFACE_FF_LTE, 'LTE'], + [IFACE_TYPE_GSM, 'GSM'], + [IFACE_TYPE_CDMA, 'CDMA'], + [IFACE_TYPE_LTE, 'LTE'], ] ], [ 'SONET', [ - [IFACE_FF_SONET_OC3, 'OC-3/STM-1'], - [IFACE_FF_SONET_OC12, 'OC-12/STM-4'], - [IFACE_FF_SONET_OC48, 'OC-48/STM-16'], - [IFACE_FF_SONET_OC192, 'OC-192/STM-64'], - [IFACE_FF_SONET_OC768, 'OC-768/STM-256'], - [IFACE_FF_SONET_OC1920, 'OC-1920/STM-640'], - [IFACE_FF_SONET_OC3840, 'OC-3840/STM-1234'], + [IFACE_TYPE_SONET_OC3, 'OC-3/STM-1'], + [IFACE_TYPE_SONET_OC12, 'OC-12/STM-4'], + [IFACE_TYPE_SONET_OC48, 'OC-48/STM-16'], + [IFACE_TYPE_SONET_OC192, 'OC-192/STM-64'], + [IFACE_TYPE_SONET_OC768, 'OC-768/STM-256'], + [IFACE_TYPE_SONET_OC1920, 'OC-1920/STM-640'], + [IFACE_TYPE_SONET_OC3840, 'OC-3840/STM-1234'], ] ], [ 'FibreChannel', [ - [IFACE_FF_1GFC_SFP, 'SFP (1GFC)'], - [IFACE_FF_2GFC_SFP, 'SFP (2GFC)'], - [IFACE_FF_4GFC_SFP, 'SFP (4GFC)'], - [IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], - [IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], - [IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'], - [IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'], + [IFACE_TYPE_1GFC_SFP, 'SFP (1GFC)'], + [IFACE_TYPE_2GFC_SFP, 'SFP (2GFC)'], + [IFACE_TYPE_4GFC_SFP, 'SFP (4GFC)'], + [IFACE_TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'], + [IFACE_TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'], + [IFACE_TYPE_32GFC_SFP28, 'SFP28 (32GFC)'], + [IFACE_TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'], ] ], [ 'Serial', [ - [IFACE_FF_T1, 'T1 (1.544 Mbps)'], - [IFACE_FF_E1, 'E1 (2.048 Mbps)'], - [IFACE_FF_T3, 'T3 (45 Mbps)'], - [IFACE_FF_E3, 'E3 (34 Mbps)'], + [IFACE_TYPE_T1, 'T1 (1.544 Mbps)'], + [IFACE_TYPE_E1, 'E1 (2.048 Mbps)'], + [IFACE_TYPE_T3, 'T3 (45 Mbps)'], + [IFACE_TYPE_E3, 'E3 (34 Mbps)'], ] ], [ 'Stacking', [ - [IFACE_FF_STACKWISE, 'Cisco StackWise'], - [IFACE_FF_STACKWISE_PLUS, 'Cisco StackWise Plus'], - [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], - [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], - [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], - [IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'], - [IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'], - [IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'], - [IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'], + [IFACE_TYPE_STACKWISE, 'Cisco StackWise'], + [IFACE_TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'], + [IFACE_TYPE_FLEXSTACK, 'Cisco FlexStack'], + [IFACE_TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], + [IFACE_TYPE_JUNIPER_VCP, 'Juniper VCP'], + [IFACE_TYPE_SUMMITSTACK, 'Extreme SummitStack'], + [IFACE_TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'], + [IFACE_TYPE_SUMMITSTACK256, 'Extreme SummitStack-256'], + [IFACE_TYPE_SUMMITSTACK512, 'Extreme SummitStack-512'], ] ], [ 'Other', [ - [IFACE_FF_OTHER, 'Other'], + [IFACE_TYPE_OTHER, 'Other'], ] ], ] VIRTUAL_IFACE_TYPES = [ - IFACE_FF_VIRTUAL, - IFACE_FF_LAG, + IFACE_TYPE_VIRTUAL, + IFACE_TYPE_LAG, ] WIRELESS_IFACE_TYPES = [ - IFACE_FF_80211A, - IFACE_FF_80211G, - IFACE_FF_80211N, - IFACE_FF_80211AC, - IFACE_FF_80211AD, + IFACE_TYPE_80211A, + IFACE_TYPE_80211G, + IFACE_TYPE_80211N, + IFACE_TYPE_80211AC, + IFACE_TYPE_80211AD, ] NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES @@ -276,6 +280,7 @@ IFACE_MODE_CHOICES = [ # Pass-through port types PORT_TYPE_8P8C = 1000 PORT_TYPE_110_PUNCH = 1100 +PORT_TYPE_BNC = 1200 PORT_TYPE_ST = 2000 PORT_TYPE_SC = 2100 PORT_TYPE_SC_APC = 2110 @@ -292,6 +297,7 @@ PORT_TYPE_CHOICES = [ [ [PORT_TYPE_8P8C, '8P8C'], [PORT_TYPE_110_PUNCH, '110 Punch'], + [PORT_TYPE_BNC, 'BNC'], ], ], [ @@ -360,7 +366,7 @@ CONNECTION_STATUS_CHOICES = [ # Cable endpoint types CABLE_TERMINATION_TYPES = [ - 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', ] # Cable types @@ -372,6 +378,7 @@ CABLE_TYPE_CAT6A = 1610 CABLE_TYPE_CAT7 = 1700 CABLE_TYPE_DAC_ACTIVE = 1800 CABLE_TYPE_DAC_PASSIVE = 1810 +CABLE_TYPE_COAXIAL = 1900 CABLE_TYPE_MMF = 3000 CABLE_TYPE_MMF_OM1 = 3010 CABLE_TYPE_MMF_OM2 = 3020 @@ -393,6 +400,7 @@ CABLE_TYPE_CHOICES = ( (CABLE_TYPE_CAT7, 'CAT7'), (CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'), (CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'), + (CABLE_TYPE_COAXIAL, 'Coaxial'), ), ), ( @@ -425,7 +433,7 @@ CABLE_TERMINATION_TYPE_CHOICES = { COMPATIBLE_TERMINATION_TYPES = { 'consoleport': ['consoleserverport', 'frontport', 'rearport'], 'consoleserverport': ['consoleport', 'frontport', 'rearport'], - 'powerport': ['poweroutlet'], + 'powerport': ['poweroutlet', 'powerfeed'], 'poweroutlet': ['powerport'], 'interface': ['interface', 'circuittermination', 'frontport', 'rearport'], 'frontport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], @@ -448,3 +456,41 @@ RACK_DIMENSION_UNIT_CHOICES = ( (LENGTH_UNIT_MILLIMETER, 'Millimeters'), (LENGTH_UNIT_INCH, 'Inches'), ) + +# Power feeds +POWERFEED_TYPE_PRIMARY = 1 +POWERFEED_TYPE_REDUNDANT = 2 +POWERFEED_TYPE_CHOICES = ( + (POWERFEED_TYPE_PRIMARY, 'Primary'), + (POWERFEED_TYPE_REDUNDANT, 'Redundant'), +) +POWERFEED_SUPPLY_AC = 1 +POWERFEED_SUPPLY_DC = 2 +POWERFEED_SUPPLY_CHOICES = ( + (POWERFEED_SUPPLY_AC, 'AC'), + (POWERFEED_SUPPLY_DC, 'DC'), +) +POWERFEED_PHASE_SINGLE = 1 +POWERFEED_PHASE_3PHASE = 3 +POWERFEED_PHASE_CHOICES = ( + (POWERFEED_PHASE_SINGLE, 'Single phase'), + (POWERFEED_PHASE_3PHASE, 'Three-phase'), +) +POWERFEED_STATUS_OFFLINE = 0 +POWERFEED_STATUS_ACTIVE = 1 +POWERFEED_STATUS_PLANNED = 2 +POWERFEED_STATUS_FAILED = 4 +POWERFEED_STATUS_CHOICES = ( + (POWERFEED_STATUS_ACTIVE, 'Active'), + (POWERFEED_STATUS_OFFLINE, 'Offline'), + (POWERFEED_STATUS_PLANNED, 'Planned'), + (POWERFEED_STATUS_FAILED, 'Failed'), +) +POWERFEED_LEG_A = 1 +POWERFEED_LEG_B = 2 +POWERFEED_LEG_C = 3 +POWERFEED_LEG_CHOICES = ( + (POWERFEED_LEG_A, 'A'), + (POWERFEED_LEG_B, 'B'), + (POWERFEED_LEG_C, 'C'), +) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 7c190176b..a063a6b83 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,24 +1,24 @@ import django_filters from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q -from netaddr import EUI -from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet +from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES from utilities.filters import ( - NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter + MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, + TreeNodeMultipleChoiceFilter, ) from virtualization.models import Cluster from .constants import * from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) @@ -36,10 +36,10 @@ class RegionFilter(NameSlugSearchFilterSet): class Meta: model = Region - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -63,21 +63,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Region (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) tag = TagFilter() class Meta: model = Site - fields = ['q', 'name', 'slug', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] + fields = [ + 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + ] def search(self, queryset, name, value): if not value.strip(): @@ -114,17 +107,17 @@ class RackGroupFilter(NameSlugSearchFilterSet): class Meta: model = RackGroup - fields = ['site_id', 'name', 'slug'] + fields = ['id', 'name', 'slug'] class RackRoleFilter(NameSlugSearchFilterSet): class Meta: model = RackRole - fields = ['name', 'slug', 'color'] + fields = ['id', 'name', 'slug', 'color'] -class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): +class RackFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -133,7 +126,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - facility_id = NullableCharFieldFilter() site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -154,16 +146,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) status = django_filters.MultipleChoiceFilter( choices=RACK_STATUS_CHOICES, null_value=None @@ -178,14 +160,13 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Role (slug)', ) - asset_tag = NullableCharFieldFilter() tag = TagFilter() class Meta: model = Rack fields = [ - 'name', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', - 'outer_unit', + 'id', 'name', 'facility_id', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', + 'outer_width', 'outer_depth', 'outer_unit', ] def search(self, queryset, name, value): @@ -200,7 +181,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): ) -class RackReservationFilter(django_filters.FilterSet): +class RackReservationFilter(TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -235,16 +216,6 @@ class RackReservationFilter(django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label='User (ID)', @@ -275,7 +246,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet): class Meta: model = Manufacturer - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class DeviceTypeFilter(CustomFieldFilterSet): @@ -373,63 +344,63 @@ class ConsolePortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate - fields = ['name'] + fields = ['id', 'name'] class ConsoleServerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate - fields = ['name'] + fields = ['id', 'name'] class PowerPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate - fields = ['name'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw'] class PowerOutletTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = PowerOutletTemplate - fields = ['name'] + fields = ['id', 'name', 'feed_leg'] class InterfaceTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = InterfaceTemplate - fields = ['name', 'form_factor', 'mgmt_only'] + fields = ['id', 'name', 'type', 'mgmt_only'] class FrontPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = FrontPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type'] class RearPortTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = RearPortTemplate - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'positions'] class DeviceBayTemplateFilter(DeviceTypeComponentFilterSet): class Meta: model = DeviceBayTemplate - fields = ['name'] + fields = ['id', 'name'] class DeviceRoleFilter(NameSlugSearchFilterSet): class Meta: model = DeviceRole - fields = ['name', 'slug', 'color', 'vm_role'] + fields = ['id', 'name', 'slug', 'color', 'vm_role'] class PlatformFilter(NameSlugSearchFilterSet): @@ -447,10 +418,10 @@ class PlatformFilter(NameSlugSearchFilterSet): class Meta: model = Platform - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug', 'napalm_driver'] -class DeviceFilter(CustomFieldFilterSet): +class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -485,16 +456,6 @@ class DeviceFilter(CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -505,8 +466,6 @@ class DeviceFilter(CustomFieldFilterSet): to_field_name='slug', label='Platform (slug)', ) - name = NullableCharFieldFilter() - asset_tag = NullableCharFieldFilter() region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region__in', @@ -538,10 +497,6 @@ class DeviceFilter(CustomFieldFilterSet): queryset=Rack.objects.all(), label='Rack (ID)', ) - position = django_filters.ChoiceFilter( - choices=DEVICE_POSITION_CHOICES, - null_label='Non-racked' - ) cluster_id = django_filters.ModelMultipleChoiceFilter( queryset=Cluster.objects.all(), label='VM cluster (ID)', @@ -560,8 +515,8 @@ class DeviceFilter(CustomFieldFilterSet): field_name='device_type__is_full_depth', label='Is full depth', ) - mac_address = django_filters.CharFilter( - method='_mac_address', + mac_address = MultiValueMACAddressFilter( + field_name='interfaces__mac_address', label='MAC address', ) has_primary_ip = django_filters.BooleanFilter( @@ -573,6 +528,10 @@ class DeviceFilter(CustomFieldFilterSet): queryset=VirtualChassis.objects.all(), label='Virtual chassis (ID)', ) + virtual_chassis_member = django_filters.BooleanFilter( + method='_virtual_chassis_member', + label='Is a virtual chassis member' + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label='Has console ports', @@ -601,7 +560,7 @@ class DeviceFilter(CustomFieldFilterSet): class Meta: model = Device - fields = ['serial', 'face'] + fields = ['id', 'name', 'serial', 'asset_tag', 'face', 'position', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -614,16 +573,6 @@ class DeviceFilter(CustomFieldFilterSet): Q(comments__icontains=value) ).distinct() - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(interfaces__mac_address=mac).distinct() - except AddrFormatError: - return queryset.none() - def _has_primary_ip(self, queryset, name, value): if value: return queryset.filter( @@ -636,6 +585,9 @@ class DeviceFilter(CustomFieldFilterSet): Q(primary_ip6__isnull=False) ) + def _virtual_chassis_member(self, queryset, name, value): + return queryset.exclude(virtual_chassis__isnull=value) + def _console_ports(self, queryset, name, value): return queryset.exclude(consoleports__isnull=value) @@ -646,7 +598,7 @@ class DeviceFilter(CustomFieldFilterSet): return queryset.exclude(powerports__isnull=value) def _power_outlets(self, queryset, name, value): - return queryset.exclude(poweroutlets_isnull=value) + return queryset.exclude(poweroutlets__isnull=value) def _interfaces(self, queryset, name, value): return queryset.exclude(interfaces__isnull=value) @@ -663,7 +615,7 @@ class DeviceComponentFilterSet(django_filters.FilterSet): method='search', label='Search', ) - device_id = django_filters.ModelChoiceFilter( + device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', ) @@ -678,7 +630,8 @@ class DeviceComponentFilterSet(django_filters.FilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) + Q(name__icontains=value) | + Q(description__icontains=value) ) @@ -691,7 +644,7 @@ class ConsolePortFilter(DeviceComponentFilterSet): class Meta: model = ConsolePort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class ConsoleServerPortFilter(DeviceComponentFilterSet): @@ -703,7 +656,7 @@ class ConsoleServerPortFilter(DeviceComponentFilterSet): class Meta: model = ConsoleServerPort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'description', 'connection_status'] class PowerPortFilter(DeviceComponentFilterSet): @@ -715,7 +668,7 @@ class PowerPortFilter(DeviceComponentFilterSet): class Meta: model = PowerPort - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description', 'connection_status'] class PowerOutletFilter(DeviceComponentFilterSet): @@ -727,7 +680,7 @@ class PowerOutletFilter(DeviceComponentFilterSet): class Meta: model = PowerOutlet - fields = ['name', 'connection_status'] + fields = ['id', 'name', 'feed_leg', 'description', 'connection_status'] class InterfaceFilter(django_filters.FilterSet): @@ -743,8 +696,8 @@ class InterfaceFilter(django_filters.FilterSet): field_name='name', label='Device', ) - device_id = django_filters.NumberFilter( - method='filter_device', + device_id = MultiValueNumberFilter( + method='filter_device_id', field_name='pk', label='Device (ID)', ) @@ -753,19 +706,16 @@ class InterfaceFilter(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) - type = django_filters.CharFilter( - method='filter_type', - label='Interface type', + kind = django_filters.CharFilter( + method='filter_kind', + label='Kind of interface', ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), label='LAG interface (ID)', ) - mac_address = django_filters.CharFilter( - method='_mac_address', - label='MAC address', - ) + mac_address = MultiValueMACAddressFilter() tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', @@ -775,20 +725,21 @@ class InterfaceFilter(django_filters.FilterSet): method='filter_vlan', label='Assigned VID' ) - form_factor = django_filters.MultipleChoiceFilter( - choices=IFACE_FF_CHOICES, + type = django_filters.MultipleChoiceFilter( + choices=IFACE_TYPE_CHOICES, null_value=None ) class Meta: model = Interface - fields = ['name', 'connection_status', 'form_factor', 'enabled', 'mtu', 'mgmt_only'] + fields = ['id', 'name', 'connection_status', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) + Q(name__icontains=value) | + Q(description__icontains=value) ).distinct() def filter_device(self, queryset, name, value): @@ -799,6 +750,17 @@ class InterfaceFilter(django_filters.FilterSet): except Device.DoesNotExist: return queryset.none() + def filter_device_id(self, queryset, name, id_list): + # Include interfaces belonging to peer virtual chassis members + vc_interface_ids = [] + try: + devices = Device.objects.filter(pk__in=id_list) + for device in devices: + vc_interface_ids += device.vc_interfaces.values_list('id', flat=True) + return queryset.filter(pk__in=vc_interface_ids) + except Device.DoesNotExist: + return queryset.none() + def filter_vlan_id(self, queryset, name, value): value = value.strip() if not value: @@ -817,25 +779,14 @@ class InterfaceFilter(django_filters.FilterSet): Q(tagged_vlans__vid=value) ) - def filter_type(self, queryset, name, value): + def filter_kind(self, queryset, name, value): value = value.strip().lower() return { - 'physical': queryset.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES), - 'virtual': queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES), - 'wireless': queryset.filter(form_factor__in=WIRELESS_IFACE_TYPES), - 'lag': queryset.filter(form_factor=IFACE_FF_LAG), + 'physical': queryset.exclude(type__in=NONCONNECTABLE_IFACE_TYPES), + 'virtual': queryset.filter(type__in=VIRTUAL_IFACE_TYPES), + 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def _mac_address(self, queryset, name, value): - value = value.strip() - if not value: - return queryset - try: - mac = EUI(value.strip()) - return queryset.filter(mac_address=mac) - except AddrFormatError: - return queryset.none() - class FrontPortFilter(DeviceComponentFilterSet): cabled = django_filters.BooleanFilter( @@ -846,7 +797,7 @@ class FrontPortFilter(DeviceComponentFilterSet): class Meta: model = FrontPort - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'description'] class RearPortFilter(DeviceComponentFilterSet): @@ -858,14 +809,14 @@ class RearPortFilter(DeviceComponentFilterSet): class Meta: model = RearPort - fields = ['name', 'type'] + fields = ['id', 'name', 'type', 'positions', 'description'] class DeviceBayFilter(DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['name'] + fields = ['id', 'name', 'description'] class InventoryItemFilter(DeviceComponentFilterSet): @@ -896,11 +847,10 @@ class InventoryItemFilter(DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - asset_tag = NullableCharFieldFilter() class Meta: model = InventoryItem - fields = ['name', 'part_id', 'serial', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'part_id', 'serial', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): @@ -946,7 +896,7 @@ class VirtualChassisFilter(django_filters.FilterSet): class Meta: model = VirtualChassis - fields = ['domain'] + fields = ['id', 'domain'] def search(self, queryset, name, value): if not value.strip(): @@ -966,6 +916,9 @@ class CableFilter(django_filters.FilterSet): type = django_filters.MultipleChoiceFilter( choices=CABLE_TYPE_CHOICES ) + status = django_filters.MultipleChoiceFilter( + choices=CONNECTION_STATUS_CHOICES + ) color = django_filters.MultipleChoiceFilter( choices=COLOR_CHOICES ) @@ -980,7 +933,7 @@ class CableFilter(django_filters.FilterSet): class Meta: model = Cable - fields = ['type', 'status', 'color', 'length', 'length_unit'] + fields = ['id', 'label', 'length', 'length_unit'] def search(self, queryset, name, value): if not value.strip(): @@ -1043,14 +996,14 @@ class PowerConnectionFilter(django_filters.FilterSet): def filter_site(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(connected_endpoint__device__site__slug=value) + return queryset.filter(_connected_poweroutlet__device__site__slug=value) def filter_device(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(device__name__icontains=value) | - Q(connected_endpoint__device__name__icontains=value) + Q(_connected_poweroutlet__device__name__icontains=value) ) @@ -1083,3 +1036,86 @@ class InterfaceConnectionFilter(django_filters.FilterSet): Q(device__name__icontains=value) | Q(_connected_interface__device__name__icontains=value) ) + + +class PowerPanelFilter(django_filters.FilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + rack_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack_group', + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + ) + + class Meta: + model = PowerPanel + fields = ['name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) + ) + return queryset.filter(qs_filter) + + +class PowerFeedFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='power_panel__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='power_panel__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + power_panel_id = django_filters.ModelMultipleChoiceFilter( + queryset=PowerPanel.objects.all(), + label='Power panel (ID)', + ) + rack_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + tag = TagFilter() + + class Meta: + model = PowerFeed + fields = ['name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(comments__icontains=value) + ) + return queryset.filter(qs_filter) diff --git a/netbox/dcim/fixtures/dcim.json b/netbox/dcim/fixtures/dcim.json index 215fbb702..ece19a83c 100644 --- a/netbox/dcim/fixtures/dcim.json +++ b/netbox/dcim/fixtures/dcim.json @@ -902,7 +902,7 @@ "fields": { "device_type": 1, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -912,7 +912,7 @@ "fields": { "device_type": 1, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -922,7 +922,7 @@ "fields": { "device_type": 1, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false } }, @@ -932,7 +932,7 @@ "fields": { "device_type": 2, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -942,7 +942,7 @@ "fields": { "device_type": 2, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -952,7 +952,7 @@ "fields": { "device_type": 2, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false } }, @@ -962,7 +962,7 @@ "fields": { "device_type": 3, "name": "em0", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -972,7 +972,7 @@ "fields": { "device_type": 3, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -982,7 +982,7 @@ "fields": { "device_type": 3, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -992,7 +992,7 @@ "fields": { "device_type": 3, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1002,7 +1002,7 @@ "fields": { "device_type": 3, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1012,7 +1012,7 @@ "fields": { "device_type": 3, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1022,7 +1022,7 @@ "fields": { "device_type": 3, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1032,7 +1032,7 @@ "fields": { "device_type": 3, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1042,7 +1042,7 @@ "fields": { "device_type": 3, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1052,7 +1052,7 @@ "fields": { "device_type": 3, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1062,7 +1062,7 @@ "fields": { "device_type": 3, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1072,7 +1072,7 @@ "fields": { "device_type": 3, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1082,7 +1082,7 @@ "fields": { "device_type": 3, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1092,7 +1092,7 @@ "fields": { "device_type": 3, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1102,7 +1102,7 @@ "fields": { "device_type": 3, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1112,7 +1112,7 @@ "fields": { "device_type": 3, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1122,7 +1122,7 @@ "fields": { "device_type": 3, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1132,7 +1132,7 @@ "fields": { "device_type": 3, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1142,7 +1142,7 @@ "fields": { "device_type": 3, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1152,7 +1152,7 @@ "fields": { "device_type": 3, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1162,7 +1162,7 @@ "fields": { "device_type": 3, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1172,7 +1172,7 @@ "fields": { "device_type": 3, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1182,7 +1182,7 @@ "fields": { "device_type": 3, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1192,7 +1192,7 @@ "fields": { "device_type": 3, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1202,7 +1202,7 @@ "fields": { "device_type": 3, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1212,7 +1212,7 @@ "fields": { "device_type": 3, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1222,7 +1222,7 @@ "fields": { "device_type": 3, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1232,7 +1232,7 @@ "fields": { "device_type": 3, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1242,7 +1242,7 @@ "fields": { "device_type": 3, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1252,7 +1252,7 @@ "fields": { "device_type": 3, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1262,7 +1262,7 @@ "fields": { "device_type": 3, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1272,7 +1272,7 @@ "fields": { "device_type": 3, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1282,7 +1282,7 @@ "fields": { "device_type": 4, "name": "em0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -1292,7 +1292,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1302,7 +1302,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1312,7 +1312,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1322,7 +1322,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1332,7 +1332,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1342,7 +1342,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1352,7 +1352,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1362,7 +1362,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1372,7 +1372,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1382,7 +1382,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1392,7 +1392,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1402,7 +1402,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1412,7 +1412,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1422,7 +1422,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1432,7 +1432,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1442,7 +1442,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1452,7 +1452,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1462,7 +1462,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1472,7 +1472,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1482,7 +1482,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1492,7 +1492,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1502,7 +1502,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1512,7 +1512,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1522,7 +1522,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1532,7 +1532,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1542,7 +1542,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1552,7 +1552,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1562,7 +1562,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1572,7 +1572,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1582,7 +1582,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1592,7 +1592,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1602,7 +1602,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1612,7 +1612,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1622,7 +1622,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1632,7 +1632,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1642,7 +1642,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1652,7 +1652,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1662,7 +1662,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1672,7 +1672,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1682,7 +1682,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1692,7 +1692,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1702,7 +1702,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1712,7 +1712,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1722,7 +1722,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1732,7 +1732,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1742,7 +1742,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1752,7 +1752,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1762,7 +1762,7 @@ "fields": { "device_type": 4, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false } }, @@ -1772,7 +1772,7 @@ "fields": { "device_type": 4, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1782,7 +1782,7 @@ "fields": { "device_type": 4, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1792,7 +1792,7 @@ "fields": { "device_type": 4, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1802,7 +1802,7 @@ "fields": { "device_type": 4, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false } }, @@ -1812,7 +1812,7 @@ "fields": { "device_type": 5, "name": "eth0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true } }, @@ -1822,7 +1822,7 @@ "fields": { "device_type": 6, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true } }, @@ -2667,7 +2667,7 @@ "fields": { "device": 1, "name": "PEM0", - "connected_endpoint": 25, + "_connected_poweroutlet": 25, "connection_status": true } }, @@ -2677,7 +2677,7 @@ "fields": { "device": 1, "name": "PEM1", - "connected_endpoint": 49, + "_connected_poweroutlet": 49, "connection_status": true } }, @@ -2687,7 +2687,7 @@ "fields": { "device": 1, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2697,7 +2697,7 @@ "fields": { "device": 1, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2707,7 +2707,7 @@ "fields": { "device": 2, "name": "PEM0", - "connected_endpoint": 26, + "_connected_poweroutlet": 26, "connection_status": true } }, @@ -2717,7 +2717,7 @@ "fields": { "device": 2, "name": "PEM1", - "connected_endpoint": 50, + "_connected_poweroutlet": 50, "connection_status": true } }, @@ -2727,7 +2727,7 @@ "fields": { "device": 2, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2737,7 +2737,7 @@ "fields": { "device": 2, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2747,7 +2747,7 @@ "fields": { "device": 4, "name": "PSU0", - "connected_endpoint": 28, + "_connected_poweroutlet": 28, "connection_status": true } }, @@ -2757,7 +2757,7 @@ "fields": { "device": 4, "name": "PSU1", - "connected_endpoint": 52, + "_connected_poweroutlet": 52, "connection_status": true } }, @@ -2767,7 +2767,7 @@ "fields": { "device": 5, "name": "PSU0", - "connected_endpoint": 56, + "_connected_poweroutlet": 56, "connection_status": true } }, @@ -2777,7 +2777,7 @@ "fields": { "device": 5, "name": "PSU1", - "connected_endpoint": 32, + "_connected_poweroutlet": 32, "connection_status": true } }, @@ -2787,7 +2787,7 @@ "fields": { "device": 3, "name": "PSU0", - "connected_endpoint": 27, + "_connected_poweroutlet": 27, "connection_status": true } }, @@ -2797,7 +2797,7 @@ "fields": { "device": 3, "name": "PSU1", - "connected_endpoint": 51, + "_connected_poweroutlet": 51, "connection_status": true } }, @@ -2807,7 +2807,7 @@ "fields": { "device": 7, "name": "PEM0", - "connected_endpoint": 53, + "_connected_poweroutlet": 53, "connection_status": true } }, @@ -2817,7 +2817,7 @@ "fields": { "device": 7, "name": "PEM1", - "connected_endpoint": 29, + "_connected_poweroutlet": 29, "connection_status": true } }, @@ -2827,7 +2827,7 @@ "fields": { "device": 7, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2837,7 +2837,7 @@ "fields": { "device": 7, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2847,7 +2847,7 @@ "fields": { "device": 8, "name": "PEM0", - "connected_endpoint": 54, + "_connected_poweroutlet": 54, "connection_status": true } }, @@ -2857,7 +2857,7 @@ "fields": { "device": 8, "name": "PEM1", - "connected_endpoint": 30, + "_connected_poweroutlet": 30, "connection_status": true } }, @@ -2867,7 +2867,7 @@ "fields": { "device": 8, "name": "PEM2", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2877,7 +2877,7 @@ "fields": { "device": 8, "name": "PEM3", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -2887,7 +2887,7 @@ "fields": { "device": 6, "name": "PSU0", - "connected_endpoint": 55, + "_connected_poweroutlet": 55, "connection_status": true } }, @@ -2897,7 +2897,7 @@ "fields": { "device": 6, "name": "PSU1", - "connected_endpoint": 31, + "_connected_poweroutlet": 31, "connection_status": true } }, @@ -2907,7 +2907,7 @@ "fields": { "device": 9, "name": "PSU", - "connected_endpoint": null, + "_connected_poweroutlet": null, "connection_status": true } }, @@ -3301,7 +3301,7 @@ "fields": { "device": 1, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3312,7 +3312,7 @@ "fields": { "device": 1, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -3323,7 +3323,7 @@ "fields": { "device": 1, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -3334,7 +3334,7 @@ "fields": { "device": 1, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "TEST" } @@ -3345,7 +3345,7 @@ "fields": { "device": 1, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3356,7 +3356,7 @@ "fields": { "device": 1, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3367,7 +3367,7 @@ "fields": { "device": 1, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3378,7 +3378,7 @@ "fields": { "device": 1, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3389,7 +3389,7 @@ "fields": { "device": 1, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3400,7 +3400,7 @@ "fields": { "device": 2, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3411,7 +3411,7 @@ "fields": { "device": 2, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -3422,7 +3422,7 @@ "fields": { "device": 2, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -3434,7 +3434,7 @@ "device": 3, "name": "em0", "mac_address": "00-00-00-AA-BB-CC", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -3445,7 +3445,7 @@ "fields": { "device": 3, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3456,7 +3456,7 @@ "fields": { "device": 3, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3467,7 +3467,7 @@ "fields": { "device": 3, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3478,7 +3478,7 @@ "fields": { "device": 3, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3489,7 +3489,7 @@ "fields": { "device": 3, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3500,7 +3500,7 @@ "fields": { "device": 3, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3511,7 +3511,7 @@ "fields": { "device": 3, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3522,7 +3522,7 @@ "fields": { "device": 3, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3533,7 +3533,7 @@ "fields": { "device": 3, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3544,7 +3544,7 @@ "fields": { "device": 3, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3555,7 +3555,7 @@ "fields": { "device": 3, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3566,7 +3566,7 @@ "fields": { "device": 3, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3577,7 +3577,7 @@ "fields": { "device": 3, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3588,7 +3588,7 @@ "fields": { "device": 3, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3599,7 +3599,7 @@ "fields": { "device": 3, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3610,7 +3610,7 @@ "fields": { "device": 3, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3621,7 +3621,7 @@ "fields": { "device": 3, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3632,7 +3632,7 @@ "fields": { "device": 3, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3643,7 +3643,7 @@ "fields": { "device": 3, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3654,7 +3654,7 @@ "fields": { "device": 3, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3665,7 +3665,7 @@ "fields": { "device": 3, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3676,7 +3676,7 @@ "fields": { "device": 3, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3687,7 +3687,7 @@ "fields": { "device": 3, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3698,7 +3698,7 @@ "fields": { "device": 3, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3709,7 +3709,7 @@ "fields": { "device": 3, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3720,7 +3720,7 @@ "fields": { "device": 3, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3731,7 +3731,7 @@ "fields": { "device": 3, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3742,7 +3742,7 @@ "fields": { "device": 3, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3753,7 +3753,7 @@ "fields": { "device": 3, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3764,7 +3764,7 @@ "fields": { "device": 3, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3775,7 +3775,7 @@ "fields": { "device": 3, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3786,7 +3786,7 @@ "fields": { "device": 4, "name": "em0", - "form_factor": 1000, + "type": 1000, "mac_address": "ff-ee-dd-33-22-11", "mgmt_only": true, "description": "" @@ -3798,7 +3798,7 @@ "fields": { "device": 4, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3809,7 +3809,7 @@ "fields": { "device": 4, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3820,7 +3820,7 @@ "fields": { "device": 4, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3831,7 +3831,7 @@ "fields": { "device": 4, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -3842,7 +3842,7 @@ "fields": { "device": 4, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3853,7 +3853,7 @@ "fields": { "device": 4, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3864,7 +3864,7 @@ "fields": { "device": 4, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3875,7 +3875,7 @@ "fields": { "device": 4, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3886,7 +3886,7 @@ "fields": { "device": 4, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3897,7 +3897,7 @@ "fields": { "device": 4, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3908,7 +3908,7 @@ "fields": { "device": 4, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3919,7 +3919,7 @@ "fields": { "device": 4, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3930,7 +3930,7 @@ "fields": { "device": 4, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3941,7 +3941,7 @@ "fields": { "device": 4, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3952,7 +3952,7 @@ "fields": { "device": 4, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3963,7 +3963,7 @@ "fields": { "device": 4, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3974,7 +3974,7 @@ "fields": { "device": 4, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3985,7 +3985,7 @@ "fields": { "device": 4, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -3996,7 +3996,7 @@ "fields": { "device": 4, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4007,7 +4007,7 @@ "fields": { "device": 4, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4018,7 +4018,7 @@ "fields": { "device": 4, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4029,7 +4029,7 @@ "fields": { "device": 4, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4040,7 +4040,7 @@ "fields": { "device": 4, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4051,7 +4051,7 @@ "fields": { "device": 4, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4062,7 +4062,7 @@ "fields": { "device": 4, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4073,7 +4073,7 @@ "fields": { "device": 4, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4084,7 +4084,7 @@ "fields": { "device": 4, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4095,7 +4095,7 @@ "fields": { "device": 4, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4106,7 +4106,7 @@ "fields": { "device": 4, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4117,7 +4117,7 @@ "fields": { "device": 4, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4128,7 +4128,7 @@ "fields": { "device": 4, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4139,7 +4139,7 @@ "fields": { "device": 4, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4150,7 +4150,7 @@ "fields": { "device": 4, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4161,7 +4161,7 @@ "fields": { "device": 4, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4172,7 +4172,7 @@ "fields": { "device": 4, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4183,7 +4183,7 @@ "fields": { "device": 4, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4194,7 +4194,7 @@ "fields": { "device": 4, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4205,7 +4205,7 @@ "fields": { "device": 4, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4216,7 +4216,7 @@ "fields": { "device": 4, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4227,7 +4227,7 @@ "fields": { "device": 4, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4238,7 +4238,7 @@ "fields": { "device": 4, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4249,7 +4249,7 @@ "fields": { "device": 4, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4260,7 +4260,7 @@ "fields": { "device": 4, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4271,7 +4271,7 @@ "fields": { "device": 4, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4282,7 +4282,7 @@ "fields": { "device": 4, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4293,7 +4293,7 @@ "fields": { "device": 4, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4304,7 +4304,7 @@ "fields": { "device": 4, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4315,7 +4315,7 @@ "fields": { "device": 4, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4326,7 +4326,7 @@ "fields": { "device": 4, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4337,7 +4337,7 @@ "fields": { "device": 4, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4348,7 +4348,7 @@ "fields": { "device": 4, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4359,7 +4359,7 @@ "fields": { "device": 4, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4370,7 +4370,7 @@ "fields": { "device": 5, "name": "em0", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -4381,7 +4381,7 @@ "fields": { "device": 5, "name": "et-0/0/48", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4392,7 +4392,7 @@ "fields": { "device": 5, "name": "et-0/0/49", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4403,7 +4403,7 @@ "fields": { "device": 5, "name": "et-0/0/50", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4414,7 +4414,7 @@ "fields": { "device": 5, "name": "et-0/0/51", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4425,7 +4425,7 @@ "fields": { "device": 5, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4436,7 +4436,7 @@ "fields": { "device": 5, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4447,7 +4447,7 @@ "fields": { "device": 5, "name": "xe-0/0/10", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4458,7 +4458,7 @@ "fields": { "device": 5, "name": "xe-0/0/11", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4469,7 +4469,7 @@ "fields": { "device": 5, "name": "xe-0/0/12", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4480,7 +4480,7 @@ "fields": { "device": 5, "name": "xe-0/0/13", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4491,7 +4491,7 @@ "fields": { "device": 5, "name": "xe-0/0/14", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4502,7 +4502,7 @@ "fields": { "device": 5, "name": "xe-0/0/15", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4513,7 +4513,7 @@ "fields": { "device": 5, "name": "xe-0/0/16", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4524,7 +4524,7 @@ "fields": { "device": 5, "name": "xe-0/0/17", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4535,7 +4535,7 @@ "fields": { "device": 5, "name": "xe-0/0/18", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4546,7 +4546,7 @@ "fields": { "device": 5, "name": "xe-0/0/19", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4557,7 +4557,7 @@ "fields": { "device": 5, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4568,7 +4568,7 @@ "fields": { "device": 5, "name": "xe-0/0/20", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4579,7 +4579,7 @@ "fields": { "device": 5, "name": "xe-0/0/21", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4590,7 +4590,7 @@ "fields": { "device": 5, "name": "xe-0/0/22", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4601,7 +4601,7 @@ "fields": { "device": 5, "name": "xe-0/0/23", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4612,7 +4612,7 @@ "fields": { "device": 5, "name": "xe-0/0/24", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4623,7 +4623,7 @@ "fields": { "device": 5, "name": "xe-0/0/25", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4634,7 +4634,7 @@ "fields": { "device": 5, "name": "xe-0/0/26", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4645,7 +4645,7 @@ "fields": { "device": 5, "name": "xe-0/0/27", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4656,7 +4656,7 @@ "fields": { "device": 5, "name": "xe-0/0/28", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4667,7 +4667,7 @@ "fields": { "device": 5, "name": "xe-0/0/29", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4678,7 +4678,7 @@ "fields": { "device": 5, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4689,7 +4689,7 @@ "fields": { "device": 5, "name": "xe-0/0/30", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4700,7 +4700,7 @@ "fields": { "device": 5, "name": "xe-0/0/31", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4711,7 +4711,7 @@ "fields": { "device": 5, "name": "xe-0/0/32", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4722,7 +4722,7 @@ "fields": { "device": 5, "name": "xe-0/0/33", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4733,7 +4733,7 @@ "fields": { "device": 5, "name": "xe-0/0/34", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4744,7 +4744,7 @@ "fields": { "device": 5, "name": "xe-0/0/35", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4755,7 +4755,7 @@ "fields": { "device": 5, "name": "xe-0/0/36", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4766,7 +4766,7 @@ "fields": { "device": 5, "name": "xe-0/0/37", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4777,7 +4777,7 @@ "fields": { "device": 5, "name": "xe-0/0/38", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4788,7 +4788,7 @@ "fields": { "device": 5, "name": "xe-0/0/39", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4799,7 +4799,7 @@ "fields": { "device": 5, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4810,7 +4810,7 @@ "fields": { "device": 5, "name": "xe-0/0/40", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4821,7 +4821,7 @@ "fields": { "device": 5, "name": "xe-0/0/41", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4832,7 +4832,7 @@ "fields": { "device": 5, "name": "xe-0/0/42", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4843,7 +4843,7 @@ "fields": { "device": 5, "name": "xe-0/0/43", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4854,7 +4854,7 @@ "fields": { "device": 5, "name": "xe-0/0/44", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4865,7 +4865,7 @@ "fields": { "device": 5, "name": "xe-0/0/45", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4876,7 +4876,7 @@ "fields": { "device": 5, "name": "xe-0/0/46", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4887,7 +4887,7 @@ "fields": { "device": 5, "name": "xe-0/0/47", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4898,7 +4898,7 @@ "fields": { "device": 5, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4909,7 +4909,7 @@ "fields": { "device": 5, "name": "xe-0/0/6", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4920,7 +4920,7 @@ "fields": { "device": 5, "name": "xe-0/0/7", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4931,7 +4931,7 @@ "fields": { "device": 5, "name": "xe-0/0/8", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4942,7 +4942,7 @@ "fields": { "device": 5, "name": "xe-0/0/9", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -4953,7 +4953,7 @@ "fields": { "device": 6, "name": "em0", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -4964,7 +4964,7 @@ "fields": { "device": 6, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4975,7 +4975,7 @@ "fields": { "device": 6, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4986,7 +4986,7 @@ "fields": { "device": 6, "name": "et-0/0/10", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -4997,7 +4997,7 @@ "fields": { "device": 6, "name": "et-0/0/11", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5008,7 +5008,7 @@ "fields": { "device": 6, "name": "et-0/0/12", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5019,7 +5019,7 @@ "fields": { "device": 6, "name": "et-0/0/13", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5030,7 +5030,7 @@ "fields": { "device": 6, "name": "et-0/0/14", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5041,7 +5041,7 @@ "fields": { "device": 6, "name": "et-0/0/15", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5052,7 +5052,7 @@ "fields": { "device": 6, "name": "et-0/0/16", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5063,7 +5063,7 @@ "fields": { "device": 6, "name": "et-0/0/17", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5074,7 +5074,7 @@ "fields": { "device": 6, "name": "et-0/0/18", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5085,7 +5085,7 @@ "fields": { "device": 6, "name": "et-0/0/19", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5096,7 +5096,7 @@ "fields": { "device": 6, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5107,7 +5107,7 @@ "fields": { "device": 6, "name": "et-0/0/20", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5118,7 +5118,7 @@ "fields": { "device": 6, "name": "et-0/0/21", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5129,7 +5129,7 @@ "fields": { "device": 6, "name": "et-0/0/22", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5140,7 +5140,7 @@ "fields": { "device": 6, "name": "et-0/0/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5151,7 +5151,7 @@ "fields": { "device": 6, "name": "et-0/0/4", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5162,7 +5162,7 @@ "fields": { "device": 6, "name": "et-0/0/5", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5173,7 +5173,7 @@ "fields": { "device": 6, "name": "et-0/0/6", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5184,7 +5184,7 @@ "fields": { "device": 6, "name": "et-0/0/7", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5195,7 +5195,7 @@ "fields": { "device": 6, "name": "et-0/0/8", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5206,7 +5206,7 @@ "fields": { "device": 6, "name": "et-0/0/9", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5217,7 +5217,7 @@ "fields": { "device": 6, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5228,7 +5228,7 @@ "fields": { "device": 6, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5239,7 +5239,7 @@ "fields": { "device": 6, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5250,7 +5250,7 @@ "fields": { "device": 6, "name": "et-0/1/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5261,7 +5261,7 @@ "fields": { "device": 6, "name": "et-0/2/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5272,7 +5272,7 @@ "fields": { "device": 6, "name": "et-0/2/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5283,7 +5283,7 @@ "fields": { "device": 6, "name": "et-0/2/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5294,7 +5294,7 @@ "fields": { "device": 6, "name": "et-0/2/3", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5305,7 +5305,7 @@ "fields": { "device": 7, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5316,7 +5316,7 @@ "fields": { "device": 7, "name": "fxp0 (RE1)", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -5327,7 +5327,7 @@ "fields": { "device": 7, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -5338,7 +5338,7 @@ "fields": { "device": 8, "name": "fxp0 (RE0)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5349,7 +5349,7 @@ "fields": { "device": 8, "name": "fxp0 (RE1)", - "form_factor": 1000, + "type": 1000, "mgmt_only": true, "description": "" } @@ -5360,7 +5360,7 @@ "fields": { "device": 8, "name": "lo0", - "form_factor": 0, + "type": 0, "mgmt_only": false, "description": "" } @@ -5371,7 +5371,7 @@ "fields": { "device": 2, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5382,7 +5382,7 @@ "fields": { "device": 2, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5393,7 +5393,7 @@ "fields": { "device": 2, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5404,7 +5404,7 @@ "fields": { "device": 2, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5415,7 +5415,7 @@ "fields": { "device": 2, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5426,7 +5426,7 @@ "fields": { "device": 2, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5437,7 +5437,7 @@ "fields": { "device": 8, "name": "et-0/0/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5448,7 +5448,7 @@ "fields": { "device": 8, "name": "et-0/0/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5459,7 +5459,7 @@ "fields": { "device": 8, "name": "et-0/0/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5470,7 +5470,7 @@ "fields": { "device": 8, "name": "et-0/1/0", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5481,7 +5481,7 @@ "fields": { "device": 8, "name": "et-0/1/1", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5492,7 +5492,7 @@ "fields": { "device": 8, "name": "et-0/1/2", - "form_factor": 1400, + "type": 1400, "mgmt_only": false, "description": "" } @@ -5503,7 +5503,7 @@ "fields": { "device": 2, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5514,7 +5514,7 @@ "fields": { "device": 2, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5525,7 +5525,7 @@ "fields": { "device": 2, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5536,7 +5536,7 @@ "fields": { "device": 2, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5547,7 +5547,7 @@ "fields": { "device": 2, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5558,7 +5558,7 @@ "fields": { "device": 2, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5569,7 +5569,7 @@ "fields": { "device": 8, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5580,7 +5580,7 @@ "fields": { "device": 8, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5591,7 +5591,7 @@ "fields": { "device": 8, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5602,7 +5602,7 @@ "fields": { "device": 8, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5613,7 +5613,7 @@ "fields": { "device": 8, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5624,7 +5624,7 @@ "fields": { "device": 8, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5635,7 +5635,7 @@ "fields": { "device": 7, "name": "xe-0/0/0", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5646,7 +5646,7 @@ "fields": { "device": 7, "name": "xe-0/0/1", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5657,7 +5657,7 @@ "fields": { "device": 7, "name": "xe-0/0/2", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5668,7 +5668,7 @@ "fields": { "device": 7, "name": "xe-0/0/3", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5679,7 +5679,7 @@ "fields": { "device": 7, "name": "xe-0/0/4", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5690,7 +5690,7 @@ "fields": { "device": 7, "name": "xe-0/0/5", - "form_factor": 1200, + "type": 1200, "mgmt_only": false, "description": "" } @@ -5701,7 +5701,7 @@ "fields": { "device": 9, "name": "eth0", - "form_factor": 1000, + "type": 1000, "mac_address": "44-55-66-77-88-99", "mgmt_only": true, "description": "" @@ -5713,7 +5713,7 @@ "fields": { "device": 11, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } @@ -5724,7 +5724,7 @@ "fields": { "device": 12, "name": "Net", - "form_factor": 800, + "type": 800, "mgmt_only": true, "description": "" } diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 71ad45409..db3b78dd3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,27 +7,30 @@ from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from mptt.forms import TreeNodeChoiceField +from netaddr import EUI +from netaddr.core import AddrFormatError from taggit.forms import TagField from timezone_field import TimeZoneFormField +from circuits.models import Circuit, Provider from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm -from tenancy.models import Tenant +from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, - BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, - ComponentForm, ConfirmationForm, ContentTypeSelect, CSVChoiceField, ExpandableNameField, - FilterChoiceField, FlexibleModelChoiceField, JSONField, SelectWithPK, SmallTextarea, SlugField, - StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES + BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, ComponentForm, + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FlexibleModelChoiceField, JSONField, + SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES ) from virtualization.models import Cluster, ClusterGroup from .constants import * from .models import ( Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis + InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, + Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, ) DEVICE_BY_PK_RE = r'{\d+\}' @@ -75,6 +78,28 @@ class BulkRenameForm(forms.Form): }) +# +# Fields +# + +class MACAddressField(forms.Field): + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value + + # # Regions # @@ -256,8 +281,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): +class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site + field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -276,16 +302,6 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): value_field="slug", ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) # @@ -596,8 +612,9 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): +class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -608,27 +625,23 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): widget=APISelectMultiple( api_url="/api/dcim/sites/", value_field="slug", + filter_for={ + 'group_id': 'site' + } ) ) - group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site'), + group_id = ChainedModelChoiceField( label='Rack group', - null_label='-- None --', + queryset=RackGroup.objects.select_related('site'), + chains=( + ('site', 'site'), + ), + required=False, widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=RACK_STATUS_CHOICES, required=False, @@ -689,40 +702,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): return unit_choices -class RackReservationFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField( - required=False, - label='Search' - ) - site = FilterChoiceField( - queryset=Site.objects.all(), - to_field_name='slug', - widget=APISelectMultiple( - api_url="/api/dcim/sites/", - value_field="slug", - ) - ) - group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site'), - label='Rack group', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", - null_option=True, - ) - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) - - class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackReservation.objects.all(), @@ -751,6 +730,31 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = [] +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): + field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + group_id = FilterChoiceField( + queryset=RackGroup.objects.select_related('site'), + label='Rack group', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) + ) + + # # Manufacturers # @@ -963,7 +967,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'maximum_draw', 'allocated_draw', ] widgets = { 'device_type': forms.HiddenInput(), @@ -974,6 +978,16 @@ class PowerPortTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum current draw (watts)" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated current draw (watts)" + ) class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): @@ -981,7 +995,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', + 'device_type', 'name', 'power_port', 'feed_leg', ] widgets = { 'device_type': forms.HiddenInput(), @@ -992,6 +1006,24 @@ class PowerOutletTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + power_port = forms.ModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_LEG_CHOICES), + required=False, + widget=StaticSelect2() + ) + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Limit power_port choices to current DeviceType + self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( + device_type=self.parent + ) class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): @@ -999,11 +1031,11 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'form_factor', 'mgmt_only', + 'device_type', 'name', 'type', 'mgmt_only', ] widgets = { 'device_type': forms.HiddenInput(), - 'form_factor': StaticSelect2(), + 'type': StaticSelect2(), } @@ -1011,8 +1043,8 @@ class InterfaceTemplateCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2() ) mgmt_only = forms.BooleanField( @@ -1026,8 +1058,8 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) - form_factor = forms.ChoiceField( - choices=add_blank_choice(IFACE_FF_CHOICES), + type = forms.ChoiceField( + choices=add_blank_choice(IFACE_TYPE_CHOICES), required=False, widget=StaticSelect2() ) @@ -1246,7 +1278,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): required=False, widget=APISelect( api_url='/api/dcim/racks/', - display_field='display_name', + display_field='display_name' ) ) position = forms.TypedChoiceField( @@ -1643,8 +1675,12 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ] -class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): +class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Device + field_order = [ + 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', + 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + ] q = forms.CharField( required=False, label='Search' @@ -1702,16 +1738,6 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): value_field="slug", ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) manufacturer_id = FilterChoiceField( queryset=Manufacturer.objects.all(), label='Manufacturer', @@ -1758,6 +1784,13 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + virtual_chassis_member = forms.NullBooleanField( + required=False, + label='Virtual chassis member', + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', @@ -1817,8 +1850,8 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2() ) enabled = forms.BooleanField( @@ -1853,7 +1886,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1864,6 +1897,10 @@ class ConsolePortCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( required=False ) @@ -1881,7 +1918,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1892,11 +1929,31 @@ class ConsoleServerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( required=False ) +class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ConsoleServerPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'description', + ] + + class ConsoleServerPortBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -1923,7 +1980,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'maximum_draw', 'allocated_draw', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1934,6 +1991,20 @@ class PowerPortCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + maximum_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Maximum draw in watts" + ) + allocated_draw = forms.IntegerField( + min_value=1, + required=False, + help_text="Allocated draw in watts" + ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( required=False ) @@ -1944,6 +2015,10 @@ class PowerPortCreateForm(ComponentForm): # class PowerOutletForm(BootstrapMixin, forms.ModelForm): + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) tags = TagField( required=False ) @@ -1951,21 +2026,69 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'power_port', 'feed_leg', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), } + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port choices to the local device + if hasattr(self.instance, 'device'): + self.fields['power_port'].queryset = PowerPort.objects.filter( + device=self.instance.device + ) + class PowerOutletCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) + power_port = forms.ModelChoiceField( + queryset=PowerPort.objects.all(), + required=False + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_LEG_CHOICES), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) tags = TagField( required=False ) + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Limit power_port choices to those on the parent device + self.fields['power_port'].queryset = PowerPort.objects.filter(device=self.parent) + + +class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput() + ) + feed_leg = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_LEG_CHOICES), + required=False, + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'feed_leg', 'description', + ] + class PowerOutletBulkRenameForm(BulkRenameForm): pk = forms.ModelMultipleChoiceField( @@ -1993,12 +2116,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'form_factor', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', + 'device', 'name', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), - 'form_factor': StaticSelect2(), + 'type': StaticSelect2(), 'lag': StaticSelect2(), 'mode': StaticSelect2(), } @@ -2016,12 +2139,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): if self.is_bound: device = Device.objects.get(pk=self.data['device']) self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], form_factor=IFACE_FF_LAG + device__in=[device, device.get_vc_master()], type=IFACE_TYPE_LAG ) else: device = self.instance.device self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.instance.device, self.instance.device.get_vc_master()], form_factor=IFACE_FF_LAG + device__in=[self.instance.device, self.instance.device.get_vc_master()], type=IFACE_TYPE_LAG ) def clean(self): @@ -2133,8 +2256,8 @@ class InterfaceCreateForm(ComponentForm, forms.Form): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=IFACE_FF_CHOICES, + type = forms.ChoiceField( + choices=IFACE_TYPE_CHOICES, widget=StaticSelect2(), ) enabled = forms.BooleanField( @@ -2185,7 +2308,7 @@ class InterfaceCreateForm(ComponentForm, forms.Form): # Limit LAG choices to interfaces belonging to this device (or its VC master) if self.parent is not None: self.fields['lag'].queryset = Interface.objects.filter( - device__in=[self.parent, self.parent.get_vc_master()], form_factor=IFACE_FF_LAG + device__in=[self.parent, self.parent.get_vc_master()], type=IFACE_TYPE_LAG ) else: self.fields['lag'].queryset = Interface.objects.none() @@ -2196,8 +2319,8 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() ) - form_factor = forms.ChoiceField( - choices=add_blank_choice(IFACE_FF_CHOICES), + type = forms.ChoiceField( + choices=add_blank_choice(IFACE_TYPE_CHOICES), required=False, widget=StaticSelect2() ) @@ -2249,7 +2372,7 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): if device is not None: self.fields['lag'].queryset = Interface.objects.filter( device__in=[device, device.get_vc_master()], - form_factor=IFACE_FF_LAG + type=IFACE_TYPE_LAG ) else: self.fields['lag'].choices = [] @@ -2472,7 +2595,10 @@ class RearPortBulkDisconnectForm(ConfirmationForm): # Cables # -class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): +class ConnectCableToDeviceForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + """ + Base form for connecting a Cable to a Device component + """ termination_b_site = forms.ModelChoiceField( queryset=Site.objects.all(), label='Site', @@ -2518,39 +2644,196 @@ class CableCreateForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): } ) ) - termination_b_type = forms.ModelChoiceField( - queryset=ContentType.objects.all(), - label='Type', - widget=ContentTypeSelect() - ) + + class Meta: + model = Cable + fields = [ + 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_id', 'type', 'status', + 'label', 'color', 'length', 'length_unit', + ] + + +class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): termination_b_id = forms.IntegerField( label='Name', widget=APISelect( - api_url='/api/dcim/{{termination_b_type}}s/', + api_url='/api/dcim/console-ports/', disabled_indicator='cable', - conditional_query_params={ - 'termination_b_type__interface': 'type=physical', + ) + ) + + +class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/console-server-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-outlets/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/interfaces/', + disabled_indicator='cable', + additional_query_params={ + 'kind': 'physical', } ) ) + +class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/front-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToRearPortForm(ConnectCableToDeviceForm): + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/rear-ports/', + disabled_indicator='cable', + ) + ) + + +class ConnectCableToCircuitTerminationForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_provider = forms.ModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + widget=APISelect( + api_url='/api/circuits/providers/', + filter_for={ + 'termination_b_circuit': 'provider_id', + } + ) + ) + termination_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + widget=APISelect( + api_url='/api/dcim/sites/', + filter_for={ + 'termination_b_circuit': 'site_id', + } + ) + ) + termination_b_circuit = ChainedModelChoiceField( + queryset=Circuit.objects.all(), + chains=( + ('provider', 'termination_b_provider'), + ), + label='Circuit', + widget=APISelect( + api_url='/api/circuits/circuits/', + display_field='cid', + filter_for={ + 'termination_b_id': 'circuit_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Side', + widget=APISelect( + api_url='/api/circuits/circuit-terminations/', + disabled_indicator='cable', + display_field='term_side' + ) + ) + class Meta: model = Cable fields = [ - 'termination_b_site', 'termination_b_rack', 'termination_b_device', 'termination_b_type', - 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', + 'termination_b_provider', 'termination_b_site', 'termination_b_circuit', 'termination_b_id', 'type', + 'status', 'label', 'color', 'length', 'length_unit', ] - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Define available types for endpoint B based on the type of endpoint A - termination_a_type = self.instance.termination_a._meta.model_name - self.fields['termination_b_type'].queryset = ContentType.objects.filter( - model__in=COMPATIBLE_TERMINATION_TYPES.get(termination_a_type) - ).exclude( - model='circuittermination' +class ConnectCableToPowerFeedForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelForm): + termination_b_site = forms.ModelChoiceField( + queryset=Site.objects.all(), + label='Site', + widget=APISelect( + api_url='/api/dcim/sites/', + display_field='cid', + filter_for={ + 'termination_b_rackgroup': 'site_id', + 'termination_b_powerpanel': 'site_id', + } ) + ) + termination_b_rackgroup = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + label='Rack Group', + chains=( + ('site', 'termination_b_site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/', + display_field='cid', + filter_for={ + 'termination_b_powerpanel': 'rackgroup_id', + } + ) + ) + termination_b_powerpanel = ChainedModelChoiceField( + queryset=PowerPanel.objects.all(), + chains=( + ('site', 'termination_b_site'), + ('rack_group', 'termination_b_rackgroup'), + ), + label='Power Panel', + widget=APISelect( + api_url='/api/dcim/power-panels/', + filter_for={ + 'termination_b_id': 'power_panel_id', + } + ) + ) + termination_b_id = forms.IntegerField( + label='Name', + widget=APISelect( + api_url='/api/dcim/power-feeds/', + ) + ) + + class Meta: + model = Cable + fields = [ + 'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'color', 'length', 'length_unit', + ] class CableForm(BootstrapMixin, forms.ModelForm): @@ -2784,7 +3067,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBay fields = [ - 'device', 'name', 'tags', + 'device', 'name', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3105,9 +3388,384 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + tenant_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + null_option=True, + filter_for={ + 'tenant': 'group' + } + ) ) tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) + ) + + +# +# Power panels +# + +class PowerPanelForm(BootstrapMixin, forms.ModelForm): + rack_group = ChainedModelChoiceField( + queryset=RackGroup.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + widget=APISelect( + api_url='/api/dcim/rack-groups/', + ) + ) + + class Meta: + model = PowerPanel + fields = [ + 'site', 'rack_group', 'name', + ] + widgets = { + 'site': APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'rack_group': 'site_id', + } + ), + } + + +class PowerPanelCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + rack_group_name = forms.CharField( + required=False, + help_text="Rack group name (optional)" + ) + + class Meta: + model = PowerPanel + fields = PowerPanel.csv_headers + + def clean(self): + + super().clean() + + site = self.cleaned_data.get('site') + rack_group_name = self.cleaned_data.get('rack_group_name') + + # Validate rack group + if rack_group_name: + try: + self.instance.rack_group = RackGroup.objects.get(site=site, name=rack_group_name) + except RackGroup.DoesNotExist: + raise forms.ValidationError( + "Rack group {} not found in site {}".format(rack_group_name, site) + ) + + +class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = PowerPanel + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'rack_group_id': 'site', + } + ) + ) + rack_group_id = FilterChoiceField( + queryset=RackGroup.objects.all(), + label='Rack group (ID)', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) + ) + + +# +# Power feeds +# + +class PowerFeedForm(BootstrapMixin, CustomFieldForm): + site = ChainedModelChoiceField( + queryset=Site.objects.all(), + required=False, + widget=APISelect( + api_url='/api/dcim/sites/', + filter_for={ + 'power_panel': 'site_id', + 'rack': 'site_id', + } + ) + ) + comments = CommentField() + tags = TagField( + required=False + ) + + class Meta: + model = PowerFeed + fields = [ + 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'comments', 'tags', + ] + widgets = { + 'power_panel': APISelect( + api_url="/api/dcim/power-panels/" + ), + 'rack': APISelect( + api_url="/api/dcim/racks/" + ), + 'status': StaticSelect2(), + 'type': StaticSelect2(), + 'supply': StaticSelect2(), + 'phase': StaticSelect2(), + } + + def __init__(self, *args, **kwargs): + + super().__init__(*args, **kwargs) + + # Initialize site field + if self.instance and hasattr(self.instance, 'power_panel'): + self.initial['site'] = self.instance.power_panel.site + + +class PowerFeedCSVForm(forms.ModelForm): + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Name of parent site', + error_messages={ + 'invalid_choice': 'Site not found.', + } + ) + panel_name = forms.ModelChoiceField( + queryset=PowerPanel.objects.all(), + to_field_name='name', + help_text='Name of upstream power panel', + error_messages={ + 'invalid_choice': 'Power panel not found.', + } + ) + rack_group = forms.CharField( + required=False, + help_text="Rack group name (optional)" + ) + rack_name = forms.CharField( + required=False, + help_text="Rack name (optional)" + ) + status = CSVChoiceField( + choices=POWERFEED_STATUS_CHOICES, + required=False, + help_text='Operational status' + ) + type = CSVChoiceField( + choices=POWERFEED_TYPE_CHOICES, + required=False, + help_text='Primary or redundant' + ) + supply = CSVChoiceField( + choices=POWERFEED_SUPPLY_CHOICES, + required=False, + help_text='AC/DC' + ) + phase = CSVChoiceField( + choices=POWERFEED_PHASE_CHOICES, + required=False, + help_text='Single or three-phase' + ) + + class Meta: + model = PowerFeed + fields = PowerFeed.csv_headers + + def clean(self): + + super().clean() + + site = self.cleaned_data.get('site') + panel_name = self.cleaned_data.get('panel_name') + rack_group = self.cleaned_data.get('rack_group') + rack_name = self.cleaned_data.get('rack_name') + + # Validate power panel + if panel_name: + try: + self.instance.power_panel = PowerPanel.objects.get(site=site, name=panel_name) + except Rack.DoesNotExist: + raise forms.ValidationError( + "Power panel {} not found in site {}".format(panel_name, site) + ) + + # Validate rack + if rack_name: + try: + self.instance.rack = Rack.objects.get(site=site, group__name=rack_group, name=rack_name) + except Rack.DoesNotExist: + raise forms.ValidationError( + "Rack {} not found in site {}, group {}".format(rack_name, site, rack_group) + ) + + +class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerFeed.objects.all(), + widget=forms.MultipleHiddenInput + ) + powerpanel = forms.ModelChoiceField( + queryset=PowerPanel.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/power-panels/", + filter_for={ + 'rackgroup': 'site_id', + } + ) + ) + rack = forms.ModelChoiceField( + queryset=Rack.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/racks", + ) + ) + status = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_STATUS_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + type = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_TYPE_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_PHASE_CHOICES), + required=False, + initial='', + widget=StaticSelect2() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + max_utilization = forms.IntegerField( + required=False + ) + comments = forms.CharField( + required=False + ) + + class Meta: + nullable_fields = [ + 'rackgroup', 'comments', + ] + + +class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = PowerFeed + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'power_panel_id': 'site', + 'rack_id': 'site', + } + ) + ) + power_panel_id = FilterChoiceField( + queryset=PowerPanel.objects.all(), + label='Power panel', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/power-panels/", + null_option=True, + ) + ) + rack_id = FilterChoiceField( + queryset=Rack.objects.all(), + label='Rack', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/racks/", + null_option=True, + ) + ) + status = forms.MultipleChoiceField( + choices=POWERFEED_STATUS_CHOICES, + required=False, + widget=StaticSelect2Multiple() + ) + type = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_TYPE_CHOICES), + required=False, + widget=StaticSelect2() + ) + supply = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_SUPPLY_CHOICES), + required=False, + widget=StaticSelect2() + ) + phase = forms.ChoiceField( + choices=add_blank_choice(POWERFEED_PHASE_CHOICES), + required=False, + widget=StaticSelect2() + ) + voltage = forms.IntegerField( + required=False + ) + amperage = forms.IntegerField( + required=False + ) + max_utilization = forms.IntegerField( + required=False ) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index 53f627a5b..e1124b84e 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -21,7 +21,7 @@ class InterfaceQuerySet(QuerySet): Return only physical interfaces which are capable of being connected to other interfaces (i.e. not virtual or wireless). """ - return self.exclude(form_factor__in=NONCONNECTABLE_IFACE_TYPES) + return self.exclude(type__in=NONCONNECTABLE_IFACE_TYPES) class InterfaceManager(Manager): diff --git a/netbox/dcim/migrations/0066_cables.py b/netbox/dcim/migrations/0066_cables.py index 253167392..096344a06 100644 --- a/netbox/dcim/migrations/0066_cables.py +++ b/netbox/dcim/migrations/0066_cables.py @@ -174,8 +174,8 @@ class Migration(migrations.Migration): ('length', models.PositiveSmallIntegerField(blank=True, null=True)), ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)), ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)), - ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), - ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), + ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), ], ), migrations.AlterUniqueTogether( diff --git a/netbox/dcim/migrations/0070_custom_tag_models.py b/netbox/dcim/migrations/0070_custom_tag_models.py new file mode 100644 index 000000000..ee78bed02 --- /dev/null +++ b/netbox/dcim/migrations/0070_custom_tag_models.py @@ -0,0 +1,85 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0069_deprecate_nullablecharfield'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='consoleport', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='consoleserverport', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='device', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='devicebay', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='devicetype', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='frontport', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='interface', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='inventoryitem', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='poweroutlet', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='powerport', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='rack', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='rearport', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='site', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='virtualchassis', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/dcim/migrations/0071_device_components_add_description.py b/netbox/dcim/migrations/0071_device_components_add_description.py new file mode 100644 index 000000000..d38f8a88b --- /dev/null +++ b/netbox/dcim/migrations/0071_device_components_add_description.py @@ -0,0 +1,38 @@ +# Generated by Django 2.1.7 on 2019-02-20 18:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0070_custom_tag_models'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='consoleserverport', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='devicebay', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='poweroutlet', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + migrations.AddField( + model_name='powerport', + name='description', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/dcim/migrations/0072_powerfeeds.py b/netbox/dcim/migrations/0072_powerfeeds.py new file mode 100644 index 000000000..63662684d --- /dev/null +++ b/netbox/dcim/migrations/0072_powerfeeds.py @@ -0,0 +1,134 @@ +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0021_add_color_comments_changelog_to_tag'), + ('dcim', '0071_device_components_add_description'), + ] + + operations = [ + migrations.CreateModel( + name='PowerFeed', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=50)), + ('status', models.PositiveSmallIntegerField(default=1)), + ('type', models.PositiveSmallIntegerField(default=1)), + ('supply', models.PositiveSmallIntegerField(default=1)), + ('phase', models.PositiveSmallIntegerField(default=1)), + ('voltage', models.PositiveSmallIntegerField(default=120, validators=[django.core.validators.MinValueValidator(1)])), + ('amperage', models.PositiveSmallIntegerField(default=20, validators=[django.core.validators.MinValueValidator(1)])), + ('max_utilization', models.PositiveSmallIntegerField(default=80, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)])), + ('available_power', models.PositiveSmallIntegerField(default=0, editable=False)), + ('comments', models.TextField(blank=True)), + ('cable', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.Cable')), + ], + options={ + 'ordering': ['power_panel', 'name'], + }, + ), + migrations.CreateModel( + name='PowerPanel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=50)), + ('rack_group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.RackGroup')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='dcim.Site')), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.AddField( + model_name='powerfeed', + name='power_panel', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='powerfeeds', to='dcim.PowerPanel'), + ), + migrations.AddField( + model_name='powerfeed', + name='rack', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='dcim.Rack'), + ), + migrations.AddField( + model_name='powerfeed', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AddField( + model_name='powerfeed', + name='connected_endpoint', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerPort'), + ), + migrations.AddField( + model_name='powerfeed', + name='connection_status', + field=models.NullBooleanField(), + ), + migrations.RenameField( + model_name='powerport', + old_name='connected_endpoint', + new_name='_connected_poweroutlet', + ), + migrations.AddField( + model_name='powerport', + name='_connected_powerfeed', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='dcim.PowerFeed'), + ), + migrations.AddField( + model_name='powerport', + name='allocated_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerport', + name='maximum_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerporttemplate', + name='allocated_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AddField( + model_name='powerporttemplate', + name='maximum_draw', + field=models.PositiveSmallIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)]), + ), + migrations.AlterUniqueTogether( + name='powerpanel', + unique_together={('site', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerfeed', + unique_together={('power_panel', 'name')}, + ), + migrations.AddField( + model_name='poweroutlet', + name='feed_leg', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='power_port', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlets', to='dcim.PowerPort'), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='feed_leg', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='power_port', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='poweroutlet_templates', to='dcim.PowerPortTemplate'), + ), + ] diff --git a/netbox/dcim/migrations/0073_interface_form_factor_to_type.py b/netbox/dcim/migrations/0073_interface_form_factor_to_type.py new file mode 100644 index 000000000..38016ea00 --- /dev/null +++ b/netbox/dcim/migrations/0073_interface_form_factor_to_type.py @@ -0,0 +1,23 @@ +# Generated by Django 2.1.7 on 2019-04-12 17:27 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0072_powerfeeds'), + ] + + operations = [ + migrations.RenameField( + model_name='interface', + old_name='form_factor', + new_name='type', + ), + migrations.RenameField( + model_name='interfacetemplate', + old_name='form_factor', + new_name='type', + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f8e8a028e..0a1b52979 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -9,13 +9,13 @@ from django.contrib.postgres.fields import ArrayField, JSONField from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Count, Q +from django.db.models import Case, Count, Q, Sum, When, F, Subquery, OuterRef from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField -from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange +from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem from utilities.fields import ColorField from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel @@ -46,6 +46,10 @@ class ComponentTemplateModel(models.Model): class ComponentModel(models.Model): + description = models.CharField( + max_length=100, + blank=True + ) class Meta: abstract = True @@ -319,7 +323,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', @@ -359,32 +363,6 @@ class Site(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return STATUS_CLASSES[self.status] - @property - def count_prefixes(self): - return self.prefixes.count() - - @property - def count_vlans(self): - return self.vlans.count() - - @property - def count_racks(self): - return Rack.objects.filter(site=self).count() - - @property - def count_devices(self): - return Device.objects.filter(site=self).count() - - @property - def count_circuits(self): - from circuits.models import Circuit - return Circuit.objects.filter(terminations__site=self).count() - - @property - def count_vms(self): - from virtualization.models import VirtualMachine - return VirtualMachine.objects.filter(cluster__site=self).count() - # # Racks @@ -566,7 +544,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'site', 'group_name', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', @@ -756,6 +734,25 @@ class Rack(ChangeLoggedModel, CustomFieldModel): u_available = len(self.get_available_units()) return int(float(self.u_height - u_available) / self.u_height * 100) + def get_power_utilization(self): + """ + Determine the utilization rate of power in the rack and return it as a percentage. + """ + power_stats = PowerFeed.objects.filter( + rack=self + ).annotate( + allocated_draw_total=Sum('connected_endpoint__poweroutlets__connected_endpoint__allocated_draw'), + ).values( + 'allocated_draw_total', + 'available_power' + ) + + if power_stats: + allocated_draw_total = sum(x['allocated_draw_total'] for x in power_stats) + available_power_total = sum(x['available_power'] for x in power_stats) + return int(allocated_draw_total / available_power_total * 100) or 0 + return 0 + class RackReservation(ChangeLoggedModel): """ @@ -914,7 +911,7 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'comments', @@ -1049,6 +1046,18 @@ class PowerPortTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + maximum_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Maximum current draw (watts)" + ) + allocated_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Allocated current draw (watts)" + ) objects = NaturalOrderingManager() @@ -1072,6 +1081,19 @@ class PowerOutletTemplate(ComponentTemplateModel): name = models.CharField( max_length=50 ) + power_port = models.ForeignKey( + to='dcim.PowerPortTemplate', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='poweroutlet_templates' + ) + feed_leg = models.PositiveSmallIntegerField( + choices=POWERFEED_LEG_CHOICES, + blank=True, + null=True, + help_text="Phase (for three-phase feeds)" + ) objects = NaturalOrderingManager() @@ -1082,6 +1104,14 @@ class PowerOutletTemplate(ComponentTemplateModel): def __str__(self): return self.name + def clean(self): + + # Validate power port assignment + if self.power_port and self.power_port.device_type != self.device_type: + raise ValidationError( + "Parent power port ({}) must belong to the same device type".format(self.power_port) + ) + class InterfaceTemplate(ComponentTemplateModel): """ @@ -1095,9 +1125,9 @@ class InterfaceTemplate(ComponentTemplateModel): name = models.CharField( max_length=64 ) - form_factor = models.PositiveSmallIntegerField( - choices=IFACE_FF_CHOICES, - default=IFACE_FF_10GE_SFP_PLUS + type = models.PositiveSmallIntegerField( + choices=IFACE_TYPE_CHOICES, + default=IFACE_TYPE_10GE_SFP_PLUS ) mgmt_only = models.BooleanField( default=False, @@ -1113,6 +1143,22 @@ class InterfaceTemplate(ComponentTemplateModel): def __str__(self): return self.name + # TODO: Remove in v2.7 + @property + def form_factor(self): + """ + Backward-compatibility for form_factor + """ + return self.type + + # TODO: Remove in v2.7 + @form_factor.setter + def form_factor(self, value): + """ + Backward-compatibility for form_factor + """ + self.type = value + class FrontPortTemplate(ComponentTemplateModel): """ @@ -1455,7 +1501,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag', 'status', @@ -1610,7 +1656,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): self.device_type.poweroutlet_templates.all()] ) Interface.objects.bulk_create( - [Interface(device=self, name=template.name, form_factor=template.form_factor, + [Interface(device=self, name=template.name, type=template.type, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] ) RearPort.objects.bulk_create([ @@ -1758,9 +1804,9 @@ class ConsolePort(CableTermination, ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'description'] class Meta: ordering = ['device', 'name'] @@ -1776,6 +1822,7 @@ class ConsolePort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.description, ) @@ -1801,9 +1848,9 @@ class ConsoleServerPort(CableTermination, ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'description'] class Meta: unique_together = ['device', 'name'] @@ -1818,6 +1865,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.description, ) @@ -1837,22 +1885,41 @@ class PowerPort(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) - connected_endpoint = models.OneToOneField( + maximum_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Maximum current draw (watts)" + ) + allocated_draw = models.PositiveSmallIntegerField( + blank=True, + null=True, + validators=[MinValueValidator(1)], + help_text="Allocated current draw (watts)" + ) + _connected_poweroutlet = models.OneToOneField( to='dcim.PowerOutlet', on_delete=models.SET_NULL, related_name='connected_endpoint', blank=True, null=True ) + _connected_powerfeed = models.OneToOneField( + to='dcim.PowerFeed', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'maximum_draw', 'allocated_draw', 'description'] class Meta: ordering = ['device', 'name'] @@ -1868,8 +1935,76 @@ class PowerPort(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.maximum_draw, + self.allocated_draw, + self.description, ) + @property + def connected_endpoint(self): + if self._connected_poweroutlet: + return self._connected_poweroutlet + return self._connected_powerfeed + + @connected_endpoint.setter + def connected_endpoint(self, value): + if value is None: + self._connected_poweroutlet = None + self._connected_powerfeed = None + elif isinstance(value, PowerOutlet): + self._connected_poweroutlet = value + self._connected_powerfeed = None + elif isinstance(value, PowerFeed): + self._connected_poweroutlet = None + self._connected_powerfeed = value + else: + raise ValueError( + "Connected endpoint must be a PowerOutlet or PowerFeed, not {}.".format(type(value)) + ) + + def get_power_draw(self): + """ + Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. + """ + # Calculate aggregate draw of all child power outlets if no numbers have been defined manually + if self.allocated_draw is None and self.maximum_draw is None: + outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) + utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + maximum_draw_total=Sum('maximum_draw'), + allocated_draw_total=Sum('allocated_draw'), + ) + ret = { + 'allocated': utilization['allocated_draw_total'] or 0, + 'maximum': utilization['maximum_draw_total'] or 0, + 'outlet_count': len(outlet_ids), + 'legs': [], + } + + # Calculate per-leg aggregates for three-phase feeds + if self._connected_powerfeed and self._connected_powerfeed.phase == POWERFEED_PHASE_3PHASE: + for leg, leg_name in POWERFEED_LEG_CHOICES: + outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) + utilization = PowerPort.objects.filter(_connected_poweroutlet_id__in=outlet_ids).aggregate( + maximum_draw_total=Sum('maximum_draw'), + allocated_draw_total=Sum('allocated_draw'), + ) + ret['legs'].append({ + 'name': leg_name, + 'allocated': utilization['allocated_draw_total'] or 0, + 'maximum': utilization['maximum_draw_total'] or 0, + 'outlet_count': len(outlet_ids), + }) + + return ret + + # Default to administratively defined values + return { + 'allocated': self.allocated_draw or 0, + 'maximum': self.maximum_draw or 0, + 'outlet_count': PowerOutlet.objects.filter(power_port=self).count(), + 'legs': [], + } + # # Power outlets @@ -1887,15 +2022,28 @@ class PowerOutlet(CableTermination, ComponentModel): name = models.CharField( max_length=50 ) + power_port = models.ForeignKey( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name='poweroutlets' + ) + feed_leg = models.PositiveSmallIntegerField( + choices=POWERFEED_LEG_CHOICES, + blank=True, + null=True, + help_text="Phase (for three-phase feeds)" + ) connection_status = models.NullBooleanField( choices=CONNECTION_STATUS_CHOICES, blank=True ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name'] + csv_headers = ['device', 'name', 'power_port', 'feed_leg', 'description'] class Meta: unique_together = ['device', 'name'] @@ -1910,8 +2058,19 @@ class PowerOutlet(CableTermination, ComponentModel): return ( self.device.identifier, self.name, + self.power_port.name if self.power_port else None, + self.get_feed_leg_display(), + self.description, ) + def clean(self): + + # Validate power port assignment + if self.power_port and self.power_port.device != self.device: + raise ValidationError( + "Parent power port ({}) must belong to the same device".format(self.power_port) + ) + # # Interfaces @@ -1965,9 +2124,9 @@ class Interface(CableTermination, ComponentModel): blank=True, verbose_name='Parent LAG' ) - form_factor = models.PositiveSmallIntegerField( - choices=IFACE_FF_CHOICES, - default=IFACE_FF_10GE_SFP_PLUS + type = models.PositiveSmallIntegerField( + choices=IFACE_TYPE_CHOICES, + default=IFACE_TYPE_10GE_SFP_PLUS ) enabled = models.BooleanField( default=True @@ -1988,10 +2147,6 @@ class Interface(CableTermination, ComponentModel): verbose_name='OOB Management', help_text='This interface is used only for out-of-band management' ) - description = models.CharField( - max_length=100, - blank=True - ) mode = models.PositiveSmallIntegerField( choices=IFACE_MODE_CHOICES, blank=True, @@ -2013,10 +2168,10 @@ class Interface(CableTermination, ComponentModel): ) objects = InterfaceManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'virtual_machine', 'name', 'lag', 'form_factor', 'enabled', 'mac_address', 'mtu', 'mgmt_only', + 'device', 'virtual_machine', 'name', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', ] @@ -2036,7 +2191,7 @@ class Interface(CableTermination, ComponentModel): self.virtual_machine.name if self.virtual_machine else None, self.name, self.lag.name if self.lag else None, - self.get_form_factor_display(), + self.get_type_display(), self.enabled, self.mac_address, self.mtu, @@ -2054,18 +2209,18 @@ class Interface(CableTermination, ComponentModel): raise ValidationError("An interface must belong to either a device or a virtual machine.") # VM interfaces must be virtual - if self.virtual_machine and self.form_factor is not IFACE_FF_VIRTUAL: + if self.virtual_machine and self.type is not IFACE_TYPE_VIRTUAL: raise ValidationError({ - 'form_factor': "Virtual machines can only have virtual interfaces." + 'type': "Virtual machines can only have virtual interfaces." }) # Virtual interfaces cannot be connected - if self.form_factor in NONCONNECTABLE_IFACE_TYPES and ( + if self.type in NONCONNECTABLE_IFACE_TYPES and ( self.cable or getattr(self, 'circuit_termination', False) ): raise ValidationError({ - 'form_factor': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable form factor." + 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " + "Disconnect the interface or choose a suitable type." }) # An interface's LAG must belong to the same device (or VC master) @@ -2077,15 +2232,15 @@ class Interface(CableTermination, ComponentModel): }) # A virtual interface cannot have a parent LAG - if self.form_factor in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: + if self.type in NONCONNECTABLE_IFACE_TYPES and self.lag is not None: raise ValidationError({ - 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display()) + 'lag': "{} interfaces cannot have a parent LAG interface.".format(self.get_type_display()) }) # Only a LAG can have LAG members - if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists(): + if self.type != IFACE_TYPE_LAG and self.member_interfaces.exists(): raise ValidationError({ - 'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format( + 'type': "Cannot change interface type; it has LAG members ({}).".format( ", ".join([iface.name for iface in self.member_interfaces.all()]) ) }) @@ -2116,7 +2271,7 @@ class Interface(CableTermination, ComponentModel): # It's possible that an Interface can be deleted _after_ its parent Device/VM, in which case trying to resolve # the component parent will raise DoesNotExist. For more discussion, see - # https://github.com/digitalocean/netbox/issues/2323 + # https://github.com/netbox-community/netbox/issues/2323 try: parent_obj = self.device or self.virtual_machine except ObjectDoesNotExist: @@ -2131,6 +2286,22 @@ class Interface(CableTermination, ComponentModel): object_data=serialize_object(self) ).save() + # TODO: Remove in v2.7 + @property + def form_factor(self): + """ + Backward-compatibility for form_factor + """ + return self.type + + # TODO: Remove in v2.7 + @form_factor.setter + def form_factor(self, value): + """ + Backward-compatibility for form_factor + """ + self.type = value + @property def connected_endpoint(self): if self._connected_interface: @@ -2161,19 +2332,19 @@ class Interface(CableTermination, ComponentModel): @property def is_connectable(self): - return self.form_factor not in NONCONNECTABLE_IFACE_TYPES + return self.type not in NONCONNECTABLE_IFACE_TYPES @property def is_virtual(self): - return self.form_factor in VIRTUAL_IFACE_TYPES + return self.type in VIRTUAL_IFACE_TYPES @property def is_wireless(self): - return self.form_factor in WIRELESS_IFACE_TYPES + return self.type in WIRELESS_IFACE_TYPES @property def is_lag(self): - return self.form_factor == IFACE_FF_LAG + return self.type == IFACE_TYPE_LAG @property def count_ipaddresses(self): @@ -2208,13 +2379,9 @@ class FrontPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - description = models.CharField( - max_length=100, - blank=True - ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] @@ -2274,13 +2441,9 @@ class RearPort(CableTermination, ComponentModel): default=1, validators=[MinValueValidator(1), MaxValueValidator(64)] ) - description = models.CharField( - max_length=100, - blank=True - ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'type', 'positions', 'description'] @@ -2327,9 +2490,9 @@ class DeviceBay(ComponentModel): ) objects = NaturalOrderingManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'installed_device'] + csv_headers = ['device', 'name', 'installed_device', 'description'] class Meta: ordering = ['device', 'name'] @@ -2346,6 +2509,7 @@ class DeviceBay(ComponentModel): self.device.identifier, self.name, self.installed_device.identifier if self.installed_device else None, + self.description, ) def clean(self): @@ -2415,12 +2579,8 @@ class InventoryItem(ComponentModel): default=False, verbose_name='Discovered' ) - description = models.CharField( - max_length=100, - blank=True - ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'device', 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', @@ -2467,7 +2627,7 @@ class VirtualChassis(ChangeLoggedModel): blank=True ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['master', 'domain'] @@ -2587,55 +2747,79 @@ class Cable(ChangeLoggedModel): def clean(self): - if self.termination_a and self.termination_b: + # Validate that termination A exists + try: + self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) + except ObjectDoesNotExist: + raise ValidationError({ + 'termination_a': 'Invalid ID for type {}'.format(self.termination_a_type) + }) - type_a = self.termination_a_type.model - type_b = self.termination_b_type.model + # Validate that termination B exists + try: + self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) + except ObjectDoesNotExist: + raise ValidationError({ + 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) + }) - # Check that termination types are compatible - if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): - raise ValidationError("Incompatible termination types: {} and {}".format( - self.termination_a_type, self.termination_b_type - )) + type_a = self.termination_a_type.model + type_b = self.termination_b_type.model - # A termination point cannot be connected to itself - if self.termination_a == self.termination_b: - raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) + # Check that termination types are compatible + if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): + raise ValidationError("Incompatible termination types: {} and {}".format( + self.termination_a_type, self.termination_b_type + )) - # A front port cannot be connected to its corresponding rear port - if ( - type_a in ['frontport', 'rearport'] and - type_b in ['frontport', 'rearport'] and - ( - getattr(self.termination_a, 'rear_port', None) == self.termination_b or - getattr(self.termination_b, 'rear_port', None) == self.termination_a + # A component with multiple positions must be connected to a component with an equal number of positions + term_a_positions = getattr(self.termination_a, 'positions', 1) + term_b_positions = getattr(self.termination_b, 'positions', 1) + if term_a_positions != term_b_positions: + raise ValidationError( + "{} has {} positions and {} has {}. Both terminations must have the same number of positions.".format( + self.termination_a, term_a_positions, self.termination_b, term_b_positions ) - ): - raise ValidationError("A front port cannot be connected to it corresponding rear port") + ) - # Check for an existing Cable connected to either termination object - if self.termination_a.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_a, self.termination_a.cable_id - )) - if self.termination_b.cable not in (None, self): - raise ValidationError("{} already has a cable attached (#{})".format( - self.termination_b, self.termination_b.cable_id - )) + # A termination point cannot be connected to itself + if self.termination_a == self.termination_b: + raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type)) - # Virtual interfaces cannot be connected - endpoint_a, endpoint_b, _ = self.get_path_endpoints() - if ( - ( - isinstance(endpoint_a, Interface) and - endpoint_a.form_factor == IFACE_FF_VIRTUAL - ) or - ( - isinstance(endpoint_b, Interface) and - endpoint_b.form_factor == IFACE_FF_VIRTUAL - ) - ): - raise ValidationError("Cannot connect to a virtual interface") + # A front port cannot be connected to its corresponding rear port + if ( + type_a in ['frontport', 'rearport'] and + type_b in ['frontport', 'rearport'] and + ( + getattr(self.termination_a, 'rear_port', None) == self.termination_b or + getattr(self.termination_b, 'rear_port', None) == self.termination_a + ) + ): + raise ValidationError("A front port cannot be connected to it corresponding rear port") + + # Check for an existing Cable connected to either termination object + if self.termination_a.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_a, self.termination_a.cable_id + )) + if self.termination_b.cable not in (None, self): + raise ValidationError("{} already has a cable attached (#{})".format( + self.termination_b, self.termination_b.cable_id + )) + + # Virtual interfaces cannot be connected + endpoint_a, endpoint_b, _ = self.get_path_endpoints() + if ( + ( + isinstance(endpoint_a, Interface) and + endpoint_a.type == IFACE_TYPE_VIRTUAL + ) or + ( + isinstance(endpoint_b, Interface) and + endpoint_b.type == IFACE_TYPE_VIRTUAL + ) + ): + raise ValidationError("Cannot connect to a virtual interface") # Validate length and length_unit if self.length is not None and self.length_unit is None: @@ -2668,6 +2852,14 @@ class Cable(ChangeLoggedModel): def get_status_class(self): return 'success' if self.status else 'info' + def get_compatible_types(self): + """ + Return all termination types compatible with termination A. + """ + if self.termination_a is None: + return + return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + def get_path_endpoints(self): """ Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be @@ -2690,3 +2882,182 @@ class Cable(ChangeLoggedModel): b_endpoint = b_path[-1][2] return a_endpoint, b_endpoint, path_status + + +# +# Power +# + +class PowerPanel(ChangeLoggedModel): + """ + A distribution point for electrical power; e.g. a data center RPP. + """ + site = models.ForeignKey( + to='Site', + on_delete=models.PROTECT + ) + rack_group = models.ForeignKey( + to='RackGroup', + on_delete=models.PROTECT, + blank=True, + null=True + ) + name = models.CharField( + max_length=50 + ) + + csv_headers = ['site', 'rack_group_name', 'name'] + + class Meta: + ordering = ['site', 'name'] + unique_together = ['site', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerpanel', args=[self.pk]) + + def to_csv(self): + return ( + self.site.name, + self.rack_group.name if self.rack_group else None, + self.name, + ) + + def clean(self): + + # RackGroup must belong to assigned Site + if self.rack_group and self.rack_group.site != self.site: + raise ValidationError("Rack group {} ({}) is in a different site than {}".format( + self.rack_group, self.rack_group.site, self.site + )) + + +class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel): + """ + An electrical circuit delivered from a PowerPanel. + """ + power_panel = models.ForeignKey( + to='PowerPanel', + on_delete=models.PROTECT, + related_name='powerfeeds' + ) + rack = models.ForeignKey( + to='Rack', + on_delete=models.PROTECT, + blank=True, + null=True + ) + connected_endpoint = models.OneToOneField( + to='dcim.PowerPort', + on_delete=models.SET_NULL, + related_name='+', + blank=True, + null=True + ) + connection_status = models.NullBooleanField( + choices=CONNECTION_STATUS_CHOICES, + blank=True + ) + name = models.CharField( + max_length=50 + ) + status = models.PositiveSmallIntegerField( + choices=POWERFEED_STATUS_CHOICES, + default=POWERFEED_STATUS_ACTIVE + ) + type = models.PositiveSmallIntegerField( + choices=POWERFEED_TYPE_CHOICES, + default=POWERFEED_TYPE_PRIMARY + ) + supply = models.PositiveSmallIntegerField( + choices=POWERFEED_SUPPLY_CHOICES, + default=POWERFEED_SUPPLY_AC + ) + phase = models.PositiveSmallIntegerField( + choices=POWERFEED_PHASE_CHOICES, + default=POWERFEED_PHASE_SINGLE + ) + voltage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=120 + ) + amperage = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1)], + default=20 + ) + max_utilization = models.PositiveSmallIntegerField( + validators=[MinValueValidator(1), MaxValueValidator(100)], + default=80, + help_text="Maximum permissible draw (percentage)" + ) + available_power = models.PositiveSmallIntegerField( + default=0, + editable=False + ) + comments = models.TextField( + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager(through=TaggedItem) + + csv_headers = [ + 'site', 'panel_name', 'rack_group', 'rack_name', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'comments', + ] + + class Meta: + ordering = ['power_panel', 'name'] + unique_together = ['power_panel', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:powerfeed', args=[self.pk]) + + def to_csv(self): + return ( + self.power_panel.name, + self.rack.name if self.rack else None, + self.name, + self.get_status_display(), + self.get_type_display(), + self.get_supply_display(), + self.get_phase_display(), + self.voltage, + self.amperage, + self.max_utilization, + self.comments, + ) + + def clean(self): + + # Rack must belong to same Site as PowerPanel + if self.rack and self.rack.site != self.power_panel.site: + raise ValidationError("Rack {} ({}) and power panel {} ({}) are in different sites".format( + self.rack, self.rack.site, self.power_panel, self.power_panel.site + )) + + def save(self, *args, **kwargs): + + # Cache the available_power property on the instance + kva = self.voltage * self.amperage * (self.max_utilization / 100) + if self.phase == POWERFEED_PHASE_3PHASE: + self.available_power = round(kva * 1.732) + else: + self.available_power = round(kva) + + super().save(*args, **kwargs) + + def get_type_class(self): + return STATUS_CLASSES[self.type] + + def get_status_class(self): + return STATUS_CLASSES[self.status] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index a36b11e65..de4a2dec5 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -6,8 +6,9 @@ from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) REGION_LINK = """ @@ -144,6 +145,10 @@ STATUS_LABEL = """ {{ record.get_status_display }} """ +TYPE_LABEL = """ +{{ record.get_type_display }} +""" + DEVICE_PRIMARY_IP = """ {{ record.primary_ip6.address.ip|default:"" }} {% if record.primary_ip6 and record.primary_ip4 %}
{% endif %} @@ -184,6 +189,10 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %} """ +POWERPANEL_POWERFEED_COUNT = """ +{{ value }} +""" + # # Regions @@ -290,12 +299,21 @@ class RackDetailTable(RackTable): template_code=RACK_DEVICE_COUNT, verbose_name='Devices' ) - get_utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + get_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Space' + ) + get_power_utilization = tables.TemplateColumn( + template_code=UTILIZATION_GRAPH, + orderable=False, + verbose_name='Power' + ) class Meta(RackTable.Meta): fields = ( 'pk', 'name', 'site', 'group', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', - 'get_utilization', + 'get_utilization', 'get_power_utilization', ) @@ -305,7 +323,12 @@ class RackDetailTable(RackTable): class RackReservationTable(BaseTable): pk = ToggleColumn() - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + site = tables.LinkColumn( + viewname='dcim:site', + accessor=Accessor('rack.site'), + args=[Accessor('rack.site.slug')], + ) + tenant = tables.TemplateColumn(template_code=COL_TENANT) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) unit_list = tables.Column(orderable=False, verbose_name='Units') actions = tables.TemplateColumn( @@ -314,7 +337,7 @@ class RackReservationTable(BaseTable): class Meta(BaseTable.Meta): model = RackReservation - fields = ('pk', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions') + fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions') # @@ -323,16 +346,26 @@ class RackReservationTable(BaseTable): class ManufacturerTable(BaseTable): pk = ToggleColumn() - name = tables.LinkColumn(verbose_name='Name') - devicetype_count = tables.Column(verbose_name='Device Types') - platform_count = tables.Column(verbose_name='Platforms') - slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, - verbose_name='') + name = tables.LinkColumn() + devicetype_count = tables.Column( + verbose_name='Device Types' + ) + inventoryitem_count = tables.Column( + verbose_name='Inventory Items' + ) + platform_count = tables.Column( + verbose_name='Platforms' + ) + slug = tables.Column() + actions = tables.TemplateColumn( + template_code=MANUFACTURER_ACTIONS, + attrs={'td': {'class': 'text-right noprint'}}, + verbose_name='' + ) class Meta(BaseTable.Meta): model = Manufacturer - fields = ('pk', 'name', 'devicetype_count', 'platform_count', 'slug', 'actions') + fields = ('pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'slug', 'actions') # @@ -391,7 +424,7 @@ class PowerPortTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPortTemplate - fields = ('pk', 'name') + fields = ('pk', 'name', 'maximum_draw', 'allocated_draw') empty_text = "None" @@ -400,7 +433,7 @@ class PowerOutletTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = PowerOutletTemplate - fields = ('pk', 'name') + fields = ('pk', 'name', 'power_port', 'feed_leg') empty_text = "None" @@ -410,7 +443,7 @@ class InterfaceTemplateTable(BaseTable): class Meta(BaseTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'mgmt_only', 'form_factor') + fields = ('pk', 'name', 'mgmt_only', 'type') empty_text = "None" @@ -567,7 +600,7 @@ class ConsoleServerPortTable(BaseTable): class Meta(BaseTable.Meta): model = ConsoleServerPort - fields = ('name',) + fields = ('name', 'description') class PowerPortTable(BaseTable): @@ -581,14 +614,14 @@ class PowerOutletTable(BaseTable): class Meta(BaseTable.Meta): model = PowerOutlet - fields = ('name',) + fields = ('name', 'description') class InterfaceTable(BaseTable): class Meta(BaseTable.Meta): model = Interface - fields = ('name', 'form_factor', 'lag', 'enabled', 'mgmt_only', 'description') + fields = ('name', 'type', 'lag', 'enabled', 'mgmt_only', 'description') class FrontPortTable(BaseTable): @@ -698,7 +731,8 @@ class PowerConnectionTable(BaseTable): args=[Accessor('connected_endpoint.device.pk')], verbose_name='PDU' ) - connected_endpoint = tables.Column( + outlet = tables.Column( + accessor=Accessor('_connected_poweroutlet'), verbose_name='Outlet' ) device = tables.LinkColumn( @@ -711,7 +745,7 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort - fields = ('pdu', 'connected_endpoint', 'device', 'name', 'connection_status') + fields = ('pdu', 'outlet', 'device', 'name', 'connection_status') class InterfaceConnectionTable(BaseTable): @@ -786,3 +820,51 @@ class VirtualChassisTable(BaseTable): class Meta(BaseTable.Meta): model = VirtualChassis fields = ('pk', 'master', 'domain', 'member_count', 'actions') + + +# +# Power panels +# + +class PowerPanelTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + site = tables.LinkColumn( + viewname='dcim:site', + args=[Accessor('site.slug')] + ) + powerfeed_count = tables.TemplateColumn( + template_code=POWERPANEL_POWERFEED_COUNT, + verbose_name='Feeds' + ) + + class Meta(BaseTable.Meta): + model = PowerPanel + fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + + +# +# Power feeds +# + +class PowerFeedTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + power_panel = tables.LinkColumn( + viewname='dcim:powerpanel', + args=[Accessor('power_panel.pk')], + ) + rack = tables.LinkColumn( + viewname='dcim:rack', + args=[Accessor('rack.pk')] + ) + status = tables.TemplateColumn( + template_code=STATUS_LABEL + ) + type = tables.TemplateColumn( + template_code=TYPE_LABEL + ) + + class Meta(BaseTable.Meta): + model = PowerFeed + fields = ('pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index aa57d4790..9c873c886 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,8 +7,8 @@ from dcim.constants import * from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, Rack, RackGroup, - RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, + InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, + Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, ) from ipam.models import IPAddress, VLAN from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE @@ -47,7 +47,7 @@ class RegionTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'site_count', 'slug', 'url'] ) def test_create_region(self): @@ -285,7 +285,7 @@ class RackGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'rack_count', 'slug', 'url'] ) def test_create_rackgroup(self): @@ -393,7 +393,7 @@ class RackRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'rack_count', 'slug', 'url'] ) def test_create_rackrole(self): @@ -520,7 +520,7 @@ class RackTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['display_name', 'id', 'name', 'url'] + ['device_count', 'display_name', 'id', 'name', 'url'] ) def test_create_rack(self): @@ -746,7 +746,7 @@ class ManufacturerTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['devicetype_count', 'id', 'name', 'slug', 'url'] ) def test_create_manufacturer(self): @@ -855,7 +855,7 @@ class DeviceTypeTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] + ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] ) def test_create_devicetype(self): @@ -1569,7 +1569,7 @@ class DeviceRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] ) def test_create_devicerole(self): @@ -1677,7 +1677,7 @@ class PlatformTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] ) def test_create_platform(self): @@ -1791,6 +1791,16 @@ class DeviceTest(APITestCase): site=self.site1, cluster=self.cluster1 ) + self.device_with_context_data = Device.objects.create( + device_type=self.devicetype1, + device_role=self.devicerole1, + name='Device with context data', + site=self.site1, + local_context_data={ + 'A': 1, + 'B': 2 + } + ) def test_get_device(self): @@ -1806,7 +1816,7 @@ class DeviceTest(APITestCase): url = reverse('dcim-api:device-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 4) def test_list_devices_brief(self): @@ -1832,7 +1842,7 @@ class DeviceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Device.objects.count(), 4) + self.assertEqual(Device.objects.count(), 5) device4 = Device.objects.get(pk=response.data['id']) self.assertEqual(device4.device_type_id, data['device_type']) self.assertEqual(device4.device_role_id, data['device_role']) @@ -1867,7 +1877,7 @@ class DeviceTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(Device.objects.count(), 6) + self.assertEqual(Device.objects.count(), 7) self.assertEqual(response.data[0]['name'], data[0]['name']) self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) @@ -1891,7 +1901,7 @@ class DeviceTest(APITestCase): response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(Device.objects.count(), 3) + self.assertEqual(Device.objects.count(), 4) device1 = Device.objects.get(pk=response.data['id']) self.assertEqual(device1.device_type_id, data['device_type']) self.assertEqual(device1.device_role_id, data['device_role']) @@ -1906,7 +1916,21 @@ class DeviceTest(APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(Device.objects.count(), 2) + self.assertEqual(Device.objects.count(), 3) + + def test_config_context_included_by_default_in_list_view(self): + + url = reverse('dcim-api:device-list') + '?slug=device-with-context-data' + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1) + + def test_config_context_excluded(self): + + url = reverse('dcim-api:device-list') + '?exclude=config_context' + response = self.client.get(url, **self.header) + + self.assertFalse('config_context' in response.data['results'][0]) class ConsolePortTest(APITestCase): @@ -2529,7 +2553,7 @@ class InterfaceTest(APITestCase): def test_update_interface(self): lag_interface = Interface.objects.create( - device=self.device, name='Test LAG Interface', form_factor=IFACE_FF_LAG + device=self.device, name='Test LAG Interface', type=IFACE_TYPE_LAG ) data = { @@ -2817,7 +2841,7 @@ class CableTest(APITestCase): ) for device in [self.device1, self.device2]: for i in range(0, 10): - Interface(device=device, form_factor=IFACE_FF_1GE_FIXED, name='eth{}'.format(i)).save() + Interface(device=device, type=IFACE_TYPE_1GE_FIXED, name='eth{}'.format(i)).save() self.cable1 = Cable( termination_a=self.device1.interfaces.get(name='eth0'), @@ -3386,23 +3410,23 @@ class VirtualChassisTest(APITestCase): device_type=device_type, device_role=device_role, name='StackSwitch9', site=site ) for i in range(0, 13): - Interface.objects.create(device=self.device1, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device1, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device2, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device2, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device3, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device3, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device4, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device4, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device5, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device5, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device6, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device6, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device7, name='1/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device7, name='1/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device8, name='2/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device8, name='2/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) for i in range(0, 13): - Interface.objects.create(device=self.device9, name='3/{}'.format(i), form_factor=IFACE_FF_1GE_FIXED) + Interface.objects.create(device=self.device9, name='3/{}'.format(i), type=IFACE_TYPE_1GE_FIXED) # Create two VirtualChassis with three members each self.vc1 = VirtualChassis.objects.create(master=self.device1, domain='test-domain-1') @@ -3433,7 +3457,7 @@ class VirtualChassisTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'master', 'url'] + ['id', 'master', 'member_count', 'url'] ) def test_create_virtualchassis(self): @@ -3508,3 +3532,260 @@ class VirtualChassisTest(APITestCase): self.assertTrue( Device.objects.filter(pk=d.pk, virtual_chassis=None, vc_position=None, vc_priority=None) ) + + +class PowerPanelTest(APITestCase): + + def setUp(self): + + super().setUp() + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackgroup2 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 2', slug='test-rack-group-2') + self.rackgroup3 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 3', slug='test-rack-group-3') + self.powerpanel1 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1' + ) + self.powerpanel2 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup2, name='Test Power Panel 2' + ) + self.powerpanel3 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup3, name='Test Power Panel 3' + ) + + def test_get_powerpanel(self): + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerpanel1.name) + + def test_list_powerpanels(self): + + url = reverse('dcim-api:powerpanel-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 3) + + def test_list_powerpanels_brief(self): + + url = reverse('dcim-api:powerpanel-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'powerfeed_count', 'url'] + ) + + def test_create_powerpanel(self): + + data = { + 'name': 'Test Power Panel 4', + 'site': self.site1.pk, + 'rack_group': self.rackgroup1.pk, + } + + url = reverse('dcim-api:powerpanel-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPanel.objects.count(), 4) + powerpanel4 = PowerPanel.objects.get(pk=response.data['id']) + self.assertEqual(powerpanel4.name, data['name']) + self.assertEqual(powerpanel4.site_id, data['site']) + self.assertEqual(powerpanel4.rack_group_id, data['rack_group']) + + def test_create_powerpanel_bulk(self): + + data = [ + { + 'name': 'Test Power Panel 4', + 'site': self.site1.pk, + 'rack_group': self.rackgroup1.pk, + }, + { + 'name': 'Test Power Panel 5', + 'site': self.site1.pk, + 'rack_group': self.rackgroup2.pk, + }, + { + 'name': 'Test Power Panel 6', + 'site': self.site1.pk, + 'rack_group': self.rackgroup3.pk, + }, + ] + + url = reverse('dcim-api:powerpanel-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerPanel.objects.count(), 6) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + self.assertEqual(response.data[2]['name'], data[2]['name']) + + def test_update_powerpanel(self): + + data = { + 'name': 'Test Power Panel X', + 'rack_group': self.rackgroup2.pk, + } + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerPanel.objects.count(), 3) + powerpanel1 = PowerPanel.objects.get(pk=response.data['id']) + self.assertEqual(powerpanel1.name, data['name']) + self.assertEqual(powerpanel1.rack_group_id, data['rack_group']) + + def test_delete_powerpanel(self): + + url = reverse('dcim-api:powerpanel-detail', kwargs={'pk': self.powerpanel1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerPanel.objects.count(), 2) + + +class PowerFeedTest(APITestCase): + + def setUp(self): + + super().setUp() + + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') + self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') + self.rack1 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42, + ) + self.rack2 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42, + ) + self.rack3 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 3', u_height=42, + ) + self.rack4 = Rack.objects.create( + site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 4', u_height=42, + ) + self.powerpanel1 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 1' + ) + self.powerpanel2 = PowerPanel.objects.create( + site=self.site1, rack_group=self.rackgroup1, name='Test Power Panel 2' + ) + self.powerfeed1 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack1, name='Test Power Feed 1A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed2 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack1, name='Test Power Feed 1B', type=POWERFEED_TYPE_REDUNDANT + ) + self.powerfeed3 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack2, name='Test Power Feed 2A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed4 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack2, name='Test Power Feed 2B', type=POWERFEED_TYPE_REDUNDANT + ) + self.powerfeed5 = PowerFeed.objects.create( + power_panel=self.powerpanel1, rack=self.rack3, name='Test Power Feed 3A', type=POWERFEED_TYPE_PRIMARY + ) + self.powerfeed6 = PowerFeed.objects.create( + power_panel=self.powerpanel2, rack=self.rack3, name='Test Power Feed 3B', type=POWERFEED_TYPE_REDUNDANT + ) + + def test_get_powerfeed(self): + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['name'], self.powerfeed1.name) + + def test_list_powerfeeds(self): + + url = reverse('dcim-api:powerfeed-list') + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['count'], 6) + + def test_list_powerfeeds_brief(self): + + url = reverse('dcim-api:powerfeed-list') + response = self.client.get('{}?brief=1'.format(url), **self.header) + + self.assertEqual( + sorted(response.data['results'][0]), + ['id', 'name', 'url'] + ) + + def test_create_powerfeed(self): + + data = { + 'name': 'Test Power Feed 4A', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_PRIMARY, + } + + url = reverse('dcim-api:powerfeed-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerFeed.objects.count(), 7) + powerfeed4 = PowerFeed.objects.get(pk=response.data['id']) + self.assertEqual(powerfeed4.name, data['name']) + self.assertEqual(powerfeed4.power_panel_id, data['power_panel']) + self.assertEqual(powerfeed4.rack_id, data['rack']) + + def test_create_powerfeed_bulk(self): + + data = [ + { + 'name': 'Test Power Feed 4A', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_PRIMARY, + }, + { + 'name': 'Test Power Feed 4B', + 'power_panel': self.powerpanel1.pk, + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_REDUNDANT, + }, + ] + + url = reverse('dcim-api:powerfeed-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(PowerFeed.objects.count(), 8) + self.assertEqual(response.data[0]['name'], data[0]['name']) + self.assertEqual(response.data[1]['name'], data[1]['name']) + + def test_update_powerfeed(self): + + data = { + 'name': 'Test Power Feed X', + 'rack': self.rack4.pk, + 'type': POWERFEED_TYPE_REDUNDANT, + } + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.patch(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(PowerFeed.objects.count(), 6) + powerfeed1 = PowerFeed.objects.get(pk=response.data['id']) + self.assertEqual(powerfeed1.name, data['name']) + self.assertEqual(powerfeed1.rack_id, data['rack']) + self.assertEqual(powerfeed1.type, data['type']) + + def test_delete_powerfeed(self): + + url = reverse('dcim-api:powerfeed-detail', kwargs={'pk': self.powerfeed1.pk}) + response = self.client.delete(url, **self.header) + + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(PowerFeed.objects.count(), 5) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 757af61f4..e0af86b20 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -249,7 +249,7 @@ class CableTestCase(TestCase): """ A cable connection cannot include a virtual interface """ - virtual_interface = Interface(device=self.device1, name="V1", form_factor=0) + virtual_interface = Interface(device=self.device1, name="V1", type=0) cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) with self.assertRaises(ValidationError): cable.clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 79f38a5c9..6e34b8ae9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1,21 +1,22 @@ import urllib.parse -from django.contrib.auth import get_user_model from django.test import Client, TestCase from django.urls import reverse -from dcim.constants import CABLE_TYPE_CAT6, IFACE_FF_1GE_FIXED +from dcim.constants import CABLE_TYPE_CAT6, IFACE_TYPE_1GE_FIXED from dcim.models import ( Cable, Device, DeviceRole, DeviceType, Interface, InventoryItem, Manufacturer, Platform, Rack, RackGroup, RackReservation, RackRole, Site, Region, VirtualChassis, ) +from utilities.testing import create_test_user class RegionTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_region']) self.client = Client() + self.client.force_login(user) # Create three Regions for i in range(1, 4): @@ -32,8 +33,9 @@ class RegionTestCase(TestCase): class SiteTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_site']) self.client = Client() + self.client.force_login(user) region = Region(name='Region 1', slug='region-1') region.save() @@ -64,8 +66,9 @@ class SiteTestCase(TestCase): class RackGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackgroup']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -84,11 +87,12 @@ class RackGroupTestCase(TestCase): self.assertEqual(response.status_code, 200) -class RackTypeTestCase(TestCase): +class RackRoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackrole']) self.client = Client() + self.client.force_login(user) RackRole.objects.bulk_create([ RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -107,12 +111,9 @@ class RackTypeTestCase(TestCase): class RackReservationTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rackreservation']) self.client = Client() - - User = get_user_model() - user = User(username='testuser', email='testuser@example.com') - user.save() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -137,8 +138,9 @@ class RackReservationTestCase(TestCase): class RackTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_rack']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -169,8 +171,9 @@ class RackTestCase(TestCase): class ManufacturerTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_manufacturer']) self.client = Client() + self.client.force_login(user) Manufacturer.objects.bulk_create([ Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), @@ -189,8 +192,9 @@ class ManufacturerTypeTestCase(TestCase): class DeviceTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_devicetype']) self.client = Client() + self.client.force_login(user) manufacturer = Manufacturer(name='Manufacturer 1', slug='manufacturer-1') manufacturer.save() @@ -221,8 +225,9 @@ class DeviceTypeTestCase(TestCase): class DeviceRoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_devicerole']) self.client = Client() + self.client.force_login(user) DeviceRole.objects.bulk_create([ DeviceRole(name='Device Role 1', slug='device-role-1'), @@ -241,8 +246,9 @@ class DeviceRoleTestCase(TestCase): class PlatformTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_platform']) self.client = Client() + self.client.force_login(user) Platform.objects.bulk_create([ Platform(name='Platform 1', slug='platform-1'), @@ -261,8 +267,9 @@ class PlatformTestCase(TestCase): class DeviceTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_device']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -303,8 +310,9 @@ class DeviceTestCase(TestCase): class InventoryItemTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_inventoryitem']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -337,18 +345,13 @@ class InventoryItemTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params))) self.assertEqual(response.status_code, 200) - def test_inventoryitem(self): - - inventoryitem = InventoryItem.objects.first() - response = self.client.get(inventoryitem.get_absolute_url()) - self.assertEqual(response.status_code, 200) - class CableTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_cable']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -367,17 +370,17 @@ class CableTestCase(TestCase): device2 = Device(name='Device 2', site=site, device_type=devicetype, device_role=devicerole) device2.save() - iface1 = Interface(device=device1, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED) + iface1 = Interface(device=device1, name='Interface 1', type=IFACE_TYPE_1GE_FIXED) iface1.save() - iface2 = Interface(device=device1, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED) + iface2 = Interface(device=device1, name='Interface 2', type=IFACE_TYPE_1GE_FIXED) iface2.save() - iface3 = Interface(device=device1, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED) + iface3 = Interface(device=device1, name='Interface 3', type=IFACE_TYPE_1GE_FIXED) iface3.save() - iface4 = Interface(device=device2, name='Interface 1', form_factor=IFACE_FF_1GE_FIXED) + iface4 = Interface(device=device2, name='Interface 1', type=IFACE_TYPE_1GE_FIXED) iface4.save() - iface5 = Interface(device=device2, name='Interface 2', form_factor=IFACE_FF_1GE_FIXED) + iface5 = Interface(device=device2, name='Interface 2', type=IFACE_TYPE_1GE_FIXED) iface5.save() - iface6 = Interface(device=device2, name='Interface 3', form_factor=IFACE_FF_1GE_FIXED) + iface6 = Interface(device=device2, name='Interface 3', type=IFACE_TYPE_1GE_FIXED) iface6.save() Cable(termination_a=iface1, termination_b=iface4, type=CABLE_TYPE_CAT6).save() @@ -401,11 +404,12 @@ class CableTestCase(TestCase): self.assertEqual(response.status_code, 200) -class VirtualMachineTestCase(TestCase): +class VirtualChassisTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['dcim.view_virtualchassis']) self.client = Client() + self.client.force_login(user) site = Site.objects.create(name='Site 1', slug='site-1') manufacturer = Manufacturer.objects.create(name='Manufacturer', slug='manufacturer-1') @@ -450,9 +454,3 @@ class VirtualMachineTestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - - def test_virtualchassis(self): - - virtualchassis = VirtualChassis.objects.first() - response = self.client.get(virtualchassis.get_absolute_url()) - self.assertEqual(response.status_code, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 21d620af1..ae1f05757 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView, ImageAttachmentEditView from ipam.views import ServiceCreateView @@ -6,277 +6,302 @@ from secrets.views import secret_add from . import views from .models import ( Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform, - PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, + PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, RackReservation, RackRole, RearPort, Region, Site, + VirtualChassis, ) app_name = 'dcim' urlpatterns = [ # Regions - url(r'^regions/$', views.RegionListView.as_view(), name='region_list'), - url(r'^regions/add/$', views.RegionCreateView.as_view(), name='region_add'), - url(r'^regions/import/$', views.RegionBulkImportView.as_view(), name='region_import'), - url(r'^regions/delete/$', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), - url(r'^regions/(?P\d+)/edit/$', views.RegionEditView.as_view(), name='region_edit'), - url(r'^regions/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + path(r'regions/', views.RegionListView.as_view(), name='region_list'), + path(r'regions/add/', views.RegionCreateView.as_view(), name='region_add'), + path(r'regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path(r'regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path(r'regions//edit/', views.RegionEditView.as_view(), name='region_edit'), + path(r'regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), # Sites - url(r'^sites/$', views.SiteListView.as_view(), name='site_list'), - url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'), - url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'), - url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), - url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), - url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), - url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), - url(r'^sites/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), - url(r'^sites/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), + path(r'sites/', views.SiteListView.as_view(), name='site_list'), + path(r'sites/add/', views.SiteCreateView.as_view(), name='site_add'), + path(r'sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), + path(r'sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), + path(r'sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), + path(r'sites//', views.SiteView.as_view(), name='site'), + path(r'sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path(r'sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path(r'sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path(r'sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), # Rack groups - url(r'^rack-groups/$', views.RackGroupListView.as_view(), name='rackgroup_list'), - url(r'^rack-groups/add/$', views.RackGroupCreateView.as_view(), name='rackgroup_add'), - url(r'^rack-groups/import/$', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - url(r'^rack-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + path(r'rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), + path(r'rack-groups/add/', views.RackGroupCreateView.as_view(), name='rackgroup_add'), + path(r'rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), + path(r'rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), + path(r'rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + path(r'rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), # Rack roles - url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), - url(r'^rack-roles/add/$', views.RackRoleCreateView.as_view(), name='rackrole_add'), - url(r'^rack-roles/import/$', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), - url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), - url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), - url(r'^rack-roles/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), + path(r'rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), + path(r'rack-roles/add/', views.RackRoleCreateView.as_view(), name='rackrole_add'), + path(r'rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path(r'rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path(r'rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), + path(r'rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), # Rack reservations - url(r'^rack-reservations/$', views.RackReservationListView.as_view(), name='rackreservation_list'), - url(r'^rack-reservations/edit/$', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), - url(r'^rack-reservations/delete/$', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), - url(r'^rack-reservations/(?P\d+)/edit/$', views.RackReservationEditView.as_view(), name='rackreservation_edit'), - url(r'^rack-reservations/(?P\d+)/delete/$', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), - url(r'^rack-reservations/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path(r'rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'), + path(r'rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'), + path(r'rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'), + path(r'rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), + path(r'rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), + path(r'rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), # Racks - url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), - url(r'^rack-elevations/$', views.RackElevationListView.as_view(), name='rack_elevation_list'), - url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), - url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'), - url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), - url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), - url(r'^racks/(?P\d+)/$', views.RackView.as_view(), name='rack'), - url(r'^racks/(?P\d+)/edit/$', views.RackEditView.as_view(), name='rack_edit'), - url(r'^racks/(?P\d+)/delete/$', views.RackDeleteView.as_view(), name='rack_delete'), - url(r'^racks/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), - url(r'^racks/(?P\d+)/reservations/add/$', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), - url(r'^racks/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), + path(r'racks/', views.RackListView.as_view(), name='rack_list'), + path(r'rack-elevations/', views.RackElevationListView.as_view(), name='rack_elevation_list'), + path(r'racks/add/', views.RackEditView.as_view(), name='rack_add'), + path(r'racks/import/', views.RackBulkImportView.as_view(), name='rack_import'), + path(r'racks/edit/', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), + path(r'racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), + path(r'racks//', views.RackView.as_view(), name='rack'), + path(r'racks//edit/', views.RackEditView.as_view(), name='rack_edit'), + path(r'racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), + path(r'racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path(r'racks//reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'), + path(r'racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers - url(r'^manufacturers/$', views.ManufacturerListView.as_view(), name='manufacturer_list'), - url(r'^manufacturers/add/$', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), - url(r'^manufacturers/import/$', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), - url(r'^manufacturers/delete/$', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - url(r'^manufacturers/(?P[\w-]+)/edit/$', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - url(r'^manufacturers/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path(r'manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), + path(r'manufacturers/add/', views.ManufacturerCreateView.as_view(), name='manufacturer_add'), + path(r'manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path(r'manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), + path(r'manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path(r'manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types - url(r'^device-types/$', views.DeviceTypeListView.as_view(), name='devicetype_list'), - url(r'^device-types/add/$', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), - url(r'^device-types/import/$', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), - url(r'^device-types/edit/$', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), - url(r'^device-types/delete/$', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), - url(r'^device-types/(?P\d+)/$', views.DeviceTypeView.as_view(), name='devicetype'), - url(r'^device-types/(?P\d+)/edit/$', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), - url(r'^device-types/(?P\d+)/delete/$', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), - url(r'^device-types/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path(r'device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), + path(r'device-types/add/', views.DeviceTypeCreateView.as_view(), name='devicetype_add'), + path(r'device-types/import/', views.DeviceTypeBulkImportView.as_view(), name='devicetype_import'), + path(r'device-types/edit/', views.DeviceTypeBulkEditView.as_view(), name='devicetype_bulk_edit'), + path(r'device-types/delete/', views.DeviceTypeBulkDeleteView.as_view(), name='devicetype_bulk_delete'), + path(r'device-types//', views.DeviceTypeView.as_view(), name='devicetype'), + path(r'device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), + path(r'device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), + path(r'device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), # Console port templates - url(r'^device-types/(?P\d+)/console-ports/add/$', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), - url(r'^device-types/(?P\d+)/console-ports/delete/$', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), + path(r'device-types//console-ports/add/', views.ConsolePortTemplateCreateView.as_view(), name='devicetype_add_consoleport'), + path(r'device-types//console-ports/delete/', views.ConsolePortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleport'), # Console server port templates - url(r'^device-types/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), - url(r'^device-types/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), + path(r'device-types//console-server-ports/add/', views.ConsoleServerPortTemplateCreateView.as_view(), name='devicetype_add_consoleserverport'), + path(r'device-types//console-server-ports/delete/', views.ConsoleServerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_consoleserverport'), # Power port templates - url(r'^device-types/(?P\d+)/power-ports/add/$', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), - url(r'^device-types/(?P\d+)/power-ports/delete/$', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), + path(r'device-types//power-ports/add/', views.PowerPortTemplateCreateView.as_view(), name='devicetype_add_powerport'), + path(r'device-types//power-ports/delete/', views.PowerPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_powerport'), # Power outlet templates - url(r'^device-types/(?P\d+)/power-outlets/add/$', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), - url(r'^device-types/(?P\d+)/power-outlets/delete/$', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), + path(r'device-types//power-outlets/add/', views.PowerOutletTemplateCreateView.as_view(), name='devicetype_add_poweroutlet'), + path(r'device-types//power-outlets/delete/', views.PowerOutletTemplateBulkDeleteView.as_view(), name='devicetype_delete_poweroutlet'), # Interface templates - url(r'^device-types/(?P\d+)/interfaces/add/$', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), - url(r'^device-types/(?P\d+)/interfaces/edit/$', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), - url(r'^device-types/(?P\d+)/interfaces/delete/$', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), + path(r'device-types//interfaces/add/', views.InterfaceTemplateCreateView.as_view(), name='devicetype_add_interface'), + path(r'device-types//interfaces/edit/', views.InterfaceTemplateBulkEditView.as_view(), name='devicetype_bulkedit_interface'), + path(r'device-types//interfaces/delete/', views.InterfaceTemplateBulkDeleteView.as_view(), name='devicetype_delete_interface'), # Front port templates - url(r'^device-types/(?P\d+)/front-ports/add/$', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), - url(r'^device-types/(?P\d+)/front-ports/delete/$', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), + path(r'device-types//front-ports/add/', views.FrontPortTemplateCreateView.as_view(), name='devicetype_add_frontport'), + path(r'device-types//front-ports/delete/', views.FrontPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_frontport'), # Rear port templates - url(r'^device-types/(?P\d+)/rear-ports/add/$', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), - url(r'^device-types/(?P\d+)/rear-ports/delete/$', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), + path(r'device-types//rear-ports/add/', views.RearPortTemplateCreateView.as_view(), name='devicetype_add_rearport'), + path(r'device-types//rear-ports/delete/', views.RearPortTemplateBulkDeleteView.as_view(), name='devicetype_delete_rearport'), # Device bay templates - url(r'^device-types/(?P\d+)/device-bays/add/$', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), - url(r'^device-types/(?P\d+)/device-bays/delete/$', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), + path(r'device-types//device-bays/add/', views.DeviceBayTemplateCreateView.as_view(), name='devicetype_add_devicebay'), + path(r'device-types//device-bays/delete/', views.DeviceBayTemplateBulkDeleteView.as_view(), name='devicetype_delete_devicebay'), # Device roles - url(r'^device-roles/$', views.DeviceRoleListView.as_view(), name='devicerole_list'), - url(r'^device-roles/add/$', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), - url(r'^device-roles/import/$', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), - url(r'^device-roles/delete/$', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - url(r'^device-roles/(?P[\w-]+)/edit/$', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - url(r'^device-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path(r'device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), + path(r'device-roles/add/', views.DeviceRoleCreateView.as_view(), name='devicerole_add'), + path(r'device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path(r'device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), + path(r'device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path(r'device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms - url(r'^platforms/$', views.PlatformListView.as_view(), name='platform_list'), - url(r'^platforms/add/$', views.PlatformCreateView.as_view(), name='platform_add'), - url(r'^platforms/import/$', views.PlatformBulkImportView.as_view(), name='platform_import'), - url(r'^platforms/delete/$', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - url(r'^platforms/(?P[\w-]+)/edit/$', views.PlatformEditView.as_view(), name='platform_edit'), - url(r'^platforms/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path(r'platforms/', views.PlatformListView.as_view(), name='platform_list'), + path(r'platforms/add/', views.PlatformCreateView.as_view(), name='platform_add'), + path(r'platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path(r'platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), + path(r'platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path(r'platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices - url(r'^devices/$', views.DeviceListView.as_view(), name='device_list'), - url(r'^devices/add/$', views.DeviceCreateView.as_view(), name='device_add'), - url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'), - url(r'^devices/import/child-devices/$', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), - url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), - url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), - url(r'^devices/(?P\d+)/$', views.DeviceView.as_view(), name='device'), - url(r'^devices/(?P\d+)/edit/$', views.DeviceEditView.as_view(), name='device_edit'), - url(r'^devices/(?P\d+)/delete/$', views.DeviceDeleteView.as_view(), name='device_delete'), - url(r'^devices/(?P\d+)/config-context/$', views.DeviceConfigContextView.as_view(), name='device_configcontext'), - url(r'^devices/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), - url(r'^devices/(?P\d+)/inventory/$', views.DeviceInventoryView.as_view(), name='device_inventory'), - url(r'^devices/(?P\d+)/status/$', views.DeviceStatusView.as_view(), name='device_status'), - url(r'^devices/(?P\d+)/lldp-neighbors/$', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), - url(r'^devices/(?P\d+)/config/$', views.DeviceConfigView.as_view(), name='device_config'), - url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), - url(r'^devices/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='device_service_assign'), - url(r'^devices/(?P\d+)/images/add/$', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), + path(r'devices/', views.DeviceListView.as_view(), name='device_list'), + path(r'devices/add/', views.DeviceCreateView.as_view(), name='device_add'), + path(r'devices/import/', views.DeviceBulkImportView.as_view(), name='device_import'), + path(r'devices/import/child-devices/', views.ChildDeviceBulkImportView.as_view(), name='device_import_child'), + path(r'devices/edit/', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), + path(r'devices/delete/', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), + path(r'devices//', views.DeviceView.as_view(), name='device'), + path(r'devices//edit/', views.DeviceEditView.as_view(), name='device_edit'), + path(r'devices//delete/', views.DeviceDeleteView.as_view(), name='device_delete'), + path(r'devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), + path(r'devices//changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path(r'devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), + path(r'devices//status/', views.DeviceStatusView.as_view(), name='device_status'), + path(r'devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), + path(r'devices//config/', views.DeviceConfigView.as_view(), name='device_config'), + path(r'devices//add-secret/', secret_add, name='device_addsecret'), + path(r'devices//services/assign/', ServiceCreateView.as_view(), name='device_service_assign'), + path(r'devices//images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}), # Console ports - url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), - url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortCreateView.as_view(), name='consoleport_add'), - url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), - url(r'^console-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), - url(r'^console-ports/(?P\d+)/edit/$', views.ConsolePortEditView.as_view(), name='consoleport_edit'), - url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), - url(r'^console-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), + path(r'devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), + path(r'devices//console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'), + path(r'devices//console-ports/delete/', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), + path(r'console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), + path(r'console-ports//edit/', views.ConsolePortEditView.as_view(), name='consoleport_edit'), + path(r'console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), + path(r'console-ports//trace/', views.CableTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), # Console server ports - url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), - url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), - url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), - url(r'^console-server-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), - url(r'^console-server-ports/(?P\d+)/edit/$', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), - url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), - url(r'^console-server-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - url(r'^console-server-ports/rename/$', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), - url(r'^console-server-ports/disconnect/$', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), + path(r'devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), + path(r'devices//console-server-ports/add/', views.ConsoleServerPortCreateView.as_view(), name='consoleserverport_add'), + path(r'devices//console-server-ports/edit/', views.ConsoleServerPortBulkEditView.as_view(), name='consoleserverport_bulk_edit'), + path(r'devices//console-server-ports/delete/', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), + path(r'console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), + path(r'console-server-ports//edit/', views.ConsoleServerPortEditView.as_view(), name='consoleserverport_edit'), + path(r'console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), + path(r'console-server-ports//trace/', views.CableTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), + path(r'console-server-ports/rename/', views.ConsoleServerPortBulkRenameView.as_view(), name='consoleserverport_bulk_rename'), + path(r'console-server-ports/disconnect/', views.ConsoleServerPortBulkDisconnectView.as_view(), name='consoleserverport_bulk_disconnect'), # Power ports - url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), - url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortCreateView.as_view(), name='powerport_add'), - url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), - url(r'^power-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), - url(r'^power-ports/(?P\d+)/edit/$', views.PowerPortEditView.as_view(), name='powerport_edit'), - url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), - url(r'^power-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), + path(r'devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), + path(r'devices//power-ports/add/', views.PowerPortCreateView.as_view(), name='powerport_add'), + path(r'devices//power-ports/delete/', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), + path(r'power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), + path(r'power-ports//edit/', views.PowerPortEditView.as_view(), name='powerport_edit'), + path(r'power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), + path(r'power-ports//trace/', views.CableTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), # Power outlets - url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), - url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), - url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), - url(r'^power-outlets/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), - url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), - url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), - url(r'^power-outlets/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - url(r'^power-outlets/rename/$', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), - url(r'^power-outlets/disconnect/$', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), + path(r'devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), + path(r'devices//power-outlets/add/', views.PowerOutletCreateView.as_view(), name='poweroutlet_add'), + path(r'devices//power-outlets/edit/', views.PowerOutletBulkEditView.as_view(), name='poweroutlet_bulk_edit'), + path(r'devices//power-outlets/delete/', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), + path(r'power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), + path(r'power-outlets//edit/', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), + path(r'power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + path(r'power-outlets//trace/', views.CableTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), + path(r'power-outlets/rename/', views.PowerOutletBulkRenameView.as_view(), name='poweroutlet_bulk_rename'), + path(r'power-outlets/disconnect/', views.PowerOutletBulkDisconnectView.as_view(), name='poweroutlet_bulk_disconnect'), # Interfaces - url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), - url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), - url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^interfaces/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), - url(r'^interfaces/(?P\d+)/$', views.InterfaceView.as_view(), name='interface'), - url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), - url(r'^interfaces/(?P\d+)/assign-vlans/$', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), - url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), - url(r'^interfaces/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), - url(r'^interfaces/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - url(r'^interfaces/rename/$', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), - url(r'^interfaces/disconnect/$', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), + path(r'devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), + path(r'devices//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), + path(r'devices//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + path(r'devices//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path(r'interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), + path(r'interfaces//', views.InterfaceView.as_view(), name='interface'), + path(r'interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), + path(r'interfaces//assign-vlans/', views.InterfaceAssignVLANsView.as_view(), name='interface_assign_vlans'), + path(r'interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), + path(r'interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), + path(r'interfaces//trace/', views.CableTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), + path(r'interfaces/rename/', views.InterfaceBulkRenameView.as_view(), name='interface_bulk_rename'), + path(r'interfaces/disconnect/', views.InterfaceBulkDisconnectView.as_view(), name='interface_bulk_disconnect'), # Front ports - # url(r'^devices/front-ports/add/$', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), - url(r'^devices/(?P\d+)/front-ports/add/$', views.FrontPortCreateView.as_view(), name='frontport_add'), - url(r'^devices/(?P\d+)/front-ports/edit/$', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), - url(r'^devices/(?P\d+)/front-ports/delete/$', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), - url(r'^front-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), - url(r'^front-ports/(?P\d+)/edit/$', views.FrontPortEditView.as_view(), name='frontport_edit'), - url(r'^front-ports/(?P\d+)/delete/$', views.FrontPortDeleteView.as_view(), name='frontport_delete'), - url(r'^front-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - url(r'^front-ports/rename/$', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), - url(r'^front-ports/disconnect/$', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), + # path(r'devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), + path(r'devices//front-ports/add/', views.FrontPortCreateView.as_view(), name='frontport_add'), + path(r'devices//front-ports/edit/', views.FrontPortBulkEditView.as_view(), name='frontport_bulk_edit'), + path(r'devices//front-ports/delete/', views.FrontPortBulkDeleteView.as_view(), name='frontport_bulk_delete'), + path(r'front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), + path(r'front-ports//edit/', views.FrontPortEditView.as_view(), name='frontport_edit'), + path(r'front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), + path(r'front-ports//trace/', views.CableTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), + path(r'front-ports/rename/', views.FrontPortBulkRenameView.as_view(), name='frontport_bulk_rename'), + path(r'front-ports/disconnect/', views.FrontPortBulkDisconnectView.as_view(), name='frontport_bulk_disconnect'), # Rear ports - # url(r'^devices/rear-ports/add/$', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), - url(r'^devices/(?P\d+)/rear-ports/add/$', views.RearPortCreateView.as_view(), name='rearport_add'), - url(r'^devices/(?P\d+)/rear-ports/edit/$', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), - url(r'^devices/(?P\d+)/rear-ports/delete/$', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), - url(r'^rear-ports/(?P\d+)/connect/$', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), - url(r'^rear-ports/(?P\d+)/edit/$', views.RearPortEditView.as_view(), name='rearport_edit'), - url(r'^rear-ports/(?P\d+)/delete/$', views.RearPortDeleteView.as_view(), name='rearport_delete'), - url(r'^rear-ports/(?P\d+)/trace/$', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - url(r'^rear-ports/rename/$', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), - url(r'^rear-ports/disconnect/$', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), + # path(r'devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + path(r'devices//rear-ports/add/', views.RearPortCreateView.as_view(), name='rearport_add'), + path(r'devices//rear-ports/edit/', views.RearPortBulkEditView.as_view(), name='rearport_bulk_edit'), + path(r'devices//rear-ports/delete/', views.RearPortBulkDeleteView.as_view(), name='rearport_bulk_delete'), + path(r'rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), + path(r'rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), + path(r'rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), + path(r'rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), + path(r'rear-ports/rename/', views.RearPortBulkRenameView.as_view(), name='rearport_bulk_rename'), + path(r'rear-ports/disconnect/', views.RearPortBulkDisconnectView.as_view(), name='rearport_bulk_disconnect'), # Device bays - url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), - url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayCreateView.as_view(), name='devicebay_add'), - url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), - url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), - url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), - url(r'^device-bays/(?P\d+)/populate/$', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), - url(r'^device-bays/(?P\d+)/depopulate/$', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), - url(r'^device-bays/rename/$', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), + path(r'devices/device-bays/add/', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), + path(r'devices//bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'), + path(r'devices//bays/delete/', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), + path(r'device-bays//edit/', views.DeviceBayEditView.as_view(), name='devicebay_edit'), + path(r'device-bays//delete/', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), + path(r'device-bays//populate/', views.DeviceBayPopulateView.as_view(), name='devicebay_populate'), + path(r'device-bays//depopulate/', views.DeviceBayDepopulateView.as_view(), name='devicebay_depopulate'), + path(r'device-bays/rename/', views.DeviceBayBulkRenameView.as_view(), name='devicebay_bulk_rename'), # Inventory items - url(r'^inventory-items/$', views.InventoryItemListView.as_view(), name='inventoryitem_list'), - url(r'^inventory-items/import/$', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), - url(r'^inventory-items/edit/$', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), - url(r'^inventory-items/delete/$', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), - url(r'^inventory-items/(?P\d+)/edit/$', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), - url(r'^inventory-items/(?P\d+)/delete/$', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), - url(r'^devices/(?P\d+)/inventory-items/add/$', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), + path(r'inventory-items/', views.InventoryItemListView.as_view(), name='inventoryitem_list'), + path(r'inventory-items/import/', views.InventoryItemBulkImportView.as_view(), name='inventoryitem_import'), + path(r'inventory-items/edit/', views.InventoryItemBulkEditView.as_view(), name='inventoryitem_bulk_edit'), + path(r'inventory-items/delete/', views.InventoryItemBulkDeleteView.as_view(), name='inventoryitem_bulk_delete'), + path(r'inventory-items//edit/', views.InventoryItemEditView.as_view(), name='inventoryitem_edit'), + path(r'inventory-items//delete/', views.InventoryItemDeleteView.as_view(), name='inventoryitem_delete'), + path(r'devices//inventory-items/add/', views.InventoryItemEditView.as_view(), name='inventoryitem_add'), # Cables - url(r'^cables/$', views.CableListView.as_view(), name='cable_list'), - url(r'^cables/import/$', views.CableBulkImportView.as_view(), name='cable_import'), - url(r'^cables/edit/$', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), - url(r'^cables/delete/$', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), - url(r'^cables/(?P\d+)/$', views.CableView.as_view(), name='cable'), - url(r'^cables/(?P\d+)/edit/$', views.CableEditView.as_view(), name='cable_edit'), - url(r'^cables/(?P\d+)/delete/$', views.CableDeleteView.as_view(), name='cable_delete'), - url(r'^cables/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path(r'cables/', views.CableListView.as_view(), name='cable_list'), + path(r'cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), + path(r'cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), + path(r'cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), + path(r'cables//', views.CableView.as_view(), name='cable'), + path(r'cables//edit/', views.CableEditView.as_view(), name='cable_edit'), + path(r'cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), + path(r'cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), # Console/power/interface connections (read-only) - url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), - url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'), - url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), + path(r'console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), + path(r'power-connections/', views.PowerConnectionsListView.as_view(), name='power_connections_list'), + path(r'interface-connections/', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), # Virtual chassis - url(r'^virtual-chassis/$', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), - url(r'^virtual-chassis/add/$', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), - url(r'^virtual-chassis/(?P\d+)/edit/$', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), - url(r'^virtual-chassis/(?P\d+)/delete/$', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), - url(r'^virtual-chassis/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), - url(r'^virtual-chassis/(?P\d+)/add-member/$', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), - url(r'^virtual-chassis-members/(?P\d+)/delete/$', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + path(r'virtual-chassis/', views.VirtualChassisListView.as_view(), name='virtualchassis_list'), + path(r'virtual-chassis/add/', views.VirtualChassisCreateView.as_view(), name='virtualchassis_add'), + path(r'virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), + path(r'virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), + path(r'virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path(r'virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), + path(r'virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), + + # Power panels + path(r'power-panels/', views.PowerPanelListView.as_view(), name='powerpanel_list'), + path(r'power-panels/add/', views.PowerPanelCreateView.as_view(), name='powerpanel_add'), + path(r'power-panels/import/', views.PowerPanelBulkImportView.as_view(), name='powerpanel_import'), + path(r'power-panels/delete/', views.PowerPanelBulkDeleteView.as_view(), name='powerpanel_bulk_delete'), + path(r'power-panels//', views.PowerPanelView.as_view(), name='powerpanel'), + path(r'power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), + path(r'power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), + path(r'power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + + # Power feeds + path(r'power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), + path(r'power-feeds/add/', views.PowerFeedEditView.as_view(), name='powerfeed_add'), + path(r'power-feeds/import/', views.PowerFeedBulkImportView.as_view(), name='powerfeed_import'), + path(r'power-feeds/edit/', views.PowerFeedBulkEditView.as_view(), name='powerfeed_bulk_edit'), + path(r'power-feeds/delete/', views.PowerFeedBulkDeleteView.as_view(), name='powerfeed_bulk_delete'), + path(r'power-feeds//', views.PowerFeedView.as_view(), name='powerfeed'), + path(r'power-feeds//edit/', views.PowerFeedEditView.as_view(), name='powerfeed_edit'), + path(r'power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), + path(r'power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), ] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 27f90a3a2..5ddaf15ed 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3,6 +3,7 @@ import re from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin +from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger from django.db import transaction from django.db.models import Count, F @@ -10,6 +11,7 @@ from django.forms import modelformset_factory from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape +from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View @@ -30,8 +32,9 @@ from . import filters, forms, tables from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, - RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, + InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, + PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + VirtualChassis, ) @@ -135,7 +138,8 @@ class BulkDisconnectView(GetReturnURLMixin, View): # Regions # -class RegionListView(ObjectListView): +class RegionListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_region' queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -179,7 +183,8 @@ class RegionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Sites # -class SiteListView(ObjectListView): +class SiteListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_site' queryset = Site.objects.select_related('region', 'tenant') filter = filters.SiteFilter filter_form = forms.SiteFilterForm @@ -187,7 +192,8 @@ class SiteListView(ObjectListView): template_name = 'dcim/site_list.html' -class SiteView(View): +class SiteView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_site' def get(self, request, slug): @@ -247,11 +253,20 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:site_list' +class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_site' + queryset = Site.objects.select_related('region', 'tenant') + filter = filters.SiteFilter + table = tables.SiteTable + default_return_url = 'dcim:site_list' + + # # Rack groups # -class RackGroupListView(ObjectListView): +class RackGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rackgroup' queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter filter_form = forms.RackGroupFilterForm @@ -289,7 +304,8 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack roles # -class RackRoleListView(ObjectListView): +class RackRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rackrole' queryset = RackRole.objects.annotate(rack_count=Count('racks')) table = tables.RackRoleTable template_name = 'dcim/rackrole_list.html' @@ -324,7 +340,8 @@ class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Racks # -class RackListView(ObjectListView): +class RackListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rack' queryset = Rack.objects.select_related( 'site', 'group', 'tenant', 'role' ).prefetch_related( @@ -338,10 +355,11 @@ class RackListView(ObjectListView): template_name = 'dcim/rack_list.html' -class RackElevationListView(View): +class RackElevationListView(PermissionRequiredMixin, View): """ Display a set of rack elevations side-by-side. """ + permission_required = 'dcim.view_rack' def get(self, request): @@ -379,7 +397,8 @@ class RackElevationListView(View): }) -class RackView(View): +class RackView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_rack' def get(self, request, pk): @@ -391,10 +410,12 @@ class RackView(View): prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name').first() reservations = RackReservation.objects.filter(rack=rack) + power_feeds = PowerFeed.objects.filter(rack=rack).select_related('power_panel') return render(request, 'dcim/rack.html', { 'rack': rack, 'reservations': reservations, + 'power_feeds': power_feeds, 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, @@ -449,8 +470,9 @@ class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Rack reservations # -class RackReservationListView(ObjectListView): - queryset = RackReservation.objects.all() +class RackReservationListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_rackreservation' + queryset = RackReservation.objects.select_related('rack__site') filter = filters.RackReservationFilter filter_form = forms.RackReservationFilterForm table = tables.RackReservationTable @@ -505,9 +527,11 @@ class RackReservationBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Manufacturers # -class ManufacturerListView(ObjectListView): +class ManufacturerListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_manufacturer' queryset = Manufacturer.objects.annotate( devicetype_count=Count('device_types', distinct=True), + inventoryitem_count=Count('inventory_items', distinct=True), platform_count=Count('platforms', distinct=True), ) table = tables.ManufacturerTable @@ -543,7 +567,8 @@ class ManufacturerBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device types # -class DeviceTypeListView(ObjectListView): +class DeviceTypeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_devicetype' queryset = DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')) filter = filters.DeviceTypeFilter filter_form = forms.DeviceTypeFilterForm @@ -551,7 +576,8 @@ class DeviceTypeListView(ObjectListView): template_name = 'dcim/devicetype_list.html' -class DeviceTypeView(View): +class DeviceTypeView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_devicetype' def get(self, request, pk): @@ -807,7 +833,8 @@ class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device roles # -class DeviceRoleListView(ObjectListView): +class DeviceRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_devicerole' queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable template_name = 'dcim/devicerole_list.html' @@ -842,7 +869,8 @@ class DeviceRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Platforms # -class PlatformListView(ObjectListView): +class PlatformListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_platform' queryset = Platform.objects.all() table = tables.PlatformTable template_name = 'dcim/platform_list.html' @@ -877,7 +905,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Devices # -class DeviceListView(ObjectListView): +class DeviceListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_device' queryset = Device.objects.select_related( 'device_type__manufacturer', 'device_role', 'tenant', 'site', 'rack', 'primary_ip4', 'primary_ip6' ) @@ -887,7 +916,8 @@ class DeviceListView(ObjectListView): template_name = 'dcim/device_list.html' -class DeviceView(View): +class DeviceView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_device' def get(self, request, pk): @@ -910,10 +940,10 @@ class DeviceView(View): consoleserverports = device.consoleserverports.select_related('connected_endpoint__device', 'cable') # Power ports - power_ports = device.powerports.select_related('connected_endpoint__device', 'cable') + power_ports = device.powerports.select_related('_connected_poweroutlet__device', 'cable') # Power outlets - poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable') + poweroutlets = device.poweroutlets.select_related('connected_endpoint__device', 'cable', 'power_port') # Interfaces interfaces = device.vc_interfaces.select_related( @@ -967,7 +997,8 @@ class DeviceView(View): }) -class DeviceInventoryView(View): +class DeviceInventoryView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_device' def get(self, request, pk): @@ -988,7 +1019,7 @@ class DeviceInventoryView(View): class DeviceStatusView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') def get(self, request, pk): @@ -1001,7 +1032,7 @@ class DeviceStatusView(PermissionRequiredMixin, View): class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') def get(self, request, pk): @@ -1018,7 +1049,7 @@ class DeviceLLDPNeighborsView(PermissionRequiredMixin, View): class DeviceConfigView(PermissionRequiredMixin, View): - permission_required = 'dcim.napalm_read' + permission_required = ('dcim.view_device', 'dcim.napalm_read') def get(self, request, pk): @@ -1030,7 +1061,8 @@ class DeviceConfigView(PermissionRequiredMixin, View): }) -class DeviceConfigContextView(ObjectConfigContextView): +class DeviceConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): + permission_required = 'dcim.view_device' object_class = Device base_template = 'dcim/device.html' @@ -1154,6 +1186,14 @@ class ConsoleServerPortDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = ConsoleServerPort +class ConsoleServerPortBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_consoleserverport' + queryset = ConsoleServerPort.objects.all() + parent_model = Device + table = tables.ConsoleServerPortTable + form = forms.ConsoleServerPortBulkEditForm + + class ConsoleServerPortBulkRenameView(PermissionRequiredMixin, BulkRenameView): permission_required = 'dcim.change_consoleserverport' queryset = ConsoleServerPort.objects.all() @@ -1230,6 +1270,14 @@ class PowerOutletDeleteView(PermissionRequiredMixin, ObjectDeleteView): model = PowerOutlet +class PowerOutletBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_poweroutlet' + queryset = PowerOutlet.objects.all() + parent_model = Device + table = tables.PowerOutletTable + form = forms.PowerOutletBulkEditForm + + class PowerOutletBulkRenameView(PermissionRequiredMixin, BulkRenameView): permission_required = 'dcim.change_poweroutlet' queryset = PowerOutlet.objects.all() @@ -1253,7 +1301,8 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # -class InterfaceView(View): +class InterfaceView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_interface' def get(self, request, pk): @@ -1634,7 +1683,8 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie # Cables # -class CableListView(ObjectListView): +class CableListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_cable' queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' ) @@ -1644,7 +1694,8 @@ class CableListView(ObjectListView): template_name = 'dcim/cable_list.html' -class CableView(View): +class CableView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_cable' def get(self, request, pk): @@ -1655,10 +1706,11 @@ class CableView(View): }) -class CableTraceView(View): +class CableTraceView(PermissionRequiredMixin, View): """ Trace a cable path beginning from the given termination. """ + permission_required = 'dcim.view_cable' def get(self, request, model, pk): @@ -1670,20 +1722,80 @@ class CableTraceView(View): }) -class CableCreateView(PermissionRequiredMixin, ObjectEditView): +class CableCreateView(PermissionRequiredMixin, GetReturnURLMixin, View): permission_required = 'dcim.add_cable' - model = Cable - model_form = forms.CableCreateForm template_name = 'dcim/cable_connect.html' - def alter_obj(self, obj, request, url_args, url_kwargs): + def dispatch(self, request, *args, **kwargs): - # Retrieve endpoint A based on the given type and PK - termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) + termination_a_type = kwargs.get('termination_a_type') + termination_a_id = kwargs.get('termination_a_id') - return obj + termination_b_type_name = kwargs.get('termination_b_type') + self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) + + self.obj = Cable( + termination_a=termination_a_type.objects.get(pk=termination_a_id), + termination_b_type=self.termination_b_type + ) + self.form_class = { + 'console-port': forms.ConnectCableToConsolePortForm, + 'console-server-port': forms.ConnectCableToConsoleServerPortForm, + 'power-port': forms.ConnectCableToPowerPortForm, + 'power-outlet': forms.ConnectCableToPowerOutletForm, + 'interface': forms.ConnectCableToInterfaceForm, + 'front-port': forms.ConnectCableToFrontPortForm, + 'rear-port': forms.ConnectCableToRearPortForm, + 'power-feed': forms.ConnectCableToPowerFeedForm, + 'circuit-termination': forms.ConnectCableToCircuitTerminationForm, + }[termination_b_type_name] + + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + + # Parse initial data manually to avoid setting field values as lists + initial_data = {k: request.GET[k] for k in request.GET} + + form = self.form_class(instance=self.obj, initial=initial_data) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'termination_b_type': self.termination_b_type.name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) + + def post(self, request, *args, **kwargs): + + form = self.form_class(request.POST, request.FILES, instance=self.obj) + + if form.is_valid(): + obj = form.save() + + msg = 'Created cable {}'.format( + obj.get_absolute_url(), + escape(obj) + ) + messages.success(request, mark_safe(msg)) + + if '_addanother' in request.POST: + return redirect(request.get_full_path()) + + return_url = form.cleaned_data.get('return_url') + if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()): + return redirect(return_url) + else: + return redirect(self.get_return_url(request, obj)) + + return render(request, self.template_name, { + 'obj': self.obj, + 'obj_type': Cable._meta.verbose_name, + 'termination_b_type': self.termination_b_type.name, + 'form': form, + 'return_url': self.get_return_url(request, self.obj), + }) class CableEditView(PermissionRequiredMixin, ObjectEditView): @@ -1728,7 +1840,8 @@ class CableBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Connections # -class ConsoleConnectionsListView(ObjectListView): +class ConsoleConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = ('dcim.view_consoleport', 'dcim.view_consoleserverport') queryset = ConsolePort.objects.select_related( 'device', 'connected_endpoint__device' ).filter( @@ -1758,13 +1871,14 @@ class ConsoleConnectionsListView(ObjectListView): return csv_data -class PowerConnectionsListView(ObjectListView): +class PowerConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = ('dcim.view_powerport', 'dcim.view_poweroutlet') queryset = PowerPort.objects.select_related( - 'device', 'connected_endpoint__device' + 'device', '_connected_poweroutlet__device' ).filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ).order_by( - 'cable', 'connected_endpoint__device__name', 'connected_endpoint__name' + 'cable', '_connected_poweroutlet__device__name', '_connected_poweroutlet__name' ) filter = filters.PowerConnectionFilter filter_form = forms.PowerConnectionFilterForm @@ -1788,7 +1902,8 @@ class PowerConnectionsListView(ObjectListView): return csv_data -class InterfaceConnectionsListView(ObjectListView): +class InterfaceConnectionsListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_interface' queryset = Interface.objects.select_related( 'device', 'cable', '_connected_interface__device' ).filter( @@ -1830,7 +1945,8 @@ class InterfaceConnectionsListView(ObjectListView): # Inventory items # -class InventoryItemListView(ObjectListView): +class InventoryItemListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_inventoryitem' queryset = InventoryItem.objects.select_related('device', 'manufacturer') filter = filters.InventoryItemFilter filter_form = forms.InventoryItemFilterForm @@ -1885,7 +2001,8 @@ class InventoryItemBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Virtual chassis # -class VirtualChassisListView(ObjectListView): +class VirtualChassisListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_virtualchassis' queryset = VirtualChassis.objects.select_related('master').annotate(member_count=Count('members')) table = tables.VirtualChassisTable filter = filters.VirtualChassisFilter @@ -2114,3 +2231,143 @@ class VirtualChassisRemoveMemberView(PermissionRequiredMixin, GetReturnURLMixin, 'form': form, 'return_url': self.get_return_url(request, device), }) + + +# +# Power panels +# + +class PowerPanelListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_powerpanel' + queryset = PowerPanel.objects.select_related( + 'site', 'rack_group' + ).annotate( + powerfeed_count=Count('powerfeeds') + ) + filter = filters.PowerPanelFilter + filter_form = forms.PowerPanelFilterForm + table = tables.PowerPanelTable + template_name = 'dcim/powerpanel_list.html' + + +class PowerPanelView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_powerpanel' + + def get(self, request, pk): + + powerpanel = get_object_or_404(PowerPanel.objects.select_related('site', 'rack_group'), pk=pk) + powerfeed_table = tables.PowerFeedTable( + data=PowerFeed.objects.filter(power_panel=powerpanel).select_related('rack'), + orderable=False + ) + powerfeed_table.exclude = ['power_panel'] + + return render(request, 'dcim/powerpanel.html', { + 'powerpanel': powerpanel, + 'powerfeed_table': powerfeed_table, + }) + + +class PowerPanelCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_powerpanel' + model = PowerPanel + model_form = forms.PowerPanelForm + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelEditView(PowerPanelCreateView): + permission_required = 'dcim.change_powerpanel' + + +class PowerPanelDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerpanel' + model = PowerPanel + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_powerpanel' + model_form = forms.PowerPanelCSVForm + table = tables.PowerPanelTable + default_return_url = 'dcim:powerpanel_list' + + +class PowerPanelBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_powerpanel' + queryset = PowerPanel.objects.select_related( + 'site', 'rack_group' + ).annotate( + rack_count=Count('powerfeeds') + ) + filter = filters.PowerPanelFilter + table = tables.PowerPanelTable + default_return_url = 'dcim:powerpanel_list' + + +# +# Power feeds +# + +class PowerFeedListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'dcim.view_powerfeed' + queryset = PowerFeed.objects.select_related( + 'power_panel', 'rack' + ) + filter = filters.PowerFeedFilter + filter_form = forms.PowerFeedFilterForm + table = tables.PowerFeedTable + template_name = 'dcim/powerfeed_list.html' + + +class PowerFeedView(PermissionRequiredMixin, View): + permission_required = 'dcim.view_powerfeed' + + def get(self, request, pk): + + powerfeed = get_object_or_404(PowerFeed.objects.select_related('power_panel', 'rack'), pk=pk) + + return render(request, 'dcim/powerfeed.html', { + 'powerfeed': powerfeed, + }) + + +class PowerFeedCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.add_powerfeed' + model = PowerFeed + model_form = forms.PowerFeedForm + template_name = 'dcim/powerfeed_edit.html' + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedEditView(PowerFeedCreateView): + permission_required = 'dcim.change_powerfeed' + + +class PowerFeedDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'dcim.delete_powerfeed' + model = PowerFeed + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_powerfeed' + model_form = forms.PowerFeedCSVForm + table = tables.PowerFeedTable + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_powerfeed' + queryset = PowerFeed.objects.select_related('power_panel', 'rack') + filter = filters.PowerFeedFilter + table = tables.PowerFeedTable + form = forms.PowerFeedBulkEditForm + default_return_url = 'dcim:powerfeed_list' + + +class PowerFeedBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_powerfeed' + queryset = PowerFeed.objects.select_related('power_panel', 'rack') + filter = filters.PowerFeedFilter + table = tables.PowerFeedTable + default_return_url = 'dcim:powerfeed_list' diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index b4962dfd7..d93b04037 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -3,7 +3,7 @@ from django.contrib import admin from netbox.admin import admin_site from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, Webhook +from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook def order_content_types(field): @@ -77,6 +77,35 @@ class CustomFieldAdmin(admin.ModelAdmin): return ', '.join([ct.name for ct in obj.obj_type.all()]) +# +# Custom links +# + +class CustomLinkForm(forms.ModelForm): + + class Meta: + model = CustomLink + exclude = [] + help_texts = { + 'text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. Links ' + 'which render as empty text will not be displayed.', + 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Format ContentType choices + order_content_types(self.fields['content_type']) + self.fields['content_type'].choices.insert(0, ('', '---------')) + + +@admin.register(CustomLink, site=admin_site) +class CustomLinkAdmin(admin.ModelAdmin): + list_display = ['name', 'content_type', 'group_name', 'weight'] + form = CustomLinkForm + + # # Graphs # diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cca783bc6..abf0d8cf5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,7 +1,7 @@ from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from taggit.models import Tag from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, @@ -11,6 +11,7 @@ from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site from extras.constants import * from extras.models import ( ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + Tag ) from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup @@ -89,7 +90,7 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'name', 'slug', 'tagged_items'] + fields = ['id', 'name', 'slug', 'color', 'comments', 'tagged_items'] # @@ -123,6 +124,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): return data + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_parent(self, obj): # Static mapping of models to their nested serializers @@ -237,6 +239,7 @@ class ObjectChangeSerializer(serializers.ModelSerializer): 'object_data', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_changed_object(self, obj): """ Serialize a nested representation of the changed object. diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 1bdcf181b..c135280ea 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -17,6 +17,9 @@ router.APIRootView = ExtrasRootView # Field choices router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') +# Custom field choices +router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice') + # Graphs router.register(r'graphs', views.GraphViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 2150cb5b5..44e010cd2 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -1,3 +1,5 @@ +from collections import OrderedDict + from django.contrib.contenttypes.models import ContentType from django.db.models import Count from django.http import Http404, HttpResponse @@ -6,11 +8,11 @@ from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from rest_framework.viewsets import ReadOnlyModelViewSet, ViewSet -from taggit.models import Tag from extras import filters from extras.models import ( - ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + ConfigContext, CustomFieldChoice, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, + Tag, ) from extras.reports import get_report, get_reports from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -29,6 +31,36 @@ class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): ) +# +# Custom field choices +# + +class CustomFieldChoicesViewSet(ViewSet): + """ + """ + permission_classes = [IsAuthenticatedOrLoginNotRequired] + + def __init__(self, *args, **kwargs): + super(CustomFieldChoicesViewSet, self).__init__(*args, **kwargs) + + self._fields = OrderedDict() + + for cfc in CustomFieldChoice.objects.all(): + self._fields.setdefault(cfc.field.name, {}) + self._fields[cfc.field.name][cfc.value] = cfc.pk + + def list(self, request): + return Response(self._fields) + + def retrieve(self, request, pk): + if pk not in self._fields: + raise Http404 + return Response(self._fields[pk]) + + def get_view_name(self): + return "Custom Field choices" + + # # Custom fields # @@ -117,7 +149,7 @@ class TopologyMapViewSet(ModelViewSet): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate( - tagged_items=Count('taggit_taggeditem_items', distinct=True) + tagged_items=Count('extras_taggeditem_items', distinct=True) ) serializer_class = serializers.TagSerializer filterset_class = filters.TagFilter diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 6e6083691..6bb3b9fca 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -7,6 +7,9 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): + + import extras.signals + # Check that we can connect to the configured Redis database if webhooks are enabled. if settings.WEBHOOKS_ENABLED: try: diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 13c15cbba..b72ae8c08 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -1,13 +1,24 @@ # Models which support custom fields -CUSTOMFIELD_MODELS = ( - 'provider', 'circuit', # Circuits - 'site', 'rack', 'devicetype', 'device', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM - 'secret', # Secrets - 'tenant', # Tenancy - 'cluster', 'virtualmachine', # Virtualization -) +CUSTOMFIELD_MODELS = [ + 'circuits.circuit', + 'circuits.provider', + 'dcim.device', + 'dcim.devicetype', + 'dcim.powerfeed', + 'dcim.rack', + 'dcim.site', + 'ipam.aggregate', + 'ipam.ipaddress', + 'ipam.prefix', + 'ipam.service', + 'ipam.vlan', + 'ipam.vrf', + 'secrets.secret', + 'tenancy.tenant', + 'virtualization.cluster', + 'virtualization.virtualmachine', +] # Custom field types CF_TYPE_TEXT = 100 @@ -35,6 +46,46 @@ CF_FILTER_CHOICES = ( (CF_FILTER_EXACT, 'Exact'), ) +# Custom links +CUSTOMLINK_MODELS = [ + 'circuits.circuit', + 'circuits.provider', + 'dcim.cable', + 'dcim.device', + 'dcim.devicetype', + 'dcim.powerpanel', + 'dcim.powerfeed', + 'dcim.rack', + 'dcim.site', + 'ipam.aggregate', + 'ipam.ipaddress', + 'ipam.prefix', + 'ipam.service', + 'ipam.vlan', + 'ipam.vrf', + 'secrets.secret', + 'tenancy.tenant', + 'virtualization.cluster', + 'virtualization.virtualmachine', +] + +BUTTON_CLASS_DEFAULT = 'default' +BUTTON_CLASS_PRIMARY = 'primary' +BUTTON_CLASS_SUCCESS = 'success' +BUTTON_CLASS_INFO = 'info' +BUTTON_CLASS_WARNING = 'warning' +BUTTON_CLASS_DANGER = 'danger' +BUTTON_CLASS_LINK = 'link' +BUTTON_CLASS_CHOICES = ( + (BUTTON_CLASS_DEFAULT, 'Default'), + (BUTTON_CLASS_PRIMARY, 'Primary (blue)'), + (BUTTON_CLASS_SUCCESS, 'Success (green)'), + (BUTTON_CLASS_INFO, 'Info (aqua)'), + (BUTTON_CLASS_WARNING, 'Warning (orange)'), + (BUTTON_CLASS_DANGER, 'Danger (red)'), + (BUTTON_CLASS_LINK, 'None (link)'), +) + # Graph types GRAPH_TYPE_INTERFACE = 100 GRAPH_TYPE_PROVIDER = 200 @@ -47,13 +98,32 @@ GRAPH_TYPE_CHOICES = ( # Models which support export templates EXPORTTEMPLATE_MODELS = [ - 'provider', 'circuit', # Circuits - 'site', 'region', 'rack', 'rackgroup', 'manufacturer', 'devicetype', 'device', # DCIM - 'consoleport', 'powerport', 'interface', 'cable', 'virtualchassis', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM - 'secret', # Secrets - 'tenant', # Tenancy - 'cluster', 'virtualmachine', # Virtualization + 'circuits.circuit', + 'circuits.provider', + 'dcim.cable', + 'dcim.consoleport', + 'dcim.device', + 'dcim.devicetype', + 'dcim.interface', + 'dcim.manufacturer', + 'dcim.powerpanel', + 'dcim.powerport', + 'dcim.powerfeed', + 'dcim.rack', + 'dcim.rackgroup', + 'dcim.region', + 'dcim.site', + 'dcim.virtualchassis', + 'ipam.aggregate', + 'ipam.ipaddress', + 'ipam.prefix', + 'ipam.service', + 'ipam.vlan', + 'ipam.vrf', + 'secrets.secret', + 'tenancy.tenant', + 'virtualization.cluster', + 'virtualization.virtualmachine', ] # ExportTemplate language choices @@ -125,13 +195,36 @@ WEBHOOK_CT_CHOICES = ( ) # Models which support registered webhooks -WEBHOOK_MODELS = ( - 'provider', 'circuit', # Circuits - 'site', 'rack', 'devicetype', 'device', 'virtualchassis', # DCIM - 'consoleport', 'consoleserverport', 'powerport', 'poweroutlet', - 'interface', 'devicebay', 'inventoryitem', - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM - 'secret', # Secrets - 'tenant', # Tenancy - 'cluster', 'virtualmachine', # Virtualization -) +WEBHOOK_MODELS = [ + 'circuits.circuit', + 'circuits.provider', + 'dcim.cable', + 'dcim.consoleport', + 'dcim.consoleserverport', + 'dcim.device', + 'dcim.devicebay', + 'dcim.devicetype', + 'dcim.interface', + 'dcim.inventoryitem', + 'dcim.frontport', + 'dcim.manufacturer', + 'dcim.poweroutlet', + 'dcim.powerpanel', + 'dcim.powerport', + 'dcim.powerfeed', + 'dcim.rack', + 'dcim.rearport', + 'dcim.region', + 'dcim.site', + 'dcim.virtualchassis', + 'ipam.aggregate', + 'ipam.ipaddress', + 'ipam.prefix', + 'ipam.service', + 'ipam.vlan', + 'ipam.vrf', + 'secrets.secret', + 'tenancy.tenant', + 'virtualization.cluster', + 'virtualization.virtualmachine', +] diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d5457a5a6..49e879fe4 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,12 +1,11 @@ import django_filters from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from .constants import CF_FILTER_DISABLED, CF_FILTER_EXACT, CF_TYPE_BOOLEAN, CF_TYPE_SELECT -from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, TopologyMap +from .models import ConfigContext, CustomField, Graph, ExportTemplate, ObjectChange, Tag, TopologyMap class CustomFieldFilter(django_filters.Filter): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 54eee0c5c..261822d28 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -5,19 +5,18 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from taggit.forms import TagField -from taggit.models import Tag from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, - FilterChoiceField, LaxURLField, JSONField, SlugField, + add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, + ContentTypeSelect, FilterChoiceField, LaxURLField, JSONField, SlugField, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, OBJECTCHANGE_ACTION_CHOICES, ) -from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange +from .models import ConfigContext, CustomField, CustomFieldValue, ImageAttachment, ObjectChange, Tag # @@ -189,11 +188,12 @@ class CustomFieldFilterForm(forms.Form): class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() + comments = CommentField() class Meta: model = Tag fields = [ - 'name', 'slug', + 'name', 'slug', 'color', 'comments' ] diff --git a/netbox/extras/management/commands/nbshell.py b/netbox/extras/management/commands/nbshell.py index c5a2fa1ec..18c0d0a0a 100644 --- a/netbox/extras/management/commands/nbshell.py +++ b/netbox/extras/management/commands/nbshell.py @@ -6,7 +6,6 @@ from django import get_version from django.apps import apps from django.conf import settings from django.core.management.base import BaseCommand -from django.db.models import Model APPS = ['circuits', 'dcim', 'extras', 'ipam', 'secrets', 'tenancy', 'users', 'virtualization'] @@ -38,16 +37,10 @@ class Command(BaseCommand): for app in APPS: self.django_models[app] = [] - # Models - app_models = sys.modules['{}.models'.format(app)] - for name in dir(app_models): - model = getattr(app_models, name) - try: - if issubclass(model, Model) and model._meta.app_label == app: - namespace[name] = model - self.django_models[app].append(name) - except TypeError: - pass + # Load models from each app + for model in apps.get_app_config(app).get_models(): + namespace[model.__name__] = model + self.django_models[app].append(model.__name__) # Constants try: diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index be878918b..b0b5a014d 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -7,6 +7,7 @@ from django.conf import settings from django.db.models.signals import post_delete, post_save from django.utils import timezone from django.utils.functional import curry +from django_prometheus.models import model_deletes, model_inserts, model_updates from extras.webhooks import enqueue_webhooks from .constants import ( @@ -29,25 +30,26 @@ def cache_changed_object(instance, **kwargs): def _record_object_deleted(request, instance, **kwargs): - # Force resolution of request.user in case it's still a SimpleLazyObject. This seems to happen - # occasionally during tests, but haven't been able to determine why. - assert request.user.is_authenticated - # Record that the object was deleted if hasattr(instance, 'log_change'): instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + # Enqueue webhooks enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE) + # Increment metric counters + model_deletes.labels(instance._meta.model_name).inc() + class ObjectChangeMiddleware(object): """ - This middleware performs two functions in response to an object being created, updated, or deleted: + This middleware performs three functions in response to an object being created, updated, or deleted: 1. Create an ObjectChange to reflect the modification to the object in the changelog. 2. Enqueue any relevant webhooks. + 3. Increment metric counter for the event type - The post_save and pre_delete signals are employed to catch object modifications, however changes are recorded a bit + The post_save and post_delete signals are employed to catch object modifications, however changes are recorded a bit differently for each. Objects being saved are cached into thread-local storage for action *after* the response has completed. This ensures that serialization of the object is performed only after any related objects (e.g. tags) have been created. Conversely, deletions are acted upon immediately, so that the serialized representation of the @@ -65,10 +67,10 @@ class ObjectChangeMiddleware(object): # the same request. request.id = uuid.uuid4() - # Signals don't include the request context, so we're currying it into the pre_delete function ahead of time. + # Signals don't include the request context, so we're currying it into the post_delete function ahead of time. record_object_deleted = curry(_record_object_deleted, request) - # Connect our receivers to the post_save and pre_delete signals. + # Connect our receivers to the post_save and post_delete signals. post_save.connect(cache_changed_object, dispatch_uid='record_object_saved') post_delete.connect(record_object_deleted, dispatch_uid='record_object_deleted') @@ -85,6 +87,12 @@ class ObjectChangeMiddleware(object): # Enqueue webhooks enqueue_webhooks(obj, request.user, request.id, action) + # Increment metric counters + if action == OBJECTCHANGE_ACTION_CREATE: + model_inserts.labels(obj._meta.model_name).inc() + elif action == OBJECTCHANGE_ACTION_UPDATE: + model_updates.labels(obj._meta.model_name).inc() + # Housekeeping: 1% chance of clearing out expired ObjectChanges if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION) diff --git a/netbox/extras/migrations/0019_tag_taggeditem.py b/netbox/extras/migrations/0019_tag_taggeditem.py new file mode 100644 index 000000000..7f531a737 --- /dev/null +++ b/netbox/extras/migrations/0019_tag_taggeditem.py @@ -0,0 +1,43 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0018_exporttemplate_add_jinja2'), + ] + + operations = [ + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TaggedItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('object_id', models.IntegerField(db_index=True)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_tagged_items', to='contenttypes.ContentType')), + ('tag', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='extras_taggeditem_items', to='extras.Tag')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterIndexTogether( + name='taggeditem', + index_together={('content_type', 'object_id')}, + ), + ] diff --git a/netbox/extras/migrations/0020_tag_data.py b/netbox/extras/migrations/0020_tag_data.py new file mode 100644 index 000000000..615ded8e2 --- /dev/null +++ b/netbox/extras/migrations/0020_tag_data.py @@ -0,0 +1,65 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +def copy_tags(apps, schema_editor): + """ + Copy data from taggit_tag to extras_tag + """ + TaggitTag = apps.get_model('taggit', 'Tag') + ExtrasTag = apps.get_model('extras', 'Tag') + + tags_values = TaggitTag.objects.all().values('id', 'name', 'slug') + tags = [ExtrasTag(**tag) for tag in tags_values] + ExtrasTag.objects.bulk_create(tags) + + +def copy_taggeditems(apps, schema_editor): + """ + Copy data from taggit_taggeditem to extras_taggeditem + """ + TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem') + ExtrasTaggedItem = apps.get_model('extras', 'TaggedItem') + + tagged_items_values = TaggitTaggedItem.objects.all().values('id', 'object_id', 'content_type_id', 'tag_id') + tagged_items = [ExtrasTaggedItem(**tagged_item) for tagged_item in tagged_items_values] + ExtrasTaggedItem.objects.bulk_create(tagged_items) + + +def delete_taggit_taggeditems(apps, schema_editor): + """ + Delete all TaggedItem instances from taggit_taggeditem + """ + TaggitTaggedItem = apps.get_model('taggit', 'TaggedItem') + TaggitTaggedItem.objects.all().delete() + + +def delete_taggit_tags(apps, schema_editor): + """ + Delete all Tag instances from taggit_tag + """ + TaggitTag = apps.get_model('taggit', 'Tag') + TaggitTag.objects.all().delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0019_tag_taggeditem'), + ('circuits', '0015_custom_tag_models'), + ('dcim', '0070_custom_tag_models'), + ('ipam', '0025_custom_tag_models'), + ('secrets', '0006_custom_tag_models'), + ('tenancy', '0006_custom_tag_models'), + ('virtualization', '0009_custom_tag_models'), + ] + + operations = [ + migrations.RunPython(copy_tags), + migrations.RunPython(copy_taggeditems), + migrations.RunPython(delete_taggit_taggeditems), + migrations.RunPython(delete_taggit_tags), + ] diff --git a/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py b/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py new file mode 100644 index 000000000..26ed34b31 --- /dev/null +++ b/netbox/extras/migrations/0021_add_color_comments_changelog_to_tag.py @@ -0,0 +1,34 @@ +# Generated by Django 2.1.4 on 2019-02-20 07:38 + +from django.db import migrations, models +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0020_tag_data'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='color', + field=utilities.fields.ColorField(max_length=6, default='9e9e9e'), + ), + migrations.AddField( + model_name='tag', + name='comments', + field=models.TextField(blank=True, default=''), + ), + migrations.AddField( + model_name='tag', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='tag', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/extras/migrations/0022_custom_links.py b/netbox/extras/migrations/0022_custom_links.py new file mode 100644 index 000000000..cd204f50a --- /dev/null +++ b/netbox/extras/migrations/0022_custom_links.py @@ -0,0 +1,48 @@ +from django.db import migrations, models +import django.db.models.deletion +import extras.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0021_add_color_comments_changelog_to_tag'), + ] + + operations = [ + migrations.CreateModel( + name='CustomLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('text', models.CharField(max_length=500)), + ('url', models.CharField(max_length=500)), + ('weight', models.PositiveSmallIntegerField(default=100)), + ('group_name', models.CharField(blank=True, max_length=50)), + ('button_class', models.CharField(default='default', max_length=30)), + ('new_window', models.BooleanField()), + ('content_type', models.ForeignKey(limit_choices_to=extras.models.get_custom_link_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['group_name', 'weight', 'name'], + }, + ), + + # Update limit_choices_to for CustomFields, ExportTemplates, and Webhooks + migrations.AlterField( + model_name='customfield', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.models.get_custom_field_models, related_name='custom_fields', to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='content_type', + field=models.ForeignKey(limit_choices_to=extras.models.get_export_template_models, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'), + ), + migrations.AlterField( + model_name='webhook', + name='obj_type', + field=models.ManyToManyField(limit_choices_to=extras.models.get_webhook_models, related_name='webhooks', to='contenttypes.ContentType'), + ), + ] diff --git a/netbox/extras/migrations/0023_fix_tag_sequences.py b/netbox/extras/migrations/0023_fix_tag_sequences.py new file mode 100644 index 000000000..faef5aa96 --- /dev/null +++ b/netbox/extras/migrations/0023_fix_tag_sequences.py @@ -0,0 +1,14 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0022_custom_links'), + ] + + operations = [ + # Update the last_value for tag Tag and TaggedItem ID sequences + migrations.RunSQL("SELECT setval('extras_tag_id_seq', (SELECT id FROM extras_tag ORDER BY id DESC LIMIT 1) + 1)"), + migrations.RunSQL("SELECT setval('extras_taggeditem_id_seq', (SELECT id FROM extras_taggeditem ORDER BY id DESC LIMIT 1) + 1)"), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index da8f09a50..c5df5c2e5 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -13,9 +13,11 @@ from django.template import Template, Context from django.urls import reverse import graphviz from jinja2 import Environment +from taggit.models import TagBase, GenericTaggedItemBase from dcim.constants import CONNECTION_STATUS_CONNECTED -from utilities.utils import deepmerge, foreground_color +from utilities.fields import ColorField +from utilities.utils import deepmerge, foreground_color, model_names_to_filter_dict from .constants import * from .querysets import ConfigContextQuerySet @@ -24,6 +26,10 @@ from .querysets import ConfigContextQuerySet # Webhooks # +def get_webhook_models(): + return model_names_to_filter_dict(WEBHOOK_MODELS) + + class Webhook(models.Model): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or @@ -35,7 +41,7 @@ class Webhook(models.Model): to=ContentType, related_name='webhooks', verbose_name='Object types', - limit_choices_to={'model__in': WEBHOOK_MODELS}, + limit_choices_to=get_webhook_models, help_text="The object(s) to which this Webhook applies." ) name = models.CharField( @@ -102,17 +108,22 @@ class Webhook(models.Model): # class CustomFieldModel(models.Model): + _cf = None class Meta: abstract = True + @property def cf(self): """ Name-based CustomFieldValue accessor for use in templates """ - if not hasattr(self, 'get_custom_fields'): - return dict() - return {field.name: value for field, value in self.get_custom_fields().items()} + if self._cf is None: + # Cache all custom field values for this instance + self._cf = { + field.name: value for field, value in self.get_custom_fields().items() + } + return self._cf def get_custom_fields(self): """ @@ -125,19 +136,23 @@ class CustomFieldModel(models.Model): # If the object exists, populate its custom fields with values if hasattr(self, 'pk'): - values = CustomFieldValue.objects.filter(obj_type=content_type, obj_id=self.pk).select_related('field') + values = self.custom_field_values.all() values_dict = {cfv.field_id: cfv.value for cfv in values} return OrderedDict([(field, values_dict.get(field.pk)) for field in fields]) else: return OrderedDict([(field, None) for field in fields]) +def get_custom_field_models(): + return model_names_to_filter_dict(CUSTOMFIELD_MODELS) + + class CustomField(models.Model): obj_type = models.ManyToManyField( to=ContentType, related_name='custom_fields', verbose_name='Object(s)', - limit_choices_to={'model__in': CUSTOMFIELD_MODELS}, + limit_choices_to=get_custom_field_models, help_text='The object(s) to which this field applies.' ) type = models.PositiveSmallIntegerField( @@ -298,6 +313,62 @@ class CustomFieldChoice(models.Model): CustomFieldValue.objects.filter(field__type=CF_TYPE_SELECT, serialized_value=str(pk)).delete() +# +# Custom links +# + +def get_custom_link_models(): + return model_names_to_filter_dict(CUSTOMLINK_MODELS) + + +class CustomLink(models.Model): + """ + A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template + code to be rendered with an object as context. + """ + content_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to=get_custom_link_models + ) + name = models.CharField( + max_length=100, + unique=True + ) + text = models.CharField( + max_length=500, + help_text="Jinja2 template code for link text" + ) + url = models.CharField( + max_length=500, + verbose_name='URL', + help_text="Jinja2 template code for link URL" + ) + weight = models.PositiveSmallIntegerField( + default=100 + ) + group_name = models.CharField( + max_length=50, + blank=True, + help_text="Links with the same group will appear as a dropdown menu" + ) + button_class = models.CharField( + max_length=30, + choices=BUTTON_CLASS_CHOICES, + default=BUTTON_CLASS_DEFAULT, + help_text="The class of the first link in a group will be used for the dropdown button" + ) + new_window = models.BooleanField( + help_text="Force link to open in a new window" + ) + + class Meta: + ordering = ['group_name', 'weight', 'name'] + + def __str__(self): + return self.name + + # # Graphs # @@ -343,11 +414,15 @@ class Graph(models.Model): # Export templates # +def get_export_template_models(): + return model_names_to_filter_dict(EXPORTTEMPLATE_MODELS) + + class ExportTemplate(models.Model): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, - limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS} + limit_choices_to=get_export_template_models ) name = models.CharField( max_length=100 @@ -564,7 +639,7 @@ class TopologyMap(models.Model): from dcim.models import PowerPort # Add all power connections to the graph - for pp in PowerPort.objects.filter(device__in=devices, connected_endpoint__device__in=devices): + for pp in PowerPort.objects.filter(device__in=devices, _connected_poweroutlet__device__in=devices): style = 'solid' if pp.connection_status == CONNECTION_STATUS_CONNECTED else 'dashed' self.graph.edge(pp.connected_endpoint.device.name, pp.device.name, style=style) @@ -885,3 +960,37 @@ class ObjectChange(models.Model): self.object_repr, self.object_data, ) + + +# +# Tags +# + +# TODO: figure out a way around this circular import for ObjectChange +from utilities.models import ChangeLoggedModel # noqa: E402 + + +class Tag(TagBase, ChangeLoggedModel): + color = ColorField( + default='9e9e9e' + ) + comments = models.TextField( + blank=True, + default='' + ) + + def get_absolute_url(self): + return reverse('extras:tag', args=[self.slug]) + + +class TaggedItem(GenericTaggedItemBase): + tag = models.ForeignKey( + to=Tag, + related_name="%(app_label)s_%(class)s_items", + on_delete=models.CASCADE + ) + + class Meta: + index_together = ( + ("content_type", "object_id") + ) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 439323c94..70c93968f 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,6 +1,24 @@ +from collections import OrderedDict + from django.db.models import Q, QuerySet +class CustomFieldQueryset: + """ + Annotate custom fields on objects within a QuerySet. + """ + def __init__(self, queryset, custom_fields): + self.queryset = queryset + self.model = queryset.model + self.custom_fields = custom_fields + + def __iter__(self): + for obj in self.queryset: + values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} + obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) + yield obj + + class ConfigContextQuerySet(QuerySet): def get_for_object(self, obj): diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py new file mode 100644 index 000000000..aa173b437 --- /dev/null +++ b/netbox/extras/signals.py @@ -0,0 +1,22 @@ +from cacheops.signals import cache_invalidated, cache_read +from prometheus_client import Counter + + +cacheops_cache_hit = Counter('cacheops_cache_hit', 'Number of cache hits') +cacheops_cache_miss = Counter('cacheops_cache_miss', 'Number of cache misses') +cacheops_cache_invalidated = Counter('cacheops_cache_invalidated', 'Number of cache invalidations') + + +def cache_read_collector(sender, func, hit, **kwargs): + if hit: + cacheops_cache_hit.inc() + else: + cacheops_cache_miss.inc() + + +def cache_invalidated_collector(sender, obj_dict, **kwargs): + cacheops_cache_invalidated.inc() + + +cache_read.connect(cache_read_collector) +cache_invalidated.connect(cache_invalidated_collector) diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index f6933bf48..a5545693e 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,11 +1,13 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from taggit.models import Tag, TaggedItem -from utilities.tables import BaseTable, BooleanColumn, ToggleColumn -from .models import ConfigContext, ObjectChange +from utilities.tables import BaseTable, BooleanColumn, ColorColumn, ToggleColumn +from .models import ConfigContext, ObjectChange, Tag, TaggedItem TAG_ACTIONS = """ + + + {% if perms.taggit.change_tag %} {% endif %} @@ -71,10 +73,11 @@ class TagTable(BaseTable): attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) + color = ColorColumn() class Meta(BaseTable.Meta): model = Tag - fields = ('pk', 'name', 'items', 'slug', 'actions') + fields = ('pk', 'name', 'items', 'slug', 'color', 'actions') class TaggedItemTable(BaseTable): diff --git a/netbox/extras/templatetags/__init__.py b/netbox/extras/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py new file mode 100644 index 000000000..ce6cc482a --- /dev/null +++ b/netbox/extras/templatetags/custom_links.py @@ -0,0 +1,77 @@ +from collections import OrderedDict + +from django import template +from django.contrib.contenttypes.models import ContentType +from django.utils.safestring import mark_safe +from jinja2 import Environment + +from extras.models import CustomLink + + +register = template.Library() + +LINK_BUTTON = '{}\n' +GROUP_BUTTON = '
\n' \ + '\n' \ + '
' +GROUP_LINK = '
  • {}
  • \n' + + +@register.simple_tag() +def custom_links(obj): + """ + Render all applicable links for the given object. + """ + content_type = ContentType.objects.get_for_model(obj) + custom_links = CustomLink.objects.filter(content_type=content_type) + if not custom_links: + return '' + + context = { + 'obj': obj, + } + template_code = '' + group_names = OrderedDict() + + for cl in custom_links: + + # Organize custom links by group + if cl.group_name and cl.group_name in group_names: + group_names[cl.group_name].append(cl) + elif cl.group_name: + group_names[cl.group_name] = [cl] + + # Add non-grouped links + else: + text_rendered = Environment().from_string(source=cl.text).render(**context) + if text_rendered: + link_target = ' target="_blank"' if cl.new_window else '' + template_code += LINK_BUTTON.format( + cl.url, link_target, cl.button_class, text_rendered + ) + + # Add grouped links to template + for group, links in group_names.items(): + + links_rendered = [] + + for cl in links: + text_rendered = Environment().from_string(source=cl.text).render(**context) + if text_rendered: + link_target = ' target="_blank"' if cl.new_window else '' + links_rendered.append( + GROUP_LINK.format(cl.url, link_target, cl.text) + ) + + if links_rendered: + template_code += GROUP_BUTTON.format( + links[0].button_class, group, ''.join(links_rendered) + ) + + # Render template + rendered = Environment().from_string(source=template_code).render(**context) + + return mark_safe(rendered) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index cccb00a8a..3fe36c9ef 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,11 +1,10 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse from rest_framework import status -from taggit.models import Tag from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site from extras.constants import GRAPH_TYPE_SITE -from extras.models import ConfigContext, Graph, ExportTemplate +from extras.models import ConfigContext, Graph, ExportTemplate, Tag from tenancy.models import Tenant, TenantGroup from utilities.testing import APITestCase diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index b02e787c1..96f3483bc 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -6,9 +6,10 @@ from django.urls import reverse from rest_framework import status from dcim.models import Site -from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL +from extras.constants import CF_TYPE_TEXT, CF_TYPE_INTEGER, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT, CF_TYPE_URL, CF_TYPE_SELECT from extras.models import CustomField, CustomFieldValue, CustomFieldChoice from utilities.testing import APITestCase +from virtualization.models import VirtualMachine class CustomFieldTest(TestCase): @@ -299,3 +300,33 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields'].get('magic_choice'), data['custom_fields']['magic_choice']) cfv = self.site.custom_field_values.get(field=self.cf_select) self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice']) + + +class CustomFieldChoiceAPITest(APITestCase): + def setUp(self): + super().setUp() + + vm_content_type = ContentType.objects.get_for_model(VirtualMachine) + + self.cf_1 = CustomField.objects.create(name="cf_1", type=CF_TYPE_SELECT) + self.cf_2 = CustomField.objects.create(name="cf_2", type=CF_TYPE_SELECT) + + self.cf_choice_1 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_1", weight=100) + self.cf_choice_2 = CustomFieldChoice.objects.create(field=self.cf_1, value="cf_field_2", weight=50) + self.cf_choice_3 = CustomFieldChoice.objects.create(field=self.cf_2, value="cf_field_3", weight=10) + + def test_list_cfc(self): + url = reverse('extras-api:custom-field-choice-list') + response = self.client.get(url, **self.header) + + self.assertEqual(len(response.data), 2) + self.assertEqual(len(response.data[self.cf_1.name]), 2) + self.assertEqual(len(response.data[self.cf_2.name]), 1) + + self.assertTrue(self.cf_choice_1.value in response.data[self.cf_1.name]) + self.assertTrue(self.cf_choice_2.value in response.data[self.cf_1.name]) + self.assertTrue(self.cf_choice_3.value in response.data[self.cf_2.name]) + + self.assertEqual(self.cf_choice_1.pk, response.data[self.cf_1.name][self.cf_choice_1.value]) + self.assertEqual(self.cf_choice_2.pk, response.data[self.cf_1.name][self.cf_choice_2.value]) + self.assertEqual(self.cf_choice_3.pk, response.data[self.cf_2.name][self.cf_choice_3.value]) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index d478f069c..9d1584226 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -4,17 +4,18 @@ import uuid from django.contrib.auth.models import User from django.test import Client, TestCase from django.urls import reverse -from taggit.models import Tag from dcim.models import Site -from extras.models import ConfigContext, ObjectChange +from extras.models import ConfigContext, ObjectChange, Tag +from utilities.testing import create_test_user class TagTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['extras.view_tag']) self.client = Client() + self.client.force_login(user) Tag.objects.bulk_create([ Tag(name='Tag 1', slug='tag-1'), @@ -36,8 +37,9 @@ class TagTestCase(TestCase): class ConfigContextTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['extras.view_configcontext']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -71,11 +73,9 @@ class ConfigContextTestCase(TestCase): class ObjectChangeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['extras.view_objectchange']) self.client = Client() - - user = User(username='testuser', email='testuser@example.com') - user.save() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 35a6fb110..ad6eabe1e 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -1,37 +1,40 @@ -from django.conf.urls import url +from django.urls import path from extras import views +from extras.models import Tag + app_name = 'extras' urlpatterns = [ # Tags - url(r'^tags/$', views.TagListView.as_view(), name='tag_list'), - url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - url(r'^tags/(?P[\w-]+)/$', views.TagView.as_view(), name='tag'), - url(r'^tags/(?P[\w-]+)/edit/$', views.TagEditView.as_view(), name='tag_edit'), - url(r'^tags/(?P[\w-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'), + path(r'tags/', views.TagListView.as_view(), name='tag_list'), + path(r'tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), + path(r'tags//', views.TagView.as_view(), name='tag'), + path(r'tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path(r'tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path(r'tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts - url(r'^config-contexts/$', views.ConfigContextListView.as_view(), name='configcontext_list'), - url(r'^config-contexts/add/$', views.ConfigContextCreateView.as_view(), name='configcontext_add'), - url(r'^config-contexts/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), - url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), - url(r'^config-contexts/(?P\d+)/$', views.ConfigContextView.as_view(), name='configcontext'), - url(r'^config-contexts/(?P\d+)/edit/$', views.ConfigContextEditView.as_view(), name='configcontext_edit'), - url(r'^config-contexts/(?P\d+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), + path(r'config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), + path(r'config-contexts/add/', views.ConfigContextCreateView.as_view(), name='configcontext_add'), + path(r'config-contexts/edit/', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'), + path(r'config-contexts/delete/', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'), + path(r'config-contexts//', views.ConfigContextView.as_view(), name='configcontext'), + path(r'config-contexts//edit/', views.ConfigContextEditView.as_view(), name='configcontext_edit'), + path(r'config-contexts//delete/', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'), # Image attachments - url(r'^image-attachments/(?P\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), - url(r'^image-attachments/(?P\d+)/delete/$', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + path(r'image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), + path(r'image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), # Reports - url(r'^reports/$', views.ReportListView.as_view(), name='report_list'), - url(r'^reports/(?P[^/]+\.[^/]+)/$', views.ReportView.as_view(), name='report'), - url(r'^reports/(?P[^/]+\.[^/]+)/run/$', views.ReportRunView.as_view(), name='report_run'), + path(r'reports/', views.ReportListView.as_view(), name='report_list'), + path(r'reports//', views.ReportView.as_view(), name='report'), + path(r'reports//run/', views.ReportRunView.as_view(), name='report_run'), # Change logging - url(r'^changelog/$', views.ObjectChangeListView.as_view(), name='objectchange_list'), - url(r'^changelog/(?P\d+)/$', views.ObjectChangeView.as_view(), name='objectchange'), + path(r'changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), + path(r'changelog//', views.ObjectChangeView.as_view(), name='objectchange'), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 2f088eb77..6f4751619 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -9,7 +9,6 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.safestring import mark_safe from django.views.generic import View from django_tables2 import RequestConfig -from taggit.models import Tag, TaggedItem from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator @@ -19,7 +18,7 @@ from .forms import ( ConfigContextForm, ConfigContextBulkEditForm, ConfigContextFilterForm, ImageAttachmentForm, ObjectChangeFilterForm, TagFilterForm, TagForm, ) -from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult +from .models import ConfigContext, ImageAttachment, ObjectChange, ReportResult, Tag, TaggedItem from .reports import get_report, get_reports from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemTable @@ -28,9 +27,10 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT # Tags # -class TagListView(ObjectListView): +class TagListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_tag' queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items', distinct=True) + items=Count('extras_taggeditem_items', distinct=True) ).order_by( 'name' ) @@ -69,22 +69,23 @@ class TagView(View): class TagEditView(PermissionRequiredMixin, ObjectEditView): - permission_required = 'taggit.change_tag' + permission_required = 'extras.change_tag' model = Tag model_form = TagForm default_return_url = 'extras:tag_list' + template_name = 'extras/tag_edit.html' class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView): - permission_required = 'taggit.delete_tag' + permission_required = 'extras.delete_tag' model = Tag default_return_url = 'extras:tag_list' class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'taggit.delete_tag' + permission_required = 'extras.delete_tag' queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items') + items=Count('extras_taggeditem_items') ).order_by( 'name' ) @@ -96,7 +97,8 @@ class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Config contexts # -class ConfigContextListView(ObjectListView): +class ConfigContextListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_configcontext' queryset = ConfigContext.objects.all() filter = filters.ConfigContextFilter filter_form = ConfigContextFilterForm @@ -104,7 +106,8 @@ class ConfigContextListView(ObjectListView): template_name = 'extras/configcontext_list.html' -class ConfigContextView(View): +class ConfigContextView(PermissionRequiredMixin, View): + permission_required = 'extras.view_configcontext' def get(self, request, pk): @@ -143,7 +146,7 @@ class ConfigContextDeleteView(PermissionRequiredMixin, ObjectDeleteView): class ConfigContextBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): - permission_required = 'extras.delete_cconfigcontext' + permission_required = 'extras.delete_configcontext' queryset = ConfigContext.objects.all() table = ConfigContextTable default_return_url = 'extras:configcontext_list' @@ -173,7 +176,8 @@ class ObjectConfigContextView(View): # Change logging # -class ObjectChangeListView(ObjectListView): +class ObjectChangeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'extras.view_objectchange' queryset = ObjectChange.objects.select_related('user', 'changed_object_type') filter = filters.ObjectChangeFilter filter_form = ObjectChangeFilterForm @@ -181,7 +185,8 @@ class ObjectChangeListView(ObjectListView): template_name = 'extras/objectchange_list.html' -class ObjectChangeView(View): +class ObjectChangeView(PermissionRequiredMixin, View): + permission_required = 'extras.view_objectchange' def get(self, request, pk): @@ -223,6 +228,13 @@ class ObjectChangeLogView(View): orderable=False ) + # Apply the request context + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(objectchanges_table) + # Check whether a header template exists for this model base_template = '{}/{}.html'.format(model._meta.app_label, model._meta.model_name) try: @@ -234,7 +246,7 @@ class ObjectChangeLogView(View): return render(request, 'extras/object_changelog.html', { object_var: obj, - 'objectchanges_table': objectchanges_table, + 'table': objectchanges_table, 'base_template': base_template, 'active_tab': 'changelog', }) @@ -272,10 +284,11 @@ class ImageAttachmentDeleteView(PermissionRequiredMixin, ObjectDeleteView): # Reports # -class ReportListView(View): +class ReportListView(PermissionRequiredMixin, View): """ Retrieve all of the available reports from disk and the recorded ReportResult (if any) for each. """ + permission_required = 'extras.view_reportresult' def get(self, request): @@ -295,10 +308,11 @@ class ReportListView(View): }) -class ReportView(View): +class ReportView(PermissionRequiredMixin, View): """ Display a single Report and its associated ReportResult (if any). """ + permission_required = 'extras.view_reportresult' def get(self, request, name): diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 1ad050866..46090fb2e 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -14,7 +14,7 @@ def enqueue_webhooks(instance, user, request_id, action): Find Webhook(s) assigned to this instance + action and enqueue them to be processed """ - if not settings.WEBHOOKS_ENABLED or instance._meta.model_name not in WEBHOOK_MODELS: + if not settings.WEBHOOKS_ENABLED or instance._meta.label.lower() not in WEBHOOK_MODELS: return # Retrieve any applicable Webhooks diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 2ffaa0ae2..aa7c95f1c 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -21,10 +21,11 @@ __all__ = [ class NestedVRFSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF - fields = ['id', 'url', 'name', 'rd'] + fields = ['id', 'url', 'name', 'rd', 'prefix_count'] # @@ -33,10 +34,11 @@ class NestedVRFSerializer(WritableNestedSerializer): class NestedRIRSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') + aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'aggregate_count'] class NestedAggregateSerializer(WritableNestedSerializer): @@ -53,18 +55,21 @@ class NestedAggregateSerializer(WritableNestedSerializer): class NestedRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') + prefix_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Role - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count'] class NestedVLANGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'vlan_count'] class NestedVLANSerializer(WritableNestedSerializer): diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 9b2c45371..fc0c390cf 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -25,12 +25,14 @@ from .nested_serializers import * class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + ipaddress_count = serializers.IntegerField(read_only=True) + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF fields = [ 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'tags', 'display_name', 'custom_fields', - 'created', 'last_updated', + 'created', 'last_updated', 'ipaddress_count', 'prefix_count', ] @@ -39,10 +41,11 @@ class VRFSerializer(TaggitSerializer, CustomFieldModelSerializer): # class RIRSerializer(ValidatedModelSerializer): + aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'name', 'slug', 'is_private'] + fields = ['id', 'name', 'slug', 'is_private', 'aggregate_count'] class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -63,18 +66,21 @@ class AggregateSerializer(TaggitSerializer, CustomFieldModelSerializer): # class RoleSerializer(ValidatedModelSerializer): + prefix_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Role - fields = ['id', 'name', 'slug', 'weight'] + fields = ['id', 'name', 'slug', 'weight', 'prefix_count', 'vlan_count'] class VLANGroupSerializer(ValidatedModelSerializer): site = NestedSiteSerializer(required=False, allow_null=True) + vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'site'] + fields = ['id', 'name', 'slug', 'site', 'vlan_count'] validators = [] def validate(self, data): @@ -99,12 +105,13 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): status = ChoiceField(choices=VLAN_STATUS_CHOICES, required=False) role = NestedRoleSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ 'id', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name', - 'custom_fields', 'created', 'last_updated', + 'custom_fields', 'created', 'last_updated', 'prefix_count', ] validators = [] @@ -203,8 +210,8 @@ class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): class Meta: model = IPAddress fields = [ - 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'description', 'nat_inside', - 'nat_outside', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'interface', 'nat_inside', + 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index e846f0489..95c418a10 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import Count from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.decorators import action @@ -9,6 +10,7 @@ from extras.api.views import CustomFieldModelViewSet from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery from . import serializers @@ -31,7 +33,10 @@ class IPAMFieldChoicesViewSet(FieldChoicesViewSet): # class VRFViewSet(CustomFieldModelViewSet): - queryset = VRF.objects.select_related('tenant').prefetch_related('tags') + queryset = VRF.objects.select_related('tenant').prefetch_related('tags').annotate( + ipaddress_count=get_subquery(IPAddress, 'vrf'), + prefix_count=get_subquery(Prefix, 'vrf') + ) serializer_class = serializers.VRFSerializer filterset_class = filters.VRFFilter @@ -41,7 +46,9 @@ class VRFViewSet(CustomFieldModelViewSet): # class RIRViewSet(ModelViewSet): - queryset = RIR.objects.all() + queryset = RIR.objects.annotate( + aggregate_count=Count('aggregates') + ) serializer_class = serializers.RIRSerializer filterset_class = filters.RIRFilter @@ -61,7 +68,10 @@ class AggregateViewSet(CustomFieldModelViewSet): # class RoleViewSet(ModelViewSet): - queryset = Role.objects.all() + queryset = Role.objects.annotate( + prefix_count=get_subquery(Prefix, 'role'), + vlan_count=get_subquery(VLAN, 'role') + ) serializer_class = serializers.RoleSerializer filterset_class = filters.RoleFilter @@ -71,7 +81,11 @@ class RoleViewSet(ModelViewSet): # class PrefixViewSet(CustomFieldModelViewSet): - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role').prefetch_related('tags') + queryset = Prefix.objects.select_related( + 'site', 'vrf__tenant', 'tenant', 'vlan', 'role' + ).prefetch_related( + 'tags' + ) serializer_class = serializers.PrefixSerializer filterset_class = filters.PrefixFilter @@ -263,7 +277,9 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class VLANGroupViewSet(ModelViewSet): - queryset = VLANGroup.objects.select_related('site') + queryset = VLANGroup.objects.select_related('site').annotate( + vlan_count=Count('vlans') + ) serializer_class = serializers.VLANGroupSerializer filterset_class = filters.VLANGroupFilter @@ -273,7 +289,13 @@ class VLANGroupViewSet(ModelViewSet): # class VLANViewSet(CustomFieldModelViewSet): - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('tags') + queryset = VLAN.objects.select_related( + 'site', 'group', 'tenant', 'role' + ).prefetch_related( + 'tags' + ).annotate( + prefix_count=get_subquery(Prefix, 'role') + ) serializer_class = serializers.VLANSerializer filterset_class = filters.VLANFilter diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index f7125ceb0..49c547d2d 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -6,14 +6,14 @@ from netaddr.core import AddrFormatError from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from virtualization.models import VirtualMachine from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VRFFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -22,16 +22,6 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) tag = TagFilter() def search(self, queryset, name, value): @@ -59,7 +49,7 @@ class RIRFilter(NameSlugSearchFilterSet): fields = ['name', 'slug', 'is_private'] -class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): +class AggregateFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -68,6 +58,10 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) + prefix = django_filters.CharFilter( + method='filter_prefix', + label='Prefix', + ) rir_id = django_filters.ModelMultipleChoiceFilter( queryset=RIR.objects.all(), label='RIR (ID)', @@ -95,6 +89,15 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) + def filter_prefix(self, queryset, name, value): + if not value.strip(): + return queryset + try: + query = str(netaddr.IPNetwork(value).cidr) + return queryset.filter(prefix=query) + except ValidationError: + return queryset.none() + class RoleFilter(NameSlugSearchFilterSet): q = django_filters.CharFilter( @@ -104,10 +107,10 @@ class RoleFilter(NameSlugSearchFilterSet): class Meta: model = Role - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): +class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -146,16 +149,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='rd', label='VRF (RD)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -254,7 +247,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): +class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -285,16 +278,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='rd', label='VRF (RD)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) device = django_filters.CharFilter( method='filter_device', field_name='name', @@ -316,6 +299,12 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='name', label='Virtual machine (name)', ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (ID)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all(), label='Interface (ID)', @@ -331,12 +320,13 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['family'] + fields = ['family', 'dns_name'] def search(self, queryset, name, value): if not value.strip(): return queryset qs_filter = ( + Q(dns_name__icontains=value) | Q(description__icontains=value) | Q(address__istartswith=value) ) @@ -391,10 +381,10 @@ class VLANGroupFilter(NameSlugSearchFilterSet): class Meta: model = VLANGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VLANFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -423,16 +413,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), label='Role (ID)', @@ -493,7 +473,7 @@ class ServiceFilter(django_filters.FilterSet): class Meta: model = Service - fields = ['name', 'protocol', 'port'] + fields = ['id', 'name', 'protocol', 'port'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f96f1a6a2..7a79caaa0 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -6,6 +6,7 @@ from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, @@ -97,22 +98,13 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm ] -class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF + field_order = ['q', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) # @@ -497,8 +489,12 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ] -class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): +class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Prefix + field_order = [ + 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant', + 'is_pool', 'expand', + ] q = forms.CharField( required=False, label='Search' @@ -533,16 +529,6 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=PREFIX_STATUS_CHOICES, required=False, @@ -659,8 +645,8 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldForm) class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'description', 'interface', 'primary_for_parent', 'nat_site', - 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'interface', 'primary_for_parent', + 'nat_site', 'nat_rack', 'nat_inside', 'tenant_group', 'tenant', 'tags', ] widgets = { 'status': StaticSelect2(), @@ -746,7 +732,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'description', 'tenant_group', 'tenant', + 'address', 'vrf', 'status', 'role', 'dns_name', 'description', 'tenant_group', 'tenant', ] widgets = { 'status': StaticSelect2(), @@ -919,13 +905,18 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd required=False, widget=StaticSelect2() ) + dns_name = forms.CharField( + max_length=255, + required=False + ) description = forms.CharField( - max_length=100, required=False + max_length=100, + required=False ) class Meta: nullable_fields = [ - 'vrf', 'role', 'tenant', 'description', + 'vrf', 'role', 'tenant', 'dns_name', 'description', ] @@ -944,8 +935,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) -class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): +class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = IPAddress + field_order = [ + 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant', + ] q = forms.CharField( required=False, label='Search' @@ -980,16 +974,6 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=IPADDRESS_STATUS_CHOICES, required=False, @@ -1221,8 +1205,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -1246,16 +1231,6 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=VLAN_STATUS_CHOICES, required=False, diff --git a/netbox/ipam/migrations/0025_custom_tag_models.py b/netbox/ipam/migrations/0025_custom_tag_models.py new file mode 100644 index 000000000..002aefcb2 --- /dev/null +++ b/netbox/ipam/migrations/0025_custom_tag_models.py @@ -0,0 +1,45 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0024_vrf_allow_null_rd'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='aggregate', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='ipaddress', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='prefix', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='service', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='vlan', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='vrf', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py b/netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py new file mode 100644 index 000000000..17b169b24 --- /dev/null +++ b/netbox/ipam/migrations/0026_prefix_ordering_vrf_nulls_first.py @@ -0,0 +1,18 @@ +# Generated by Django 2.2 on 2019-04-20 00:57 + +from django.db import migrations +import django.db.models.expressions + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0025_custom_tag_models'), + ] + + operations = [ + migrations.AlterModelOptions( + name='prefix', + options={'ordering': [django.db.models.expressions.OrderBy(django.db.models.expressions.F('vrf'), nulls_first=True), 'family', 'prefix'], 'verbose_name_plural': 'prefixes'}, + ), + ] diff --git a/netbox/ipam/migrations/0027_ipaddress_add_dns_name.py b/netbox/ipam/migrations/0027_ipaddress_add_dns_name.py new file mode 100644 index 000000000..534957ce1 --- /dev/null +++ b/netbox/ipam/migrations/0027_ipaddress_add_dns_name.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2 on 2019-04-22 21:43 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0026_prefix_ordering_vrf_nulls_first'), + ] + + operations = [ + migrations.AddField( + model_name='ipaddress', + name='dns_name', + field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index a2f7bbe07..373128a8f 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -4,18 +4,19 @@ from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Q +from django.db.models import F, Q from django.db.models.expressions import RawSQL from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import Interface -from extras.models import CustomFieldModel, ObjectChange +from extras.models import CustomFieldModel, ObjectChange, TaggedItem from utilities.models import ChangeLoggedModel from utilities.utils import serialize_object from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet +from .validators import DNSValidator class VRF(ChangeLoggedModel, CustomFieldModel): @@ -56,7 +57,7 @@ class VRF(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] @@ -155,7 +156,7 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['prefix', 'rir', 'date_added', 'description'] @@ -325,14 +326,14 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): ) objects = PrefixQuerySet.as_manager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan_vid', 'status', 'role', 'is_pool', 'description', ] class Meta: - ordering = ['vrf', 'family', 'prefix'] + ordering = [F('vrf').asc(nulls_first=True), 'family', 'prefix'] verbose_name_plural = 'prefixes' def __str__(self): @@ -367,11 +368,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): }) def save(self, *args, **kwargs): - if self.prefix: + + if isinstance(self.prefix, netaddr.IPNetwork): + # Clear host bits from prefix self.prefix = self.prefix.cidr - # Infer address family from IPNetwork object + + # Record address family self.family = self.prefix.version + super().save(*args, **kwargs) def to_csv(self): @@ -573,6 +578,13 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): verbose_name='NAT (Inside)', help_text='The IP for which this address is the "outside" IP' ) + dns_name = models.CharField( + max_length=255, + blank=True, + validators=[DNSValidator], + verbose_name='DNS Name', + help_text='Hostname or FQDN (not case-sensitive)' + ) description = models.CharField( max_length=100, blank=True @@ -584,11 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): ) objects = IPAddressManager() - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface_name', 'is_primary', - 'description', + 'dns_name', 'description', ] class Meta: @@ -625,9 +637,14 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): }) def save(self, *args, **kwargs): - if self.address: - # Infer address family from IPAddress object + + # Record address family + if isinstance(self.address, netaddr.IPNetwork): self.family = self.address.version + + # Force dns_name to lowercase + self.dns_name = self.dns_name.lower() + super().save(*args, **kwargs) def log_change(self, user, request_id, action): @@ -671,6 +688,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.virtual_machine.name if self.virtual_machine else None, self.interface.name if self.interface else None, is_primary, + self.dns_name, self.description, ) @@ -812,7 +830,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['site', 'group_name', 'vid', 'name', 'tenant', 'status', 'role', 'description'] @@ -914,7 +932,7 @@ class Service(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'description'] diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index bfb2525f2..9fd9bb6c1 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -1,7 +1,7 @@ -from utilities.sql import NullsFirstQuerySet +from django.db.models import QuerySet -class PrefixQuerySet(NullsFirstQuerySet): +class PrefixQuerySet(QuerySet): def annotate_depth(self, limit=None): """ diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index fb48dda24..3906f080f 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -319,6 +319,7 @@ class PrefixTable(BaseTable): class PrefixDetailTable(PrefixTable): utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(PrefixTable.Meta): fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description') @@ -339,7 +340,9 @@ class IPAddressTable(BaseTable): class Meta(BaseTable.Meta): model = IPAddress - fields = ('pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'description') + fields = ( + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface', 'dns_name', 'description', + ) row_attrs = { 'class': lambda record: 'success' if not isinstance(record, IPAddress) else '', } @@ -349,10 +352,12 @@ class IPAddressDetailTable(IPAddressTable): nat_inside = tables.LinkColumn( 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' ) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(IPAddressTable.Meta): fields = ( - 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'description', + 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'parent', 'interface', 'dns_name', + 'description', ) @@ -423,6 +428,7 @@ class VLANTable(BaseTable): class VLANDetailTable(VLANTable): prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 3f4555b55..29368090e 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1,3 +1,5 @@ +import json + from django.urls import reverse from netaddr import IPNetwork from rest_framework import status @@ -39,7 +41,7 @@ class VRFTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'rd', 'url'] + ['id', 'name', 'prefix_count', 'rd', 'url'] ) def test_create_vrf(self): @@ -147,7 +149,7 @@ class RIRTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['aggregate_count', 'id', 'name', 'slug', 'url'] ) def test_create_rir(self): @@ -351,7 +353,7 @@ class RoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] ) def test_create_role(self): @@ -790,7 +792,7 @@ class VLANGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'slug', 'url', 'vlan_count'] ) def test_create_vlangroup(self): @@ -870,6 +872,8 @@ class VLANTest(APITestCase): self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3') + self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24')) + def test_get_vlan(self): url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) @@ -960,6 +964,20 @@ class VLANTest(APITestCase): self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertEqual(VLAN.objects.count(), 2) + def test_delete_vlan_with_prefix(self): + self.prefix1.vlan = self.vlan1 + self.prefix1.save() + + url = reverse('ipam-api:vlan-detail', kwargs={'pk': self.vlan1.pk}) + response = self.client.delete(url, **self.header) + + # can't use assertHttpStatus here because we don't have response.data + self.assertEqual(response.status_code, 409) + + content = json.loads(response.content.decode('utf-8')) + self.assertIn('detail', content) + self.assertTrue(content['detail'].startswith('Unable to delete object.')) + class ServiceTest(APITestCase): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 20c16df9b..e14a257d6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -7,13 +7,15 @@ from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.constants import IP_PROTOCOL_TCP from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF +from utilities.testing import create_test_user class VRFTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vrf']) self.client = Client() + self.client.force_login(user) VRF.objects.bulk_create([ VRF(name='VRF 1', rd='65000:1'), @@ -41,8 +43,9 @@ class VRFTestCase(TestCase): class RIRTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_rir']) self.client = Client() + self.client.force_login(user) RIR.objects.bulk_create([ RIR(name='RIR 1', slug='rir-1'), @@ -57,18 +60,13 @@ class RIRTestCase(TestCase): response = self.client.get(url) self.assertEqual(response.status_code, 200) - def test_rir(self): - - rir = RIR.objects.first() - response = self.client.get(rir.get_absolute_url()) - self.assertEqual(response.status_code, 200) - class AggregateTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_aggregate']) self.client = Client() + self.client.force_login(user) rir = RIR(name='RIR 1', slug='rir-1') rir.save() @@ -99,8 +97,9 @@ class AggregateTestCase(TestCase): class RoleTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_role']) self.client = Client() + self.client.force_login(user) Role.objects.bulk_create([ Role(name='Role 1', slug='role-1'), @@ -119,8 +118,9 @@ class RoleTestCase(TestCase): class PrefixTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_prefix']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -151,8 +151,9 @@ class PrefixTestCase(TestCase): class IPAddressTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_ipaddress']) self.client = Client() + self.client.force_login(user) vrf = VRF(name='VRF 1', rd='65000:1') vrf.save() @@ -183,8 +184,9 @@ class IPAddressTestCase(TestCase): class VLANGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vlangroup']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -209,8 +211,9 @@ class VLANGroupTestCase(TestCase): class VLANTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_vlan']) self.client = Client() + self.client.force_login(user) vlangroup = VLANGroup(name='VLAN Group 1', slug='vlan-group-1') vlangroup.save() @@ -241,8 +244,9 @@ class VLANTestCase(TestCase): class ServiceTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['ipam.view_service']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index c2f7badd3..2a1dcdf05 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from . import views @@ -8,97 +8,97 @@ app_name = 'ipam' urlpatterns = [ # VRFs - url(r'^vrfs/$', views.VRFListView.as_view(), name='vrf_list'), - url(r'^vrfs/add/$', views.VRFCreateView.as_view(), name='vrf_add'), - url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'), - url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), - url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), - url(r'^vrfs/(?P\d+)/$', views.VRFView.as_view(), name='vrf'), - url(r'^vrfs/(?P\d+)/edit/$', views.VRFEditView.as_view(), name='vrf_edit'), - url(r'^vrfs/(?P\d+)/delete/$', views.VRFDeleteView.as_view(), name='vrf_delete'), - url(r'^vrfs/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path(r'vrfs/', views.VRFListView.as_view(), name='vrf_list'), + path(r'vrfs/add/', views.VRFCreateView.as_view(), name='vrf_add'), + path(r'vrfs/import/', views.VRFBulkImportView.as_view(), name='vrf_import'), + path(r'vrfs/edit/', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), + path(r'vrfs/delete/', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), + path(r'vrfs//', views.VRFView.as_view(), name='vrf'), + path(r'vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), + path(r'vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), + path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), # RIRs - url(r'^rirs/$', views.RIRListView.as_view(), name='rir_list'), - url(r'^rirs/add/$', views.RIRCreateView.as_view(), name='rir_add'), - url(r'^rirs/import/$', views.RIRBulkImportView.as_view(), name='rir_import'), - url(r'^rirs/delete/$', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - url(r'^rirs/(?P[\w-]+)/edit/$', views.RIREditView.as_view(), name='rir_edit'), - url(r'^vrfs/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path(r'rirs/', views.RIRListView.as_view(), name='rir_list'), + path(r'rirs/add/', views.RIRCreateView.as_view(), name='rir_add'), + path(r'rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path(r'rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), + path(r'rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path(r'vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates - url(r'^aggregates/$', views.AggregateListView.as_view(), name='aggregate_list'), - url(r'^aggregates/add/$', views.AggregateCreateView.as_view(), name='aggregate_add'), - url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'), - url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), - url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), - url(r'^aggregates/(?P\d+)/$', views.AggregateView.as_view(), name='aggregate'), - url(r'^aggregates/(?P\d+)/edit/$', views.AggregateEditView.as_view(), name='aggregate_edit'), - url(r'^aggregates/(?P\d+)/delete/$', views.AggregateDeleteView.as_view(), name='aggregate_delete'), - url(r'^aggregates/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path(r'aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), + path(r'aggregates/add/', views.AggregateCreateView.as_view(), name='aggregate_add'), + path(r'aggregates/import/', views.AggregateBulkImportView.as_view(), name='aggregate_import'), + path(r'aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), + path(r'aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), + path(r'aggregates//', views.AggregateView.as_view(), name='aggregate'), + path(r'aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), + path(r'aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), + path(r'aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), # Roles - url(r'^roles/$', views.RoleListView.as_view(), name='role_list'), - url(r'^roles/add/$', views.RoleCreateView.as_view(), name='role_add'), - url(r'^roles/import/$', views.RoleBulkImportView.as_view(), name='role_import'), - url(r'^roles/delete/$', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - url(r'^roles/(?P[\w-]+)/edit/$', views.RoleEditView.as_view(), name='role_edit'), - url(r'^roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path(r'roles/', views.RoleListView.as_view(), name='role_list'), + path(r'roles/add/', views.RoleCreateView.as_view(), name='role_add'), + path(r'roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path(r'roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), + path(r'roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path(r'roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes - url(r'^prefixes/$', views.PrefixListView.as_view(), name='prefix_list'), - url(r'^prefixes/add/$', views.PrefixCreateView.as_view(), name='prefix_add'), - url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'), - url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), - url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), - url(r'^prefixes/(?P\d+)/$', views.PrefixView.as_view(), name='prefix'), - url(r'^prefixes/(?P\d+)/edit/$', views.PrefixEditView.as_view(), name='prefix_edit'), - url(r'^prefixes/(?P\d+)/delete/$', views.PrefixDeleteView.as_view(), name='prefix_delete'), - url(r'^prefixes/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), - url(r'^prefixes/(?P\d+)/prefixes/$', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), - url(r'^prefixes/(?P\d+)/ip-addresses/$', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), + path(r'prefixes/', views.PrefixListView.as_view(), name='prefix_list'), + path(r'prefixes/add/', views.PrefixCreateView.as_view(), name='prefix_add'), + path(r'prefixes/import/', views.PrefixBulkImportView.as_view(), name='prefix_import'), + path(r'prefixes/edit/', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), + path(r'prefixes/delete/', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), + path(r'prefixes//', views.PrefixView.as_view(), name='prefix'), + path(r'prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), + path(r'prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), + path(r'prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path(r'prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), + path(r'prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), # IP addresses - url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'), - url(r'^ip-addresses/add/$', views.IPAddressCreateView.as_view(), name='ipaddress_add'), - url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), - url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), - url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), - url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), - url(r'^ip-addresses/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), - url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), - url(r'^ip-addresses/(?P\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), - url(r'^ip-addresses/(?P\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), - url(r'^ip-addresses/(?P\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), + path(r'ip-addresses/', views.IPAddressListView.as_view(), name='ipaddress_list'), + path(r'ip-addresses/add/', views.IPAddressCreateView.as_view(), name='ipaddress_add'), + path(r'ip-addresses/bulk-add/', views.IPAddressBulkCreateView.as_view(), name='ipaddress_bulk_add'), + path(r'ip-addresses/import/', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), + path(r'ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), + path(r'ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + path(r'ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path(r'ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), + path(r'ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), + path(r'ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), + path(r'ip-addresses//delete/', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), # VLAN groups - url(r'^vlan-groups/$', views.VLANGroupListView.as_view(), name='vlangroup_list'), - url(r'^vlan-groups/add/$', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), - url(r'^vlan-groups/import/$', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), - url(r'^vlan-groups/delete/$', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), - url(r'^vlan-groups/(?P\d+)/edit/$', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), - url(r'^vlan-groups/(?P\d+)/vlans/$', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), - url(r'^vlan-groups/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), + path(r'vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), + path(r'vlan-groups/add/', views.VLANGroupCreateView.as_view(), name='vlangroup_add'), + path(r'vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path(r'vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path(r'vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), + path(r'vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), + path(r'vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs - url(r'^vlans/$', views.VLANListView.as_view(), name='vlan_list'), - url(r'^vlans/add/$', views.VLANCreateView.as_view(), name='vlan_add'), - url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'), - url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), - url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), - url(r'^vlans/(?P\d+)/$', views.VLANView.as_view(), name='vlan'), - url(r'^vlans/(?P\d+)/members/$', views.VLANMembersView.as_view(), name='vlan_members'), - url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), - url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), - url(r'^vlans/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path(r'vlans/', views.VLANListView.as_view(), name='vlan_list'), + path(r'vlans/add/', views.VLANCreateView.as_view(), name='vlan_add'), + path(r'vlans/import/', views.VLANBulkImportView.as_view(), name='vlan_import'), + path(r'vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), + path(r'vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), + path(r'vlans//', views.VLANView.as_view(), name='vlan'), + path(r'vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path(r'vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), + path(r'vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), + path(r'vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services - url(r'^services/$', views.ServiceListView.as_view(), name='service_list'), - url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), - url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), - url(r'^services/(?P\d+)/$', views.ServiceView.as_view(), name='service'), - url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), - url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), - url(r'^services/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path(r'services/', views.ServiceListView.as_view(), name='service_list'), + path(r'services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + path(r'services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + path(r'services//', views.ServiceView.as_view(), name='service'), + path(r'services//edit/', views.ServiceEditView.as_view(), name='service_edit'), + path(r'services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), + path(r'services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), ] diff --git a/netbox/ipam/validators.py b/netbox/ipam/validators.py new file mode 100644 index 000000000..6669b7ec5 --- /dev/null +++ b/netbox/ipam/validators.py @@ -0,0 +1,8 @@ +from django.core.validators import RegexValidator + + +DNSValidator = RegexValidator( + regex='^[0-9A-Za-z.-]+$', + message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', + code='invalid' +) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 2f76089a2..d80646bb0 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -113,7 +113,8 @@ def add_available_vlans(vlan_group, vlans): # VRFs # -class VRFListView(ObjectListView): +class VRFListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_vrf' queryset = VRF.objects.select_related('tenant') filter = filters.VRFFilter filter_form = forms.VRFFilterForm @@ -121,7 +122,8 @@ class VRFListView(ObjectListView): template_name = 'ipam/vrf_list.html' -class VRFView(View): +class VRFView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vrf' def get(self, request, pk): @@ -180,7 +182,8 @@ class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # RIRs # -class RIRListView(ObjectListView): +class RIRListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_rir' queryset = RIR.objects.annotate(aggregate_count=Count('aggregates')) filter = filters.RIRFilter filter_form = forms.RIRFilterForm @@ -286,7 +289,8 @@ class RIRBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Aggregates # -class AggregateListView(ObjectListView): +class AggregateListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_aggregate' queryset = Aggregate.objects.select_related('rir').extra(select={ 'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', }) @@ -312,7 +316,8 @@ class AggregateListView(ObjectListView): } -class AggregateView(View): +class AggregateView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_aggregate' def get(self, request, pk): @@ -398,7 +403,8 @@ class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefix/VLAN roles # -class RoleListView(ObjectListView): +class RoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_role' queryset = Role.objects.all() table = tables.RoleTable template_name = 'ipam/role_list.html' @@ -433,7 +439,8 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Prefixes # -class PrefixListView(ObjectListView): +class PrefixListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_prefix' queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm @@ -446,7 +453,8 @@ class PrefixListView(ObjectListView): return self.queryset.annotate_depth(limit=limit) -class PrefixView(View): +class PrefixView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_prefix' def get(self, request, pk): @@ -489,7 +497,8 @@ class PrefixView(View): }) -class PrefixPrefixesView(View): +class PrefixPrefixesView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_prefix' def get(self, request, pk): @@ -531,7 +540,8 @@ class PrefixPrefixesView(View): }) -class PrefixIPAddressesView(View): +class PrefixIPAddressesView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_prefix' def get(self, request, pk): @@ -617,7 +627,8 @@ class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # IP addresses # -class IPAddressListView(ObjectListView): +class IPAddressListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_ipaddress' queryset = IPAddress.objects.select_related( 'vrf__tenant', 'tenant', 'nat_inside' ).prefetch_related( @@ -629,7 +640,8 @@ class IPAddressListView(ObjectListView): template_name = 'ipam/ipaddress_list.html' -class IPAddressView(View): +class IPAddressView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_ipaddress' def get(self, request, pk): @@ -788,7 +800,8 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # VLAN groups # -class VLANGroupListView(ObjectListView): +class VLANGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_vlangroup' queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter filter_form = forms.VLANGroupFilterForm @@ -822,7 +835,9 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_return_url = 'ipam:vlangroup_list' -class VLANGroupVLANsView(View): +class VLANGroupVLANsView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vlangroup' + def get(self, request, pk): vlan_group = get_object_or_404(VLANGroup.objects.all(), pk=pk) @@ -861,7 +876,8 @@ class VLANGroupVLANsView(View): # VLANs # -class VLANListView(ObjectListView): +class VLANListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_vlan' queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filter = filters.VLANFilter filter_form = forms.VLANFilterForm @@ -869,7 +885,8 @@ class VLANListView(ObjectListView): template_name = 'ipam/vlan_list.html' -class VLANView(View): +class VLANView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vlan' def get(self, request, pk): @@ -886,7 +903,8 @@ class VLANView(View): }) -class VLANMembersView(View): +class VLANMembersView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_vlan' def get(self, request, pk): @@ -954,7 +972,8 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # -class ServiceListView(ObjectListView): +class ServiceListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipam.view_service' queryset = Service.objects.select_related('device', 'virtual_machine') filter = filters.ServiceFilter filter_form = forms.ServiceFilterForm @@ -962,7 +981,8 @@ class ServiceListView(ObjectListView): template_name = 'ipam/service_list.html' -class ServiceView(View): +class ServiceView(PermissionRequiredMixin, View): + permission_required = 'ipam.view_service' def get(self, request, pk): diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 61796aabd..27a033094 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -2,8 +2,6 @@ from django.conf import settings from django.contrib.admin import AdminSite from django.contrib.auth.admin import GroupAdmin, UserAdmin from django.contrib.auth.models import Group, User -from taggit.admin import TagAdmin -from taggit.models import Tag class NetBoxAdminSite(AdminSite): @@ -20,7 +18,6 @@ admin_site = NetBoxAdminSite(name='admin') # Register external models admin_site.register(Group, GroupAdmin) admin_site.register(User, UserAdmin) -admin_site.register(Tag, TagAdmin) # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK) if settings.WEBHOOKS_ENABLED: diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index d8592f341..d20ac6563 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.db.models import QuerySet from rest_framework import authentication, exceptions from rest_framework.pagination import LimitOffsetPagination from rest_framework.permissions import DjangoModelPermissions, SAFE_METHODS @@ -55,16 +56,31 @@ class TokenPermissions(DjangoModelPermissions): Custom permissions handler which extends the built-in DjangoModelPermissions to validate a Token's write ability for unsafe requests (POST/PUT/PATCH/DELETE). """ + # Override the stock perm_map to enforce view permissions + perms_map = { + 'GET': ['%(app_label)s.view_%(model_name)s'], + 'OPTIONS': [], + 'HEAD': ['%(app_label)s.view_%(model_name)s'], + 'POST': ['%(app_label)s.add_%(model_name)s'], + 'PUT': ['%(app_label)s.change_%(model_name)s'], + 'PATCH': ['%(app_label)s.change_%(model_name)s'], + 'DELETE': ['%(app_label)s.delete_%(model_name)s'], + } + def __init__(self): + # LOGIN_REQUIRED determines whether read-only access is provided to anonymous users. self.authenticated_users_only = settings.LOGIN_REQUIRED + super().__init__() def has_permission(self, request, view): + # If token authentication is in use, verify that the token allows write operations (for unsafe methods). if request.method not in SAFE_METHODS and isinstance(request.auth, Token): if not request.auth.write_enabled: return False + return super().has_permission(request, view) @@ -81,13 +97,8 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): - if hasattr(queryset, 'all'): - # TODO: This breaks filtering by annotated values - # Make a clone of the queryset with any annotations stripped (performance hack) - qs = queryset.all() - qs.query.annotations.clear() - self.count = qs.count() - + if isinstance(queryset, QuerySet): + self.count = queryset.count() else: # We're dealing with an iterable, not a QuerySet self.count = len(queryset) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 145ebf0e6..ebc3d4540 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -25,6 +25,17 @@ DATABASE = { # https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY SECRET_KEY = '' +# Redis database settings. The Redis database is used for caching and background processing such as webhooks +REDIS = { + 'HOST': 'localhost', + 'PORT': 6379, + 'PASSWORD': '', + 'DATABASE': 0, + 'CACHE_DATABASE': 1, + 'DEFAULT_TIMEOUT': 300, + 'SSL': False, +} + ######################### # # @@ -50,6 +61,9 @@ BANNER_LOGIN = '' # BASE_PATH = 'netbox/' BASE_PATH = '' +# Cache timeout in seconds. Set to 0 to dissable caching. Defaults to 900 (15 minutes) +CACHE_TIMEOUT = 900 + # Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90) CHANGELOG_RETENTION = 90 @@ -58,7 +72,7 @@ CHANGELOG_RETENTION = 90 # CORS_ORIGIN_REGEX_WHITELIST. For more information, see https://github.com/ottoyiu/django-cors-headers CORS_ORIGIN_ALLOW_ALL = False CORS_ORIGIN_WHITELIST = [ - # 'hostname.example.com', + # 'https://hostname.example.com', ] CORS_ORIGIN_REGEX_WHITELIST = [ # r'^(https?://)?(\w+\.)?example\.com$', @@ -83,6 +97,14 @@ EMAIL = { # (all prefixes and IP addresses not assigned to a VRF), set ENFORCE_GLOBAL_UNIQUE to True. ENFORCE_GLOBAL_UNIQUE = False +# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and +# by anonymous users. List models in the form `.`. Add '*' to this list to exempt all models. +EXEMPT_VIEW_PERMISSIONS = [ + # 'dcim.site', + # 'dcim.region', + # 'ipam.prefix', +] + # Enable custom logging. Please see the Django documentation for detailed guidance on configuring custom logs: # https://docs.djangoproject.com/en/1.11/topics/logging/ LOGGING = {} @@ -107,6 +129,9 @@ MAX_PAGE_SIZE = 1000 # the default value of this setting is derived from the installed location. # MEDIA_ROOT = '/opt/netbox/netbox/media' +# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics' +METRICS_ENABLED = False + # Credentials that NetBox will uses to authenticate to devices when connecting via NAPALM. NAPALM_USERNAME = '' NAPALM_PASSWORD = '' @@ -125,16 +150,6 @@ PAGINATE_COUNT = 50 # prefer IPv4 instead. PREFER_IPV4 = False -# Redis database settings (optional). A Redis database is required only if the webhooks backend is enabled. -REDIS = { - 'HOST': 'localhost', - 'PORT': 6379, - 'PASSWORD': '', - 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, - 'SSL': False, -} - # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index a2ad1376b..36198a384 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -16,6 +16,7 @@ OBJ_TYPE_CHOICES = ( ('device', 'Devices'), ('virtualchassis', 'Virtual Chassis'), ('cable', 'Cables'), + ('powerfeed', 'Power Feeds'), )), ('IPAM', ( ('vrf', 'VRFs'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index da429823b..090122e37 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -1,19 +1,37 @@ import logging import os +import platform import socket -import sys import warnings from django.contrib.messages import constants as messages from django.core.exceptions import ImproperlyConfigured -# Django 2.1 requires Python 3.5+ -if sys.version_info < (3, 5): + +# +# Environment setup +# + +VERSION = '2.6.3-dev' + +# Hostname +HOSTNAME = platform.node() + +# Set the base directory two levels up +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Django 2.1+ requires Python 3.5+ +if platform.python_version_tuple() < ('3', '5'): raise RuntimeError( - "NetBox requires Python 3.5 or higher (current: Python {})".format(sys.version.split()[0]) + "NetBox requires Python 3.5 or higher (current: Python {})".format(platform.python_version()) ) -# Check for configuration file + +# +# Configuration import +# + +# Import configuration parameters try: from netbox import configuration except ImportError: @@ -21,22 +39,20 @@ except ImportError: "Configuration file is not present. Please define netbox/netbox/configuration.py per the documentation." ) - -VERSION = '2.5.11-dev' - -BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) - -# Import required configuration parameters -ALLOWED_HOSTS = DATABASE = SECRET_KEY = None -for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: - try: - globals()[setting] = getattr(configuration, setting) - except AttributeError: +# Enforce required configuration parameters +for parameter in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY', 'REDIS']: + if not hasattr(configuration, parameter): raise ImproperlyConfigured( - "Mandatory setting {} is missing from configuration.py.".format(setting) + "Required parameter {} is missing from configuration.py.".format(parameter) ) -# Import optional configuration parameters +# Set required parameters +ALLOWED_HOSTS = getattr(configuration, 'ALLOWED_HOSTS') +DATABASE = getattr(configuration, 'DATABASE') +REDIS = getattr(configuration, 'REDIS') +SECRET_KEY = getattr(configuration, 'SECRET_KEY') + +# Set optional parameters ADMINS = getattr(configuration, 'ADMINS', []) BANNER_BOTTOM = getattr(configuration, 'BANNER_BOTTOM', '') BANNER_LOGIN = getattr(configuration, 'BANNER_LOGIN', '') @@ -44,6 +60,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '') BASE_PATH = getattr(configuration, 'BASE_PATH', '') if BASE_PATH: BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only +CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900) CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90) CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False) CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', []) @@ -51,22 +68,23 @@ CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', []) DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y') DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a') DEBUG = getattr(configuration, 'DEBUG', False) -ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) EMAIL = getattr(configuration, 'EMAIL', {}) +ENFORCE_GLOBAL_UNIQUE = getattr(configuration, 'ENFORCE_GLOBAL_UNIQUE', False) +EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') -NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') +METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) +NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30) -NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) +NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '') PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50) PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') -REDIS = getattr(configuration, 'REDIS', {}) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') @@ -75,65 +93,54 @@ TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') WEBHOOKS_ENABLED = getattr(configuration, 'WEBHOOKS_ENABLED', False) -CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS - -# Attempt to import LDAP configuration if it has been defined -LDAP_IGNORE_CERT_ERRORS = False -try: - from netbox.ldap_config import * - LDAP_CONFIGURED = True -except ImportError: - LDAP_CONFIGURED = False - -# LDAP configuration (optional) -if LDAP_CONFIGURED: - try: - import ldap - import django_auth_ldap - # Prepend LDAPBackend to the default ModelBackend - AUTHENTICATION_BACKENDS = [ - 'django_auth_ldap.backend.LDAPBackend', - 'django.contrib.auth.backends.ModelBackend', - ] - # Optionally disable strict certificate checking - if LDAP_IGNORE_CERT_ERRORS: - ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) - # Enable logging for django_auth_ldap - ldap_logger = logging.getLogger('django_auth_ldap') - ldap_logger.addHandler(logging.StreamHandler()) - ldap_logger.setLevel(logging.DEBUG) - except ImportError: - raise ImproperlyConfigured( - "LDAP authentication has been configured, but django-auth-ldap is not installed. You can remove " - "netbox/ldap_config.py to disable LDAP." - ) +# # Database -configuration.DATABASE.update({'ENGINE': 'django.db.backends.postgresql'}) +# + +# Only PostgreSQL is supported +if METRICS_ENABLED: + DATABASE.update({ + 'ENGINE': 'django_prometheus.db.backends.postgresql' + }) +else: + DATABASE.update({ + 'ENGINE': 'django.db.backends.postgresql' + }) + DATABASES = { - 'default': configuration.DATABASE, + 'default': DATABASE, } + +# +# Redis +# + +REDIS_HOST = REDIS.get('HOST', 'localhost') +REDIS_PORT = REDIS.get('PORT', 6379) +REDIS_PASSWORD = REDIS.get('PASSWORD', '') +REDIS_DATABASE = REDIS.get('DATABASE', 0) +REDIS_CACHE_DATABASE = REDIS.get('CACHE_DATABASE', 1) +REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) +REDIS_SSL = REDIS.get('SSL', False) + + +# # Sessions +# + if LOGIN_TIMEOUT is not None: - if type(LOGIN_TIMEOUT) is not int or LOGIN_TIMEOUT < 0: - raise ImproperlyConfigured( - "LOGIN_TIMEOUT must be a positive integer (value: {})".format(LOGIN_TIMEOUT) - ) # Django default is 1209600 seconds (14 days) SESSION_COOKIE_AGE = LOGIN_TIMEOUT if SESSION_FILE_PATH is not None: SESSION_ENGINE = 'django.contrib.sessions.backends.file' -# Redis -REDIS_HOST = REDIS.get('HOST', 'localhost') -REDIS_PORT = REDIS.get('PORT', 6379) -REDIS_PASSWORD = REDIS.get('PASSWORD', '') -REDIS_DATABASE = REDIS.get('DATABASE', 0) -REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) -REDIS_SSL = REDIS.get('SSL', False) +# # Email +# + EMAIL_HOST = EMAIL.get('SERVER') EMAIL_PORT = EMAIL.get('PORT', 25) EMAIL_HOST_USER = EMAIL.get('USERNAME') @@ -142,7 +149,11 @@ EMAIL_TIMEOUT = EMAIL.get('TIMEOUT', 10) SERVER_EMAIL = EMAIL.get('FROM_EMAIL') EMAIL_SUBJECT_PREFIX = '[NetBox] ' -# Installed applications + +# +# Django +# + INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', @@ -151,10 +162,12 @@ INSTALLED_APPS = [ 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.humanize', + 'cacheops', 'corsheaders', 'debug_toolbar', 'django_filters', 'django_tables2', + 'django_prometheus', 'mptt', 'rest_framework', 'taggit', @@ -179,6 +192,7 @@ if WEBHOOKS_ENABLED: # Middleware MIDDLEWARE = ( 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -191,14 +205,16 @@ MIDDLEWARE = ( 'utilities.middleware.LoginRequiredMiddleware', 'utilities.middleware.APIVersionMiddleware', 'extras.middleware.ObjectChangeMiddleware', + 'django_prometheus.middleware.PrometheusAfterMiddleware', ) ROOT_URLCONF = 'netbox.urls' +TEMPLATES_DIR = BASE_DIR + '/templates' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR + '/templates'], + 'DIRS': [TEMPLATES_DIR], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -213,16 +229,21 @@ TEMPLATES = [ }, ] -# WSGI -WSGI_APPLICATION = 'netbox.wsgi.application' -SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -USE_X_FORWARDED_HOST = True +# Authentication +AUTHENTICATION_BACKENDS = [ + 'utilities.auth_backends.ViewExemptModelBackend', +] # Internationalization LANGUAGE_CODE = 'en-us' USE_I18N = True USE_TZ = True +# WSGI +WSGI_APPLICATION = 'netbox.wsgi.application' +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +USE_X_FORWARDED_HOST = True + # Static files (CSS, JavaScript, Images) STATIC_ROOT = BASE_DIR + '/static' STATIC_URL = '/{}static/'.format(BASE_PATH) @@ -244,22 +265,134 @@ MESSAGE_TAGS = { # Authentication URLs LOGIN_URL = '/{}login/'.format(BASE_PATH) -# Secrets -SECRETS_MIN_PUBKEY_SIZE = 2048 +CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -# Pagination -PER_PAGE_DEFAULTS = [ - 25, 50, 100, 250, 500, 1000 -] -if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: - PER_PAGE_DEFAULTS.append(PAGINATE_COUNT) - PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS) +# +# LDAP authentication (optional) +# + +try: + from netbox import ldap_config as LDAP_CONFIG +except ImportError: + LDAP_CONFIG = None + +if LDAP_CONFIG is not None: + + # Check that django_auth_ldap is installed + try: + import ldap + import django_auth_ldap + except ImportError: + raise ImproperlyConfigured( + "LDAP authentication has been configured, but django-auth-ldap is not installed. Remove " + "netbox/ldap_config.py to disable LDAP." + ) + + # Required configuration parameters + try: + AUTH_LDAP_SERVER_URI = getattr(LDAP_CONFIG, 'AUTH_LDAP_SERVER_URI') + except AttributeError: + raise ImproperlyConfigured( + "Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py." + ) + + # Optional configuration parameters + AUTH_LDAP_ALWAYS_UPDATE_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_ALWAYS_UPDATE_USER', True) + AUTH_LDAP_AUTHORIZE_ALL_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_AUTHORIZE_ALL_USERS', False) + AUTH_LDAP_BIND_AS_AUTHENTICATING_USER = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_AS_AUTHENTICATING_USER', False) + AUTH_LDAP_BIND_DN = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_DN', '') + AUTH_LDAP_BIND_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_BIND_PASSWORD', '') + AUTH_LDAP_CACHE_TIMEOUT = getattr(LDAP_CONFIG, 'AUTH_LDAP_CACHE_TIMEOUT', 0) + AUTH_LDAP_CONNECTION_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_CONNECTION_OPTIONS', {}) + AUTH_LDAP_DENY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_DENY_GROUP', None) + AUTH_LDAP_FIND_GROUP_PERMS = getattr(LDAP_CONFIG, 'AUTH_LDAP_FIND_GROUP_PERMS', False) + AUTH_LDAP_GLOBAL_OPTIONS = getattr(LDAP_CONFIG, 'AUTH_LDAP_GLOBAL_OPTIONS', {}) + AUTH_LDAP_GROUP_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_SEARCH', None) + AUTH_LDAP_GROUP_TYPE = getattr(LDAP_CONFIG, 'AUTH_LDAP_GROUP_TYPE', None) + AUTH_LDAP_MIRROR_GROUPS = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS', None) + AUTH_LDAP_MIRROR_GROUPS_EXCEPT = getattr(LDAP_CONFIG, 'AUTH_LDAP_MIRROR_GROUPS_EXCEPT', None) + AUTH_LDAP_PERMIT_EMPTY_PASSWORD = getattr(LDAP_CONFIG, 'AUTH_LDAP_PERMIT_EMPTY_PASSWORD', False) + AUTH_LDAP_REQUIRE_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_REQUIRE_GROUP', None) + AUTH_LDAP_NO_NEW_USERS = getattr(LDAP_CONFIG, 'AUTH_LDAP_NO_NEW_USERS', False) + AUTH_LDAP_START_TLS = getattr(LDAP_CONFIG, 'AUTH_LDAP_START_TLS', False) + AUTH_LDAP_USER_QUERY_FIELD = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_QUERY_FIELD', None) + AUTH_LDAP_USER_ATTRLIST = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTRLIST', None) + AUTH_LDAP_USER_ATTR_MAP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_ATTR_MAP', {}) + AUTH_LDAP_USER_DN_TEMPLATE = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_DN_TEMPLATE', None) + AUTH_LDAP_USER_FLAGS_BY_GROUP = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_FLAGS_BY_GROUP', {}) + AUTH_LDAP_USER_SEARCH = getattr(LDAP_CONFIG, 'AUTH_LDAP_USER_SEARCH', None) + + # Optionally disable strict certificate checking + if getattr(LDAP_CONFIG, 'LDAP_IGNORE_CERT_ERRORS', False): + ldap.set_option(ldap.OPT_X_TLS_REQUIRE_CERT, ldap.OPT_X_TLS_NEVER) + + # Prepend LDAPBackend to the authentication backends list + AUTHENTICATION_BACKENDS.insert(0, 'django_auth_ldap.backend.LDAPBackend') + + # Enable logging for django_auth_ldap + ldap_logger = logging.getLogger('django_auth_ldap') + ldap_logger.addHandler(logging.StreamHandler()) + ldap_logger.setLevel(logging.DEBUG) + + +# +# Caching +# + +if REDIS_SSL: + REDIS_CACHE_CON_STRING = 'rediss://' +else: + REDIS_CACHE_CON_STRING = 'redis://' + +if REDIS_PASSWORD: + REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, REDIS_PASSWORD) + +REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(REDIS_CACHE_CON_STRING, REDIS_HOST, REDIS_PORT, REDIS_CACHE_DATABASE) + +if not CACHE_TIMEOUT: + CACHEOPS_ENABLED = False +else: + CACHEOPS_ENABLED = True + +CACHEOPS_REDIS = REDIS_CACHE_CON_STRING +CACHEOPS_DEFAULTS = { + 'timeout': CACHE_TIMEOUT +} +CACHEOPS = { + 'auth.user': {'ops': 'get', 'timeout': 60 * 15}, + 'auth.*': {'ops': ('fetch', 'get')}, + 'auth.permission': {'ops': 'all'}, + 'dcim.*': {'ops': 'all'}, + 'ipam.*': {'ops': 'all'}, + 'extras.*': {'ops': 'all'}, + 'secrets.*': {'ops': 'all'}, + 'users.*': {'ops': 'all'}, + 'tenancy.*': {'ops': 'all'}, + 'virtualization.*': {'ops': 'all'}, +} +CACHEOPS_DEGRADE_ON_FAILURE = True + + +# +# Django Prometheus +# + +PROMETHEUS_EXPORT_MIGRATIONS = False + + +# # Django filters +# + FILTERS_NULL_CHOICE_LABEL = 'None' FILTERS_NULL_CHOICE_VALUE = 'null' + +# # Django REST framework (API) +# + REST_FRAMEWORK_VERSION = VERSION[0:3] # Use major.minor as API version REST_FRAMEWORK = { 'ALLOWED_VERSIONS': [REST_FRAMEWORK_VERSION], @@ -284,19 +417,11 @@ REST_FRAMEWORK = { 'VIEW_NAME_FUNCTION': 'netbox.api.get_view_name', } -# Django RQ (Webhooks backend) -RQ_QUEUES = { - 'default': { - 'HOST': REDIS_HOST, - 'PORT': REDIS_PORT, - 'DB': REDIS_DATABASE, - 'PASSWORD': REDIS_PASSWORD, - 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, - 'SSL': REDIS_SSL, - } -} -# drf_yasg settings for Swagger +# +# drf_yasg (OpenAPI/Swagger) +# + SWAGGER_SETTINGS = { 'DEFAULT_AUTO_SCHEMA_CLASS': 'utilities.custom_inspectors.NetBoxSwaggerAutoSchema', 'DEFAULT_FIELD_INSPECTORS': [ @@ -310,6 +435,7 @@ SWAGGER_SETTINGS = { 'drf_yasg.inspectors.ChoiceFieldInspector', 'drf_yasg.inspectors.FileFieldInspector', 'drf_yasg.inspectors.DictFieldInspector', + 'drf_yasg.inspectors.SerializerMethodFieldInspector', 'drf_yasg.inspectors.SimpleFieldInspector', 'drf_yasg.inspectors.StringDefaultFieldInspector', ], @@ -334,14 +460,43 @@ SWAGGER_SETTINGS = { } +# +# Django RQ (Webhooks backend) +# + +RQ_QUEUES = { + 'default': { + 'HOST': REDIS_HOST, + 'PORT': REDIS_PORT, + 'DB': REDIS_DATABASE, + 'PASSWORD': REDIS_PASSWORD, + 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, + 'SSL': REDIS_SSL, + } +} + + +# # Django debug toolbar +# + INTERNAL_IPS = ( '127.0.0.1', '::1', ) -try: - HOSTNAME = socket.gethostname() -except Exception: - HOSTNAME = 'localhost' +# +# NetBox internal settings +# + +# Secrets +SECRETS_MIN_PUBKEY_SIZE = 2048 + +# Pagination +PER_PAGE_DEFAULTS = [ + 25, 50, 100, 250, 500, 1000 +] +if PAGINATE_COUNT not in PER_PAGE_DEFAULTS: + PER_PAGE_DEFAULTS.append(PAGINATE_COUNT) + PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index 45c99beb9..f39040baf 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.conf.urls import include, url +from django.conf.urls import include +from django.urls import path, re_path from django.views.static import serve from drf_yasg import openapi from drf_yasg.views import get_schema_view @@ -13,8 +14,7 @@ schema_view = get_schema_view( title="NetBox API", default_version='v2', description="API to access NetBox", - terms_of_service="https://github.com/digitalocean/netbox", - contact=openapi.Contact(email="netbox@digitalocean.com"), + terms_of_service="https://github.com/netbox-community/netbox", license=openapi.License(name="Apache v2 License"), ), validators=['flex', 'ssv'], @@ -24,58 +24,63 @@ schema_view = get_schema_view( _patterns = [ # Base views - url(r'^$', HomeView.as_view(), name='home'), - url(r'^search/$', SearchView.as_view(), name='search'), + path(r'', HomeView.as_view(), name='home'), + path(r'search/', SearchView.as_view(), name='search'), # Login/logout - url(r'^login/$', LoginView.as_view(), name='login'), - url(r'^logout/$', LogoutView.as_view(), name='logout'), + path(r'login/', LoginView.as_view(), name='login'), + path(r'logout/', LogoutView.as_view(), name='logout'), # Apps - url(r'^circuits/', include('circuits.urls')), - url(r'^dcim/', include('dcim.urls')), - url(r'^extras/', include('extras.urls')), - url(r'^ipam/', include('ipam.urls')), - url(r'^secrets/', include('secrets.urls')), - url(r'^tenancy/', include('tenancy.urls')), - url(r'^user/', include('users.urls')), - url(r'^virtualization/', include('virtualization.urls')), + path(r'circuits/', include('circuits.urls')), + path(r'dcim/', include('dcim.urls')), + path(r'extras/', include('extras.urls')), + path(r'ipam/', include('ipam.urls')), + path(r'secrets/', include('secrets.urls')), + path(r'tenancy/', include('tenancy.urls')), + path(r'user/', include('users.urls')), + path(r'virtualization/', include('virtualization.urls')), # API - url(r'^api/$', APIRootView.as_view(), name='api-root'), - url(r'^api/circuits/', include('circuits.api.urls')), - url(r'^api/dcim/', include('dcim.api.urls')), - url(r'^api/extras/', include('extras.api.urls')), - url(r'^api/ipam/', include('ipam.api.urls')), - url(r'^api/secrets/', include('secrets.api.urls')), - url(r'^api/tenancy/', include('tenancy.api.urls')), - url(r'^api/virtualization/', include('virtualization.api.urls')), - url(r'^api/docs/$', schema_view.with_ui('swagger'), name='api_docs'), - url(r'^api/redoc/$', schema_view.with_ui('redoc'), name='api_redocs'), - url(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), + path(r'api/', APIRootView.as_view(), name='api-root'), + path(r'api/circuits/', include('circuits.api.urls')), + path(r'api/dcim/', include('dcim.api.urls')), + path(r'api/extras/', include('extras.api.urls')), + path(r'api/ipam/', include('ipam.api.urls')), + path(r'api/secrets/', include('secrets.api.urls')), + path(r'api/tenancy/', include('tenancy.api.urls')), + path(r'api/virtualization/', include('virtualization.api.urls')), + path(r'api/docs/', schema_view.with_ui('swagger'), name='api_docs'), + path(r'api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'), + re_path(r'^api/swagger(?P.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'), # Serving static media in Django to pipe it through LoginRequiredMiddleware - url(r'^media/(?P.*)$', serve, {'document_root': settings.MEDIA_ROOT}), + path(r'media/', serve, {'document_root': settings.MEDIA_ROOT}), # Admin - url(r'^admin/', admin_site.urls), + path(r'admin/', admin_site.urls), ] if settings.WEBHOOKS_ENABLED: _patterns += [ - url(r'^admin/webhook-backend-status/', include('django_rq.urls')), + path(r'admin/webhook-backend-status/', include('django_rq.urls')), ] if settings.DEBUG: import debug_toolbar _patterns += [ - url(r'^__debug__/', include(debug_toolbar.urls)), + path(r'__debug__/', include(debug_toolbar.urls)), + ] + +if settings.METRICS_ENABLED: + _patterns += [ + path('', include('django_prometheus.urls')), ] # Prepend BASE_PATH urlpatterns = [ - url(r'^{}'.format(settings.BASE_PATH), include(_patterns)) + path(r'{}'.format(settings.BASE_PATH), include(_patterns)) ] handler500 = 'utilities.views.server_error' diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index 837d9473d..146bba6db 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -11,13 +11,15 @@ from circuits.filters import CircuitFilter, ProviderFilter from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, ProviderTable from dcim.filters import ( - CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter + CableFilter, DeviceFilter, DeviceTypeFilter, PowerFeedFilter, RackFilter, RackGroupFilter, SiteFilter, + VirtualChassisFilter, ) from dcim.models import ( - Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis + Cable, ConsolePort, Device, DeviceType, Interface, PowerPanel, PowerFeed, PowerPort, Rack, RackGroup, Site, VirtualChassis ) from dcim.tables import ( - CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable + CableTable, DeviceDetailTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, + VirtualChassisTable, ) from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter @@ -94,6 +96,12 @@ SEARCH_TYPES = OrderedDict(( 'table': CableTable, 'url': 'dcim:cable_list', }), + ('powerfeed', { + 'queryset': PowerFeed.objects.all(), + 'filter': PowerFeedFilter, + 'table': PowerFeedTable, + 'url': 'dcim:powerfeed_list', + }), # IPAM ('vrf', { 'queryset': VRF.objects.select_related('tenant'), @@ -166,7 +174,7 @@ class HomeView(View): connected_endpoint__isnull=False ) connected_powerports = PowerPort.objects.filter( - connected_endpoint__isnull=False + _connected_poweroutlet__isnull=False ) connected_interfaces = Interface.objects.filter( _connected_interface__isnull=False, @@ -182,11 +190,14 @@ class HomeView(View): # DCIM 'rack_count': Rack.objects.count(), + 'devicetype_count': DeviceType.objects.count(), 'device_count': Device.objects.count(), 'interface_connections_count': connected_interfaces.count(), 'cable_count': cables.count(), 'console_connections_count': connected_consoleports.count(), 'power_connections_count': connected_powerports.count(), + 'powerpanel_count': PowerPanel.objects.count(), + 'powerfeed_count': PowerFeed.objects.count(), # IPAM 'vrf_count': VRF.objects.count(), diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 26ca50220..fcee05e12 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -559,6 +559,7 @@ table.report th a { .color-block { display: block; width: 80px; + border: 1px solid grey; } .text-nowrap { white-space: nowrap; @@ -585,6 +586,7 @@ ul.nav-tabs, ul.nav-pills { /* Fix progress bar margin inside table cells */ td .progress { margin-bottom: 0; + min-width: 100px; } textarea { font-family: Consolas, Lucida Console, monospace; diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 96d59ace5..b1a08c7f4 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -183,7 +183,7 @@ $(document).ready(function() { // Additional query params $.each(element.attributes, function(index, attr){ if (attr.name.includes("data-additional-query-param-")){ - var param_name = attr.name.split("data-additional-query-param-")[1] + var param_name = attr.name.split("data-additional-query-param-")[1]; parameters[param_name] = attr.value; } }); @@ -194,6 +194,8 @@ $(document).ready(function() { processResults: function (data) { var element = this.$element[0]; + // Clear any disabled options + $(element).children('option').attr('disabled', false); var results = $.map(data.results, function (obj) { obj.text = obj[element.getAttribute('display-field')] || obj.name; obj.id = obj[element.getAttribute('value-field')] || obj.id; @@ -207,7 +209,7 @@ $(document).ready(function() { // Handle the null option, but only add it once if (element.getAttribute('data-null-option') && data.previous === null) { - var null_option = $(element).children()[0] + var null_option = $(element).children()[0]; results.unshift({ id: null_option.value, text: null_option.text @@ -267,6 +269,10 @@ $(document).ready(function() { processResults: function (data) { var results = $.map(data.results, function (obj) { + // If tag contains space add double quotes + if (/\s/.test(obj.name)) + obj.name = '"' + obj.name + '"' + return { id: obj.name, text: obj.name diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index 819546c63..7aa8087da 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -10,7 +10,8 @@ __all__ = [ class NestedSecretRoleSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') + secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'secret_count'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index 1faf85dcf..7a0447a39 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -14,10 +14,11 @@ from .nested_serializers import * # class SecretRoleSerializer(ValidatedModelSerializer): + secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'secret_count'] class SecretSerializer(TaggitSerializer, CustomFieldModelSerializer): diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 0c164de07..88537b649 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -1,6 +1,7 @@ import base64 from Crypto.PublicKey import RSA +from django.db.models import Count from django.http import HttpResponseBadRequest from rest_framework.exceptions import ValidationError from rest_framework.permissions import IsAuthenticated @@ -32,7 +33,9 @@ class SecretsFieldChoicesViewSet(FieldChoicesViewSet): # class SecretRoleViewSet(ModelViewSet): - queryset = SecretRole.objects.all() + queryset = SecretRole.objects.annotate( + secret_count=Count('secrets') + ) serializer_class = serializers.SecretRoleSerializer permission_classes = [IsAuthenticated] filterset_class = filters.SecretRoleFilter diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index 6548708b5..628d716db 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -11,10 +11,10 @@ class SecretRoleFilter(NameSlugSearchFilterSet): class Meta: model = SecretRole - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class SecretFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SecretFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/secrets/migrations/0006_custom_tag_models.py b/netbox/secrets/migrations/0006_custom_tag_models.py new file mode 100644 index 000000000..399dcc5bf --- /dev/null +++ b/netbox/secrets/migrations/0006_custom_tag_models.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0005_change_logging'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='secret', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 8190cd1dd..6dcb5abee 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -14,7 +14,7 @@ from django.urls import reverse from django.utils.encoding import force_bytes from taggit.managers import TaggableManager -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -345,7 +345,7 @@ class Secret(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) plaintext = None csv_headers = ['device', 'role', 'name', 'plaintext'] diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index c260f1a48..ce0295d83 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -78,7 +78,7 @@ class SecretRoleTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'secret_count', 'slug', 'url'] ) def test_create_secretrole(self): diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index f9985db28..5ba5cdcf6 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -1,25 +1,19 @@ import urllib.parse -from django.contrib.auth import get_user_model from django.test import Client, TestCase from django.urls import reverse from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from secrets.models import Secret, SecretRole +from utilities.testing import create_test_user class SecretRoleTestCase(TestCase): def setUp(self): - - TEST_USERNAME = 'testuser' - TEST_PASSWORD = 'testpassword' - - User = get_user_model() - User.objects.create(username=TEST_USERNAME, email='testuser@example.com', password=TEST_PASSWORD) - + user = create_test_user(permissions=['secrets.view_secretrole']) self.client = Client() - self.client.login(username=TEST_USERNAME, password=TEST_PASSWORD) + self.client.force_login(user) SecretRole.objects.bulk_create([ SecretRole(name='Secret Role 1', slug='secret-role-1'), @@ -29,7 +23,7 @@ class SecretRoleTestCase(TestCase): def test_secretrole_list(self): - url = reverse('secrets:secret_list') + url = reverse('secrets:secretrole_list') response = self.client.get(url, follow=True) self.assertEqual(response.status_code, 200) @@ -38,8 +32,9 @@ class SecretRoleTestCase(TestCase): class SecretTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['secrets.view_secret']) self.client = Client() + self.client.force_login(user) site = Site(name='Site 1', slug='site-1') site.save() @@ -75,7 +70,7 @@ class SecretTestCase(TestCase): response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)), follow=True) self.assertEqual(response.status_code, 200) - def test_configcontext(self): + def test_secret(self): secret = Secret.objects.first() response = self.client.get(secret.get_absolute_url(), follow=True) diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index e1ce2b8f2..9d07dd63c 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from . import views @@ -8,21 +8,21 @@ app_name = 'secrets' urlpatterns = [ # Secret roles - url(r'^secret-roles/$', views.SecretRoleListView.as_view(), name='secretrole_list'), - url(r'^secret-roles/add/$', views.SecretRoleCreateView.as_view(), name='secretrole_add'), - url(r'^secret-roles/import/$', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), - url(r'^secret-roles/delete/$', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - url(r'^secret-roles/(?P[\w-]+)/edit/$', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - url(r'^secret-roles/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path(r'secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), + path(r'secret-roles/add/', views.SecretRoleCreateView.as_view(), name='secretrole_add'), + path(r'secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path(r'secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), + path(r'secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path(r'secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets - url(r'^secrets/$', views.SecretListView.as_view(), name='secret_list'), - url(r'^secrets/import/$', views.SecretBulkImportView.as_view(), name='secret_import'), - url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), - url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), - url(r'^secrets/(?P\d+)/$', views.SecretView.as_view(), name='secret'), - url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), - url(r'^secrets/(?P\d+)/delete/$', views.SecretDeleteView.as_view(), name='secret_delete'), - url(r'^secrets/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path(r'secrets/', views.SecretListView.as_view(), name='secret_list'), + path(r'secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'), + path(r'secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), + path(r'secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), + path(r'secrets//', views.SecretView.as_view(), name='secret'), + path(r'secrets//edit/', views.secret_edit, name='secret_edit'), + path(r'secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), + path(r'secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), ] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 99b725528..b30987935 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -1,12 +1,11 @@ import base64 from django.contrib import messages -from django.contrib.auth.decorators import permission_required, login_required +from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin from django.db.models import Count from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.decorators import method_decorator from django.views.generic import View from dcim.models import Device @@ -32,7 +31,8 @@ def get_session_key(request): # Secret roles # -class SecretRoleListView(ObjectListView): +class SecretRoleListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'secrets.view_secretrole' queryset = SecretRole.objects.annotate(secret_count=Count('secrets')) table = tables.SecretRoleTable template_name = 'secrets/secretrole_list.html' @@ -67,8 +67,8 @@ class SecretRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Secrets # -@method_decorator(login_required, name='dispatch') -class SecretListView(ObjectListView): +class SecretListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'secrets.view_secret' queryset = Secret.objects.select_related('role', 'device') filter = filters.SecretFilter filter_form = forms.SecretFilterForm @@ -76,8 +76,8 @@ class SecretListView(ObjectListView): template_name = 'secrets/secret_list.html' -@method_decorator(login_required, name='dispatch') -class SecretView(View): +class SecretView(PermissionRequiredMixin, View): + permission_required = 'secrets.view_secret' def get(self, request, pk): @@ -198,7 +198,7 @@ class SecretDeleteView(PermissionRequiredMixin, ObjectDeleteView): class SecretBulkImportView(BulkImportView): - permission_required = 'ipam.add_vlan' + permission_required = 'secrets.add_secret' model_form = forms.SecretCSVForm table = tables.SecretTable template_name = 'secrets/secret_import.html' diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 02b6bb32c..0a2864ea6 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -58,8 +58,8 @@

    Docs · API · - Code · - Help + Code · + Help

    diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 890b2a880..a29a2ed7c 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block title %}{{ circuit }}{% endblock %} @@ -41,13 +42,18 @@

    {{ circuit }}

    {% include 'inc/created_updated.html' with obj=circuit %} +
    + {% custom_links circuit %} +
    {% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index a3ca77279..d161fdb68 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -55,9 +55,16 @@ {% else %} {% if perms.dcim.add_cable %}
    - - Connect - + + + +
    {% endif %} Not defined diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 3dd5d973f..a83a5337a 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,5 +1,6 @@ {% extends '_base.html' %} {% load static %} +{% load custom_links %} {% load helpers %} {% block title %}{{ provider }}{% endblock %} @@ -47,13 +48,18 @@

    {{ provider }}

    {% include 'inc/created_updated.html' with obj=provider %} +
    + {% custom_links provider %} +
    {% endblock %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 953e6caf4..54e575009 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -23,13 +24,19 @@ {% endif %}

    {% block title %}Cable {{ cable }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=cable %} +
    + {% custom_links cable %} +
    {% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index cad396966..b1609f578 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -22,7 +22,7 @@ {% endif %} {% with termination_a=form.instance.termination_a %} -

    {% block title %}Connect {{ termination_a.device }} {{ termination_a }}{% endblock %}

    +

    {% block title %}Connect {{ termination_a.device }} {{ termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %}

    @@ -101,21 +101,43 @@ B Side
    - -
    - -
    - {% render_field form.termination_b_site %} - {% render_field form.termination_b_rack %} + {% if tabs %} + + {% endif %} + {% if 'termination_b_provider' in form.fields %} + {% render_field form.termination_b_provider %} + {% endif %} + {% if 'termination_b_site' in form.fields %} + {% render_field form.termination_b_site %} + {% endif %} + {% if 'termination_b_rackgroup' in form.fields %} + {% render_field form.termination_b_rackgroup %} + {% endif %} + {% if 'termination_b_rack' in form.fields %} + {% render_field form.termination_b_rack %} + {% endif %} + {% if 'termination_b_device' in form.fields %} + {% render_field form.termination_b_device %} + {% endif %} + {% if 'termination_b_type' in form.fields %} + {% render_field form.termination_b_type %} + {% endif %} + {% if 'termination_b_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} +
    + +
    +

    {{ termination_b_type|capfirst }}

    - {% render_field form.termination_b_device %} - {% render_field form.termination_b_type %} {% render_field form.termination_b_id %}
    diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 7ef88543e..c9da88c46 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -31,8 +31,10 @@

    {{ cable.get_status_display }}

    {{ cable.get_type_display|default:"" }}

    - {% if cable.length %}- {{ cable.length }}{{ cable.get_length_unit_display }}{% endif %} -   + {% if cable.length %}{{ cable.length }} {{ cable.get_length_unit_display }}{% endif %} + {% if cable.color %} +   + {% endif %} {% else %}

    No Cable

    {% endif %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 047887dd3..414b61bb6 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load helpers %} +{% load custom_links %} {% block title %}{{ device }}{% endblock %} @@ -64,6 +65,9 @@

    {{ device }}

    {% include 'inc/created_updated.html' with obj=device %} +
    + {% custom_links device %} +
    {% endblock %} @@ -332,6 +340,49 @@ {% endif %}
    {% endif %} + {% if power_ports and poweroutlets %} +
    +
    + Power Utilization +
    + + + + + + + + + {% for pp in power_ports %} + {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} + + + + + {% if powerfeed.available_power %} + + + {% else %} + + + {% endif %} + + {% for leg in utilization.legs %} + + + + + + {% with phase_available=powerfeed.available_power|divide:3 %} + + {% endwith %} + + {% endfor %} + {% endwith %} + {% endfor %} +
    InputOutletsAllocatedAvailableUtilization
    {{ pp }}{{ utilization.outlet_count }}{{ utilization.allocated }}VA{{ powerfeed.available_power }}VA{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}
    Leg {{ leg.name }}{{ leg.outlet_count }}{{ leg.allocated }}{{ powerfeed.available_power|divide:3 }}VA{% utilization_graph leg.allocated|percentage:phase_available %}
    +
    + {% endif %} {% if request.user.is_authenticated %}
    @@ -445,6 +496,7 @@ {% endif %} Name Status + Description Installed Device @@ -570,6 +622,7 @@ {% endif %} Name + Description Cable Connection @@ -586,6 +639,9 @@ + @@ -625,8 +681,10 @@ {% endif %} Name + Input/Leg + Description Cable - Connection + Connection @@ -641,6 +699,9 @@ + diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html index 4bc8f0833..0d50343eb 100644 --- a/netbox/templates/dcim/device_inventory.html +++ b/netbox/templates/dcim/device_inventory.html @@ -37,7 +37,7 @@ Name Manufacturer - Part Number + Part ID Serial Number Asset Tag Description diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html index d2bba9562..4764fffa2 100644 --- a/netbox/templates/dcim/device_lldp_neighbors.html +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -27,7 +27,7 @@ {{ iface.connected_endpoint.device }} - {{ iface.connected_endpoint }} + {{ iface.connected_endpoint }} {% elif iface.connected_endpoint.circuit %} {% with circuit=iface.connected_endpoint.circuit %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 5b5410457..2e244ac55 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block title %}{{ devicetype.manufacturer }} {{ devicetype.model }}{% endblock %} @@ -46,13 +47,18 @@ {% endif %}

    {{ devicetype.manufacturer }} {{ devicetype.model }}

    {% include 'inc/created_updated.html' with obj=devicetype %} +
    + {% custom_links devicetype %} +
    {% endblock %} diff --git a/netbox/templates/dcim/inc/cable_trace_end.html b/netbox/templates/dcim/inc/cable_trace_end.html index c4c41e4ba..4eab4d1d7 100644 --- a/netbox/templates/dcim/inc/cable_trace_end.html +++ b/netbox/templates/dcim/inc/cable_trace_end.html @@ -20,7 +20,7 @@ {% with model=end|model_name %} {{ model|bettertitle }} {{ end }}
    {% if model == 'interface' %} - {{ end.get_form_factor_display }} + {{ end.get_type_display }} {% elif model == 'front port' or model == 'rear port' %} {{ end.get_type_display }} {% endif %} diff --git a/netbox/templates/dcim/inc/consoleport.html b/netbox/templates/dcim/inc/consoleport.html index c2d547cc0..03c28c22a 100644 --- a/netbox/templates/dcim/inc/consoleport.html +++ b/netbox/templates/dcim/inc/consoleport.html @@ -4,11 +4,20 @@ {{ cp }} + + + {# Description #} + + {{ cp.description }} + {# Cable #} {% if cp.cable %} {{ cp.cable }} + + + {% else %} — {% endif %} @@ -33,9 +42,16 @@ {% if cp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=cp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleport %} diff --git a/netbox/templates/dcim/inc/consoleserverport.html b/netbox/templates/dcim/inc/consoleserverport.html index 5442795cc..8e5666852 100644 --- a/netbox/templates/dcim/inc/consoleserverport.html +++ b/netbox/templates/dcim/inc/consoleserverport.html @@ -1,3 +1,5 @@ +{% load helpers %} + {# Checkbox #} @@ -12,12 +14,20 @@ {{ csp }} - {# Cable #} + {# Description #} + {{ csp.description|placeholder }} + + + {# Cable #} + {% if csp.cable %} {{ csp.cable }} + + + {% else %} - — + {% endif %} @@ -40,9 +50,16 @@ {% if csp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=csp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_consoleserverport %} diff --git a/netbox/templates/dcim/inc/devicebay.html b/netbox/templates/dcim/inc/devicebay.html index 38f69f5a0..70ce7e8df 100644 --- a/netbox/templates/dcim/inc/devicebay.html +++ b/netbox/templates/dcim/inc/devicebay.html @@ -1,16 +1,35 @@ +{% load helpers %} + {% if perms.dcim.change_devicebay or perms.dcim.delete_devicebay %} {% endif %} + + {# Name #} {{ devicebay.name }} + + {# Status #} + + {% if devicebay.installed_device %} + + {{ devicebay.installed_device.get_status_display }} + + {% else %} + Vacant + {% endif %} + + + {# Description #} + + {{ devicebay.description|placeholder }} + + + {# Installed device #} {% if devicebay.installed_device %} - - {{ devicebay.installed_device.get_status_display }} - {{ devicebay.installed_device }} @@ -18,11 +37,9 @@ {{ devicebay.installed_device.device_type.display_name }} {% else %} - - - Vacant - + {% endif %} + {% if perms.dcim.change_devicebay %} {% if devicebay.installed_device %} diff --git a/netbox/templates/dcim/inc/frontport.html b/netbox/templates/dcim/inc/frontport.html index 2b468725d..ae86a36b4 100644 --- a/netbox/templates/dcim/inc/frontport.html +++ b/netbox/templates/dcim/inc/frontport.html @@ -58,9 +58,17 @@ {% if frontport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=frontport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_frontport %} diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 584d1754d..ab57d2ff4 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -10,7 +10,7 @@ {# Icon and name #} - + {{ iface }} @@ -52,7 +52,7 @@ {% else %} - — + {% endif %} @@ -81,7 +81,7 @@ - + {{ iface.connected_endpoint }} @@ -151,9 +151,17 @@ {% if iface.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %} {% elif iface.is_connectable and perms.dcim.add_cable %} - - - + + + + {% endif %} diff --git a/netbox/templates/dcim/inc/poweroutlet.html b/netbox/templates/dcim/inc/poweroutlet.html index 5aaeaef2e..6c2e70373 100644 --- a/netbox/templates/dcim/inc/poweroutlet.html +++ b/netbox/templates/dcim/inc/poweroutlet.html @@ -1,3 +1,5 @@ +{% load helpers %} + {# Checkbox #} @@ -12,25 +14,53 @@ {{ po }} - {# Cable #} + {# Input/leg #} + {% if po.power_port %} + {{ po.power_port }}{% if po.feed_leg %} / {{ po.get_feed_leg_display }}{% endif %} + {% elif po.feed_leg %} + {{ po.get_feed_leg_display }} + {% else %} + None + {% endif %} + + + {# Description #} + + {{ po.description|placeholder }} + + + {# Cable #} + {% if po.cable %} {{ po.cable }} + + + {% else %} - — + {% endif %} {# Connection #} {% if po.connected_endpoint %} - - {{ po.connected_endpoint.device }} - - - {{ po.connected_endpoint }} - + {% with pp=po.connected_endpoint %} + + {{ pp.device }} + + + {{ pp }} + + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + {% endwith %} {% else %} - + Not connected {% endif %} @@ -40,7 +70,7 @@ {% if po.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=po.cable %} {% elif perms.dcim.add_cable %} - + {% endif %} diff --git a/netbox/templates/dcim/inc/powerport.html b/netbox/templates/dcim/inc/powerport.html index 0c7bb1cdc..99e9e8991 100644 --- a/netbox/templates/dcim/inc/powerport.html +++ b/netbox/templates/dcim/inc/powerport.html @@ -5,23 +5,44 @@ {{ pp }} + {# Current draw #} + + {% if pp.allocated_draw %} + {{ pp.allocated_draw }}W{% if pp.maximum_draw %} ({{ pp.maximum_draw }}W max){% endif %} + {% elif pp.maximum_draw %} + {{ pp.maximum_draw }}W + {% endif %} + + + {# Description #} + + {{ pp.description }} + + {# Cable #} {% if pp.cable %} {{ pp.cable }} + + + {% else %} — {% endif %} {# Connection #} - {% if pp.connected_endpoint %} + {% if pp.connected_endpoint.device %} {{ pp.connected_endpoint.device }} {{ pp.connected_endpoint }} + {% elif pp.connected_endpoint %} + + {{ pp.connected_endpoint }} + {% else %} Not connected @@ -33,9 +54,15 @@ {% if pp.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=pp.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_powerport %} diff --git a/netbox/templates/dcim/inc/rearport.html b/netbox/templates/dcim/inc/rearport.html index 63aae1127..27609e726 100644 --- a/netbox/templates/dcim/inc/rearport.html +++ b/netbox/templates/dcim/inc/rearport.html @@ -57,9 +57,17 @@ {% if rearport.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=rearport.cable %} {% elif perms.dcim.add_cable %} - - - + + + + {% endif %} {% if perms.dcim.change_rearport %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3c3951417..4e7cc6306 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -32,9 +32,11 @@ - + {% if perms.extras.view_objectchange %} + + {% endif %} {% endblock %} @@ -58,7 +60,7 @@ Type - {{ interface.get_form_factor_display }} + {{ interface.get_type_display }} Enabled @@ -123,7 +125,7 @@ Type - {{ connected_interface.get_form_factor_display }} + {{ connected_interface.get_type_display }} Enabled @@ -225,7 +227,7 @@ {{ member }} - {{ member.get_form_factor_display }} + {{ member.get_type_display }} {% empty %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 6acdf39e3..fedb1b61a 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -6,7 +6,7 @@
    Interface
    {% render_field form.name %} - {% render_field form.form_factor %} + {% render_field form.type %} {% render_field form.enabled %} {% render_field form.lag %} {% render_field form.mac_address %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html new file mode 100644 index 000000000..8589524c9 --- /dev/null +++ b/netbox/templates/dcim/powerfeed.html @@ -0,0 +1,155 @@ +{% extends '_base.html' %} +{% load static %} +{% load custom_links %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% if perms.dcim.change_powerfeed %} + + + Edit this power feed + + {% endif %} + {% if perms.dcim.delete_powerfeed %} + + + Delete this power feed + + {% endif %} +
    +

    {% block title %}{{ powerfeed }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=powerfeed %} +
    + {% custom_links powerfeed %} +
    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Power Feed +
    + + + + + + + + + + + + + + + + + + + + + + + + {% with utilization=powerfeed.connected_endpoint.get_power_draw %} + {% if utilization %} + + {% else %} + + {% endif %} + {% endwith %} + +
    Power Panel + {{ powerfeed.power_panel }} +
    Rack + {% if powerfeed.rack %} + {{ powerfeed.rack }} + {% else %} + None + {% endif %} +
    Type + {{ powerfeed.get_type_display }} +
    Status + {{ powerfeed.get_status_display }} +
    Connected Device + {% if powerfeed.connected_endpoint %} + {{ powerfeed.connected_endpoint.device }} ({{ powerfeed.connected_endpoint }}) + {% else %} + None + {% endif %} +
    Utilization (Allocated) + {{ utilization.allocated }}VA / {{ powerfeed.available_power }}VA + {% utilization_graph utilization.allocated|percentage:powerfeed.available_power %} + N/A
    +
    +
    +
    +
    +
    + Electrical Characteristics +
    + + + + + + + + + + + + + + + + + + + + + +
    Supply{{ powerfeed.get_supply_display }}
    Voltage{{ powerfeed.voltage }}V
    Amperage{{ powerfeed.amperage }}A
    Phase{{ powerfeed.get_phase_display }}
    Max Utilization{{ powerfeed.max_utilization }}%
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html new file mode 100644 index 000000000..f4b3ada46 --- /dev/null +++ b/netbox/templates/dcim/powerfeed_edit.html @@ -0,0 +1,46 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    Power Feed
    +
    + {% render_field form.site %} + {% render_field form.power_panel %} + {% render_field form.rack %} + {% render_field form.name %} + {% render_field form.status %} +
    +
    +
    +
    Characteristics
    +
    + {% render_field form.type %} + {% render_field form.supply %} + {% render_field form.voltage %} + {% render_field form.amperage %} + {% render_field form.phase %} + {% render_field form.max_utilization %} +
    +
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} +
    +
    Tags
    +
    + {% render_field form.tags %} +
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerfeed_list.html b/netbox/templates/dcim/powerfeed_list.html new file mode 100644 index 000000000..cfe2c989c --- /dev/null +++ b/netbox/templates/dcim/powerfeed_list.html @@ -0,0 +1,22 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
    + {% if perms.dcim.add_powerfeed %} + {% add_button 'dcim:powerfeed_add' %} + {% import_button 'dcim:powerfeed_import' %} + {% endif %} + {% export_button content_type %} +
    +

    {% block title %}Power Feeds{% endblock %}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:powerfeed_bulk_edit' bulk_delete_url='dcim:powerfeed_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} + {% include 'inc/tags_panel.html' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html new file mode 100644 index 000000000..b92eddfc7 --- /dev/null +++ b/netbox/templates/dcim/powerpanel.html @@ -0,0 +1,93 @@ +{% extends '_base.html' %} +{% load static %} +{% load custom_links %} +{% load helpers %} + +{% block header %} +
    +
    + +
    +
    +
    +
    + + + + +
    +
    +
    +
    +
    + {% if perms.dcim.change_powerpanel %} + + + Edit this power panel + + {% endif %} + {% if perms.dcim.delete_powerpanel %} + + + Delete this power panel + + {% endif %} +
    +

    {% block title %}{{ powerpanel }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=powerpanel %} +
    + {% custom_links powerpanel %} +
    + +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Power Panel +
    + + + + + + + + + +
    Site + {{ powerpanel.site }} +
    Rack Group + {% if powerpanel.rack_group %} + {{ powerpanel.rack_group }} + {% else %} + None + {% endif %} +
    +
    +
    +
    + {% include 'panel_table.html' with table=powerfeed_table heading='Connected Feeds' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerpanel_list.html b/netbox/templates/dcim/powerpanel_list.html new file mode 100644 index 000000000..a0d49b30b --- /dev/null +++ b/netbox/templates/dcim/powerpanel_list.html @@ -0,0 +1,21 @@ +{% extends '_base.html' %} +{% load buttons %} + +{% block content %} +
    + {% if perms.dcim.add_powerpanel %} + {% add_button 'dcim:powerpanel_add' %} + {% import_button 'dcim:powerpanel_import' %} + {% endif %} + {% export_button content_type %} +
    +

    {% block title %}Power Panels{% endblock %}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_delete_url='dcim:powerpanel_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 68ea75b6c..60a70c36c 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -43,13 +44,18 @@

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

    {% include 'inc/created_updated.html' with obj=rack %} +
    + {% custom_links rack %} +
    {% endblock %} @@ -190,47 +196,45 @@ {% endif %}
    -
    -
    - Non-Racked Devices -
    - {% if nonracked_devices %} - + {% if power_feeds %} +
    +
    + Power Feeds +
    +
    - - + + + - + - {% for device in nonracked_devices %} - + {% for powerfeed in power_feeds %} + - - + + {% with power_port=powerfeed.connected_endpoint %} + {% if power_port %} + {% else %} - + {% endif %} - + {% endwith %} {% endfor %}
    NameRolePanelFeedStatus TypeParentUtilization
    - {{ device }} + {{ powerfeed.power_panel.name }} + + + {{ powerfeed.name }} {{ device.device_role }}{{ device.device_type.display_name }} - {% if device.parent_bay %} - {{ device.parent_bay }} + {{ powerfeed.get_status_display }} + + {{ powerfeed.get_type_display }} + {% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}N/A
    - {% else %} -
    None
    - {% endif %} - {% if perms.dcim.add_device %} - - {% endif %} -
    +
    + {% endif %}
    Images @@ -299,19 +303,62 @@ {% endif %}
    -
    -
    -
    -

    Front

    -
    - {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} -
    -
    -
    -

    Rear

    +
    +
    +
    +
    +

    Front

    +
    + {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} +
    +
    +
    +

    Rear

    +
    + {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} +
    +
    +
    +
    + Non-Racked Devices +
    + {% if nonracked_devices %} + + + + + + + + {% for device in nonracked_devices %} + + + + + + + {% endfor %} +
    NameRoleTypeParent
    + {{ device }} + {{ device.device_role }}{{ device.device_type.display_name }} + {% if device.parent_bay %} + {{ device.parent_bay }} + {% else %} + + {% endif %} +
    + {% else %} +
    None
    + {% endif %} + {% if perms.dcim.add_device %} + + {% endif %}
    - {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} -
    {% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 0ef14ee1b..0e38d2967 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,6 +1,7 @@ {% extends '_base.html' %} {% load static %} {% load tz %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -52,13 +53,18 @@

    {% block title %}{{ site }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=site %} +
    + {% custom_links site %} +
    {% endblock %} diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html index 0dd52a96d..64948a6f9 100644 --- a/netbox/templates/dcim/site_list.html +++ b/netbox/templates/dcim/site_list.html @@ -12,7 +12,7 @@

    {% block title %}Sites{% endblock %}

    - {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' %} + {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:site_bulk_edit' bulk_delete_url='dcim:site_bulk_delete' %}
    {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/extras/object_changelog.html b/netbox/templates/extras/object_changelog.html index ac79be2a6..a286b508c 100644 --- a/netbox/templates/extras/object_changelog.html +++ b/netbox/templates/extras/object_changelog.html @@ -4,5 +4,11 @@ {% block content %} {% if obj %}

    {{ obj }}

    {% endif %} - {% include 'panel_table.html' with table=objectchanges_table %} + {% include 'panel_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% if settings.CHANGELOG_RETENTION %} +
    + Changelog retention: {% if settings.CHANGELOG_RETENTION == 0 %}Indefinite{% else %}{{ settings.CHANGELOG_RETENTION }} days{% endif %} +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/extras/objectchange_list.html b/netbox/templates/extras/objectchange_list.html index ec309e2a6..714689ff0 100644 --- a/netbox/templates/extras/objectchange_list.html +++ b/netbox/templates/extras/objectchange_list.html @@ -9,6 +9,11 @@
    {% include 'utilities/obj_table.html' %} + {% if settings.CHANGELOG_RETENTION %} +
    + Changelog retention: {% if settings.CHANGELOG_RETENTION == 0 %}Indefinite{% else %}{{ settings.CHANGELOG_RETENTION }} days{% endif %} +
    + {% endif %}
    {% include 'inc/search_panel.html' %} diff --git a/netbox/templates/extras/tag.html b/netbox/templates/extras/tag.html index 5251b74de..3011e2d3b 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -37,6 +37,17 @@ {% endif %}

    {% block title %}Tag: {{ tag }}{% endblock %}

    + {% include 'inc/created_updated.html' with obj=tag %} + {% endblock %} {% block content %} @@ -65,8 +76,26 @@ {{ items_count }} + + Color + +   + +
    +
    +
    + Comments +
    +
    + {% if tag.comments %} + {{ tag.comments|gfm }} + {% else %} + None + {% endif %} +
    +
    {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %} diff --git a/netbox/templates/extras/tag_edit.html b/netbox/templates/extras/tag_edit.html new file mode 100644 index 000000000..800db1d26 --- /dev/null +++ b/netbox/templates/extras/tag_edit.html @@ -0,0 +1,19 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
    +
    Tag
    +
    + {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.color %} +
    +
    +
    +
    Comments
    +
    + {% render_field form.comments %} +
    +
    +{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 76d90bad7..8d483568f 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -11,15 +11,25 @@
    - {{ stats.site_count }} -

    Sites

    + {% if perms.dcim.view_site %} + {{ stats.site_count }} +

    Sites

    + {% else %} + +

    Sites

    + {% endif %}

    Geographic locations

    - {{ stats.tenant_count }} -

    Tenants

    -

    Customers or departments

    -
    + {% if perms.tenancy.view_tenant %} + {{ stats.tenant_count }} +

    Tenants

    + {% else %} + +

    Tenants

    + {% endif %} +

    Customers or departments

    +
    @@ -28,42 +38,109 @@
    - {{ stats.rack_count }} -

    Racks

    + {% if perms.dcim.view_rack %} + {{ stats.rack_count }} +

    Racks

    + {% else %} + +

    Racks

    + {% endif %}

    Equipment racks, optionally organized by group

    - {{ stats.device_count }} -

    Devices

    + {% if perms.dcim.view_devicetype %} + {{ stats.devicetype_count }} +

    Device Types

    + {% else %} + +

    Device Types

    + {% endif %} +

    Physical hardware models by manufacturer

    +
    +
    + {% if perms.dcim.view_device %} + {{ stats.device_count }} +

    Devices

    + {% else %} + +

    Devices

    + {% endif %}

    Rack-mounted network equipment, servers, and other devices

    Connections

    - {{ stats.cable_count }} -

    Cables

    - {{ stats.interface_connections_count }} -

    Interfaces

    - {{ stats.console_connections_count }} -

    Console

    - {{ stats.power_connections_count }} -

    Power

    + {% if perms.dcim.view_cable %} + {{ stats.cable_count }} +

    Cables

    + {% else %} + +

    Cables

    + {% endif %} + {% if perms.dcim.view_interface %} + {{ stats.interface_connections_count }} +

    Interfaces

    + {% else %} + +

    Interfaces

    + {% endif %} + {% if perms.dcim.view_consoleport and perms.dcim.view_consoleserverport %} + {{ stats.console_connections_count }} +

    Console

    + {% else %} + +

    Console

    + {% endif %} + {% if perms.dcim.view_powerport and perms.dcim.view_poweroutlet %} + {{ stats.power_connections_count }} +

    Power

    + {% else %} + +

    Power

    + {% endif %}
    - Virtualization + Power
    - {{ stats.cluster_count }} -

    Clusters

    -

    Clusters of physical hosts in which VMs reside

    + {% if perms.dcim.view_powerfeed %} + {{ stats.powerfeed_count }} +

    Power Feeds

    + {% else %} + +

    Power Feeds

    + {% endif %} +

    Electrical circuits delivering power from panels

    - {{ stats.virtualmachine_count }} -

    Virtual Machines

    -

    Virtual compute instances running inside clusters

    + {% if perms.dcim.view_powerpanel %} + {{ stats.powerpanel_count }} +

    Power Panels

    + {% else %} + +

    Power Panels

    + {% endif %} +

    Electrical panels receiving utility power

    +
    +
    +
    +
    +
    + Secrets +
    +
    +
    + {% if perms.secrets.view_secret %} + {{ stats.secret_count }} +

    Secrets

    + {% else %} + +

    Secrets

    + {% endif %} +

    Cryptographically secured secret data

    @@ -75,28 +152,53 @@
    - {{ stats.vrf_count }} -

    VRFs

    + {% if perms.ipam.view_vrf %} + {{ stats.vrf_count }} +

    VRFs

    + {% else %} + +

    VRFs

    + {% endif %}

    Virtual routing and forwarding tables

    - {{ stats.aggregate_count }} -

    Aggregates

    + {% if perms.ipam.view_aggregate %} + {{ stats.aggregate_count }} +

    Aggregates

    + {% else %} + +

    Aggregates

    + {% endif %}

    Top-level IP allocations

    - {{ stats.prefix_count }} -

    Prefixes

    + {% if perms.ipam.view_prefix %} + {{ stats.prefix_count }} +

    Prefixes

    + {% else %} + +

    Prefixes

    + {% endif %}

    IPv4 and IPv6 network assignments

    - {{ stats.ipaddress_count }} -

    IP Addresses

    + {% if perms.ipam.view_ipaddress %} + {{ stats.ipaddress_count }} +

    IP Addresses

    + {% else %} + +

    IP Addresses

    + {% endif %}

    Individual IPv4 and IPv6 addresses

    - {{ stats.vlan_count }} -

    VLANs

    + {% if perms.ipam.view_vlan %} + {{ stats.vlan_count }} +

    VLANs

    + {% else %} + +

    VLANs

    + {% endif %}

    Layer two domains, identified by VLAN ID

    @@ -107,38 +209,61 @@
    - {{ stats.provider_count }} -

    Providers

    + {% if perms.circuits.view_provider %} + {{ stats.provider_count }} +

    Providers

    + {% else %} + +

    Providers

    + {% endif %}

    Organizations which provide circuit connectivity

    - {{ stats.circuit_count }} -

    Circuits

    + {% if perms.circuits.view_circuit %} + {{ stats.circuit_count }} +

    Circuits

    + {% else %} + +

    Circuits

    + {% endif %}

    Communication links for Internet transit, peering, and other services

    - {% if perms.secrets %} -
    -
    - Secrets +
    +
    + Virtualization +
    +
    +
    + {% if perms.virtualization.view_cluster %} + {{ stats.cluster_count }} +

    Clusters

    + {% else %} + +

    Clusters

    + {% endif %} +

    Clusters of physical hosts in which VMs reside

    -
    -
    - {{ stats.secret_count }} -

    Secrets

    -

    Sensitive data (such as passwords) which has been stored securely

    -
    +
    + {% if perms.virtualization.view_virtualmachine %} + {{ stats.virtualmachine_count }} +

    Virtual Machines

    + {% else %} + +

    Virtual Machines

    + {% endif %} +

    Virtual compute instances running inside clusters

    - {% endif %} +
    Global Topology Maps
    - {% if topology_maps %} + {% if topology_maps and perms.extras.view_topologymap %} {% for tm in topology_maps %} @@ -147,17 +272,21 @@ {% endfor %}
    + {% elif perms.extras.view_topologymap %} +
    + None found +
    {% else %}
    - None + No permission
    {% endif %}
    - {% if report_results %} -
    -
    - Reports -
    +
    +
    + Reports +
    + {% if report_results and perms.extras.view_reportresult %} {% for result in report_results %} @@ -166,45 +295,61 @@ {% endfor %}
    -
    - {% endif %} + {% elif perms.extras.view_reportresult %} +
    + None found +
    + {% else %} +
    + No permission +
    + {% endif %} +
    Changelog
    -
    - {% for change in changelog %} - {% with action=change.get_action_display|lower %} -
    - {% if action == 'created' %} - - {% elif action == 'updated' %} - - {% elif action == 'deleted' %} - - {% endif %} - {{ change.changed_object_type.name|bettertitle }} - {% if change.changed_object.get_absolute_url %} - {{ change.changed_object }} - {% else %} - {{ change.changed_object|default:change.object_repr }} - {% endif %} -
    - - {{ change.user|default:change.user_name }} - - {{ change.time|date:'SHORT_DATETIME_FORMAT' }} - -
    - {% endwith %} - {% if forloop.last %} - - {% endif %} - {% empty %} -
    No change history found
    - {% endfor %} -
    + {% if changelog and perms.extras.view_objectchange %} +
    + {% for change in changelog %} + {% with action=change.get_action_display|lower %} +
    + {% if action == 'created' %} + + {% elif action == 'updated' %} + + {% elif action == 'deleted' %} + + {% endif %} + {{ change.changed_object_type.name|bettertitle }} + {% if change.changed_object.get_absolute_url %} + {{ change.changed_object }} + {% else %} + {{ change.changed_object|default:change.object_repr }} + {% endif %} +
    + + {{ change.user|default:change.user_name }} - + {{ change.time|date:'SHORT_DATETIME_FORMAT' }} + +
    + {% endwith %} + {% if forloop.last %} + + {% endif %} + {% endfor %} +
    + {% elif perms.extras.view_objectchange %} +
    + No change history found +
    + {% else %} +
    + No permission +
    + {% endif %}
    diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 5f8f371d3..b8e0d6dcb 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -20,7 +20,7 @@ @@ -78,7 +78,7 @@ @@ -117,7 +117,7 @@ @@ -202,7 +202,7 @@ @@ -293,7 +293,7 @@ {% endif %} {% else %} -
  • Log in
  • + {% url 'login' as login_url %} + {% if request.path == login_url %} +
  • Log in
  • + {% else %} +
  • Log in
  • + {% endif %} {% endif %}

    {% block title %}{{ secret }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=secret %} +
    + {% custom_links secret %} +
    {% endblock %} diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 2cb925303..053c69121 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -41,13 +42,18 @@

    {% block title %}{{ tenant }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=tenant %} +
    + {% custom_links tenant %} +
    {% endblock %} diff --git a/netbox/templates/utilities/templatetags/tag.html b/netbox/templates/utilities/templatetags/tag.html index 09b885d42..0be4e7ca5 100644 --- a/netbox/templates/utilities/templatetags/tag.html +++ b/netbox/templates/utilities/templatetags/tag.html @@ -1,5 +1,7 @@ +{% load helpers %} + {% if url_name %} - {{ tag }} + {{ tag }} {% else %} {{ tag }} {% endif %} diff --git a/netbox/templates/virtualization/cluster.html b/netbox/templates/virtualization/cluster.html index d6a589775..b543e85b5 100644 --- a/netbox/templates/virtualization/cluster.html +++ b/netbox/templates/virtualization/cluster.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -32,7 +33,7 @@ Edit this cluster {% endif %} - {% if perms.dcim.delete_cluster %} + {% if perms.virtualization.delete_cluster %} Delete this cluster @@ -41,13 +42,18 @@

    {% block title %}{{ cluster }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=cluster %} +
    + {% custom_links cluster %} +
    {% endblock %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index b1a8a1505..2498039ff 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -1,4 +1,5 @@ {% extends '_base.html' %} +{% load custom_links %} {% load helpers %} {% block header %} @@ -40,16 +41,23 @@

    {% block title %}{{ virtualmachine }}{% endblock %}

    {% include 'inc/created_updated.html' with obj=virtualmachine %} +
    + {% custom_links virtualmachine %} +
    {% endblock %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index d26ac4675..80780dba3 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -15,10 +15,11 @@ __all__ = [ class NestedTenantGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail') + tenant_count = serializers.IntegerField(read_only=True) class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'tenant_count'] class NestedTenantSerializer(WritableNestedSerializer): diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 80f3b948d..28ae04694 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,3 +1,4 @@ +from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from extras.api.customfields import CustomFieldModelSerializer @@ -11,19 +12,30 @@ from .nested_serializers import * # class TenantGroupSerializer(ValidatedModelSerializer): + tenant_count = serializers.IntegerField(read_only=True) class Meta: model = TenantGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'tenant_count'] class TenantSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedTenantGroupSerializer(required=False) tags = TagListSerializerField(required=False) + circuit_count = serializers.IntegerField(read_only=True) + device_count = serializers.IntegerField(read_only=True) + ipaddress_count = serializers.IntegerField(read_only=True) + prefix_count = serializers.IntegerField(read_only=True) + rack_count = serializers.IntegerField(read_only=True) + site_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) + vlan_count = serializers.IntegerField(read_only=True) + vrf_count = serializers.IntegerField(read_only=True) class Meta: model = Tenant fields = [ 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', + 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', ] diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index af3e318fc..b79a076f1 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,7 +1,12 @@ +from circuits.models import Circuit +from dcim.models import Device, Rack, Site from extras.api.views import CustomFieldModelViewSet +from ipam.models import IPAddress, Prefix, VLAN, VRF from tenancy import filters from tenancy.models import Tenant, TenantGroup from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery +from virtualization.models import VirtualMachine from . import serializers @@ -18,7 +23,9 @@ class TenancyFieldChoicesViewSet(FieldChoicesViewSet): # class TenantGroupViewSet(ModelViewSet): - queryset = TenantGroup.objects.all() + queryset = TenantGroup.objects.annotate( + tenant_count=get_subquery(Tenant, 'group') + ) serializer_class = serializers.TenantGroupSerializer filterset_class = filters.TenantGroupFilter @@ -28,6 +35,20 @@ class TenantGroupViewSet(ModelViewSet): # class TenantViewSet(CustomFieldModelViewSet): - queryset = Tenant.objects.select_related('group').prefetch_related('tags') + queryset = Tenant.objects.select_related( + 'group' + ).prefetch_related( + 'tags' + ).annotate( + circuit_count=get_subquery(Circuit, 'tenant'), + device_count=get_subquery(Device, 'tenant'), + ipaddress_count=get_subquery(IPAddress, 'tenant'), + prefix_count=get_subquery(Prefix, 'tenant'), + rack_count=get_subquery(Rack, 'tenant'), + site_count=get_subquery(Site, 'tenant'), + virtualmachine_count=get_subquery(VirtualMachine, 'tenant'), + vlan_count=get_subquery(VLAN, 'tenant'), + vrf_count=get_subquery(VRF, 'tenant') + ) serializer_class = serializers.TenantSerializer filterset_class = filters.TenantFilter diff --git a/netbox/tenancy/filters.py b/netbox/tenancy/filters.py index 2610b3ec0..acb0fa0cc 100644 --- a/netbox/tenancy/filters.py +++ b/netbox/tenancy/filters.py @@ -10,10 +10,10 @@ class TenantGroupFilter(NameSlugSearchFilterSet): class Meta: model = TenantGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] -class TenantFilter(CustomFieldFilterSet, django_filters.FilterSet): +class TenantFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py new file mode 100644 index 000000000..8980777b0 --- /dev/null +++ b/netbox/tenancy/filtersets.py @@ -0,0 +1,27 @@ +import django_filters +from .models import Tenant, TenantGroup + + +class TenancyFilterSet(django_filters.FilterSet): + tenant_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='tenant__group__id', + queryset=TenantGroup.objects.all(), + to_field_name='id', + label='Tenant Group (ID)', + ) + tenant_group = django_filters.ModelMultipleChoiceFilter( + field_name='tenant__group__slug', + queryset=TenantGroup.objects.all(), + to_field_name='slug', + label='Tenant Group (slug)', + ) + tenant_id = django_filters.ModelMultipleChoiceFilter( + queryset=Tenant.objects.all(), + label='Tenant (ID)', + ) + tenant = django_filters.ModelMultipleChoiceFilter( + field_name='tenant__slug', + queryset=Tenant.objects.all(), + to_field_name='slug', + label='Tenant (slug)', + ) diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 3c97eb801..f8aaa45e5 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -1,5 +1,4 @@ from django import forms -from django.db.models import Count from taggit.forms import TagField from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm @@ -117,7 +116,7 @@ class TenantFilterForm(BootstrapMixin, CustomFieldFilterForm): # -# Tenancy form extension +# Form extensions # class TenancyForm(ChainedFieldsMixin, forms.Form): @@ -155,3 +154,29 @@ class TenancyForm(ChainedFieldsMixin, forms.Form): kwargs['initial'] = initial super().__init__(*args, **kwargs) + + +class TenancyFilterForm(forms.Form): + tenant_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + null_option=True, + filter_for={ + 'tenant': 'group' + } + ) + ) + tenant = FilterChoiceField( + queryset=Tenant.objects.all(), + to_field_name='slug', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) + ) diff --git a/netbox/tenancy/migrations/0006_custom_tag_models.py b/netbox/tenancy/migrations/0006_custom_tag_models.py new file mode 100644 index 000000000..dd39ff471 --- /dev/null +++ b/netbox/tenancy/migrations/0006_custom_tag_models.py @@ -0,0 +1,20 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0005_change_logging'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='tenant', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 045679b90..bc67804d6 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -3,7 +3,7 @@ from django.db import models from django.urls import reverse from taggit.managers import TaggableManager -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel @@ -70,7 +70,7 @@ class Tenant(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'slug', 'group', 'description', 'comments'] diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index 69db73ac6..121898019 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -36,7 +36,7 @@ class TenantGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['id', 'name', 'slug', 'tenant_count', 'url'] ) def test_create_tenantgroup(self): diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index a6ca8f358..877b70145 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -4,13 +4,15 @@ from django.test import Client, TestCase from django.urls import reverse from tenancy.models import Tenant, TenantGroup +from utilities.testing import create_test_user class TenantGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['tenancy.view_tenantgroup']) self.client = Client() + self.client.force_login(user) TenantGroup.objects.bulk_create([ TenantGroup(name='Tenant Group 1', slug='tenant-group-1'), @@ -29,8 +31,9 @@ class TenantGroupTestCase(TestCase): class TenantTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['tenancy.view_tenant']) self.client = Client() + self.client.force_login(user) tenantgroup = TenantGroup(name='Tenant Group 1', slug='tenant-group-1') tenantgroup.save() diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index 19522e6c7..fb23a6ef1 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from . import views @@ -8,22 +8,22 @@ app_name = 'tenancy' urlpatterns = [ # Tenant groups - url(r'^tenant-groups/$', views.TenantGroupListView.as_view(), name='tenantgroup_list'), - url(r'^tenant-groups/add/$', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), - url(r'^tenant-groups/import/$', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), - url(r'^tenant-groups/delete/$', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), - url(r'^tenant-groups/(?P[\w-]+)/edit/$', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), - url(r'^tenant-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), + path(r'tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), + path(r'tenant-groups/add/', views.TenantGroupCreateView.as_view(), name='tenantgroup_add'), + path(r'tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), + path(r'tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), + path(r'tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), + path(r'tenant-groups//changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}), # Tenants - url(r'^tenants/$', views.TenantListView.as_view(), name='tenant_list'), - url(r'^tenants/add/$', views.TenantCreateView.as_view(), name='tenant_add'), - url(r'^tenants/import/$', views.TenantBulkImportView.as_view(), name='tenant_import'), - url(r'^tenants/edit/$', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), - url(r'^tenants/delete/$', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), - url(r'^tenants/(?P[\w-]+)/$', views.TenantView.as_view(), name='tenant'), - url(r'^tenants/(?P[\w-]+)/edit/$', views.TenantEditView.as_view(), name='tenant_edit'), - url(r'^tenants/(?P[\w-]+)/delete/$', views.TenantDeleteView.as_view(), name='tenant_delete'), - url(r'^tenants/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), + path(r'tenants/', views.TenantListView.as_view(), name='tenant_list'), + path(r'tenants/add/', views.TenantCreateView.as_view(), name='tenant_add'), + path(r'tenants/import/', views.TenantBulkImportView.as_view(), name='tenant_import'), + path(r'tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), + path(r'tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), + path(r'tenants//', views.TenantView.as_view(), name='tenant'), + path(r'tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), + path(r'tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), + path(r'tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 97334c9f0..5d43309de 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -18,7 +18,8 @@ from .models import Tenant, TenantGroup # Tenant groups # -class TenantGroupListView(ObjectListView): +class TenantGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'tenancy.view_tenantgroup' queryset = TenantGroup.objects.annotate(tenant_count=Count('tenants')) table = tables.TenantGroupTable template_name = 'tenancy/tenantgroup_list.html' @@ -53,7 +54,8 @@ class TenantGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Tenants # -class TenantListView(ObjectListView): +class TenantListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'tenancy.view_tenant' queryset = Tenant.objects.select_related('group') filter = filters.TenantFilter filter_form = forms.TenantFilterForm @@ -61,7 +63,8 @@ class TenantListView(ObjectListView): template_name = 'tenancy/tenant_list.html' -class TenantView(View): +class TenantView(PermissionRequiredMixin, View): + permission_required = 'tenancy.view_tenant' def get(self, request, slug): diff --git a/netbox/users/urls.py b/netbox/users/urls.py index a45f859e7..40fdbeab1 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -1,18 +1,18 @@ -from django.conf.urls import url +from django.urls import path from . import views app_name = 'user' urlpatterns = [ - url(r'^profile/$', views.ProfileView.as_view(), name='profile'), - url(r'^password/$', views.ChangePasswordView.as_view(), name='change_password'), - url(r'^api-tokens/$', views.TokenListView.as_view(), name='token_list'), - url(r'^api-tokens/add/$', views.TokenEditView.as_view(), name='token_add'), - url(r'^api-tokens/(?P\d+)/edit/$', views.TokenEditView.as_view(), name='token_edit'), - url(r'^api-tokens/(?P\d+)/delete/$', views.TokenDeleteView.as_view(), name='token_delete'), - url(r'^user-key/$', views.UserKeyView.as_view(), name='userkey'), - url(r'^user-key/edit/$', views.UserKeyEditView.as_view(), name='userkey_edit'), - url(r'^session-key/delete/$', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'), + path(r'profile/', views.ProfileView.as_view(), name='profile'), + path(r'password/', views.ChangePasswordView.as_view(), name='change_password'), + path(r'api-tokens/', views.TokenListView.as_view(), name='token_list'), + path(r'api-tokens/add/', views.TokenEditView.as_view(), name='token_add'), + path(r'api-tokens//edit/', views.TokenEditView.as_view(), name='token_edit'), + path(r'api-tokens//delete/', views.TokenDeleteView.as_view(), name='token_delete'), + path(r'user-key/', views.UserKeyView.as_view(), name='userkey'), + path(r'user-key/edit/', views.UserKeyEditView.as_view(), name='userkey_edit'), + path(r'session-key/delete/', views.SessionKeyDeleteView.as_view(), name='sessionkey_delete'), ] diff --git a/netbox/users/views.py b/netbox/users/views.py index 0ff4a8049..6abdd817d 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,7 +1,6 @@ from django.conf import settings from django.contrib import messages from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash -from django.contrib.auth.decorators import login_required from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.models import update_last_login from django.contrib.auth.signals import user_logged_in @@ -82,8 +81,7 @@ class LogoutView(View): # User profiles # -@method_decorator(login_required, name='dispatch') -class ProfileView(View): +class ProfileView(LoginRequiredMixin, View): template_name = 'users/profile.html' def get(self, request): @@ -93,8 +91,7 @@ class ProfileView(View): }) -@method_decorator(login_required, name='dispatch') -class ChangePasswordView(View): +class ChangePasswordView(LoginRequiredMixin, View): template_name = 'users/change_password.html' def get(self, request): @@ -119,8 +116,7 @@ class ChangePasswordView(View): }) -@method_decorator(login_required, name='dispatch') -class UserKeyView(View): +class UserKeyView(LoginRequiredMixin, View): template_name = 'users/userkey.html' def get(self, request): @@ -135,10 +131,9 @@ class UserKeyView(View): }) -class UserKeyEditView(View): +class UserKeyEditView(LoginRequiredMixin, View): template_name = 'users/userkey_edit.html' - @method_decorator(login_required) def dispatch(self, request, *args, **kwargs): try: self.userkey = UserKey.objects.get(user=request.user) @@ -172,7 +167,6 @@ class UserKeyEditView(View): }) -@method_decorator(login_required, name='dispatch') class SessionKeyDeleteView(LoginRequiredMixin, View): def get(self, request): diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index fbebd09ff..9354cbb30 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -3,8 +3,8 @@ from collections import OrderedDict import pytz from django.conf import settings from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist -from django.db.models import ManyToManyField +from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import ManyToManyField, ProtectedError from django.http import Http404 from rest_framework.exceptions import APIException from rest_framework.permissions import BasePermission @@ -13,7 +13,7 @@ from rest_framework.response import Response from rest_framework.serializers import Field, ModelSerializer, ValidationError from rest_framework.viewsets import ModelViewSet as _ModelViewSet, ViewSet -from .utils import dynamic_import +from .utils import dict_to_filter_params, dynamic_import class ServiceUnavailable(APIException): @@ -200,22 +200,48 @@ class WritableNestedSerializer(ModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ - def run_validators(self, value): - # DRF v3.8.2: Skip running validators on the data, since we only accept an integer PK instead of a dict. For - # more context, see: - # https://github.com/encode/django-rest-framework/pull/5922/commits/2227bc47f8b287b66775948ffb60b2d9378ac84f - # https://github.com/encode/django-rest-framework/issues/6053 - return def to_internal_value(self, data): + if data is None: return None + + # Dictionary of related object attributes + if isinstance(data, dict): + params = dict_to_filter_params(data) + try: + return self.Meta.model.objects.get(**params) + except ObjectDoesNotExist: + raise ValidationError( + "Related object not found using the provided attributes: {}".format(params) + ) + except MultipleObjectsReturned: + raise ValidationError( + "Multiple objects match the provided attributes: {}".format(params) + ) + except FieldError as e: + raise ValidationError(e) + + # Integer PK of related object + if isinstance(data, int): + pk = data + else: + try: + # PK might have been mistakenly passed as a string + pk = int(data) + except (TypeError, ValueError): + raise ValidationError( + "Related objects must be referenced by numeric ID or by dictionary of attributes. Received an " + "unrecognized value: {}".format(data) + ) + + # Look up object by PK try: return self.Meta.model.objects.get(pk=int(data)) - except (TypeError, ValueError): - raise ValidationError("Primary key must be an integer") except ObjectDoesNotExist: - raise ValidationError("Invalid ID") + raise ValidationError( + "Related object not found using the provided numeric ID: {}".format(pk) + ) # @@ -248,6 +274,31 @@ class ModelViewSet(_ModelViewSet): # Fall back to the hard-coded serializer class return self.serializer_class + def dispatch(self, request, *args, **kwargs): + try: + return super().dispatch(request, *args, **kwargs) + except ProtectedError as e: + models = ['{} ({})'.format(o, o._meta) for o in e.protected_objects.all()] + msg = 'Unable to delete object. The following dependent objects were found: {}'.format(', '.join(models)) + return self.finalize_response( + request, + Response({'detail': msg}, status=409), + *args, + **kwargs + ) + + def list(self, *args, **kwargs): + """ + Call to super to allow for caching + """ + return super().list(*args, **kwargs) + + def retrieve(self, *args, **kwargs): + """ + Call to super to allow for caching + """ + return super().retrieve(*args, **kwargs) + class FieldChoicesViewSet(ViewSet): """ diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py new file mode 100644 index 000000000..54541b0b5 --- /dev/null +++ b/netbox/utilities/auth_backends.py @@ -0,0 +1,28 @@ +from django.conf import settings +from django.contrib.auth.backends import ModelBackend + + +class ViewExemptModelBackend(ModelBackend): + """ + Custom implementation of Django's stock ModelBackend which allows for the exemption of arbitrary models from view + permission enforcement. + """ + def has_perm(self, user_obj, perm, obj=None): + + # If this is a view permission, check whether the model has been exempted from enforcement + try: + app, codename = perm.split('.') + action, model = codename.split('_') + if action == 'view': + if ( + # All models are exempt from view permission enforcement + '*' in settings.EXEMPT_VIEW_PERMISSIONS + ) or ( + # This specific model is exempt from view permission enforcement + '{}.{}'.format(app, model) in settings.EXEMPT_VIEW_PERMISSIONS + ): + return True + except ValueError: + pass + + return super().has_perm(user_obj, perm, obj) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index b0c2b3ec3..7ba008c70 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -1,7 +1,61 @@ import django_filters +from django import forms from django.conf import settings -from django.db.models import Q -from taggit.models import Tag +from django.db import models + +from dcim.forms import MACAddressField +from extras.models import Tag + + +def multivalue_field_factory(field_class): + """ + Given a form field class, return a subclass capable of accepting multiple values. This allows us to OR on multiple + filter values while maintaining the field's built-in validation. Example: GET /api/dcim/devices/?name=foo&name=bar + """ + class NewField(field_class): + widget = forms.SelectMultiple + + def to_python(self, value): + if not value: + return [] + return [ + # Only append non-empty values (this avoids e.g. trying to cast '' as an integer) + super(field_class, self).to_python(v) for v in value if v + ] + + return type('MultiValue{}'.format(field_class.__name__), (NewField,), dict()) + + +# +# Filters +# + +class MultiValueCharFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.CharField) + + +class MultiValueDateFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateField) + + +class MultiValueDateTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.DateTimeField) + + +class MultiValueNumberFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.IntegerField) + + +class MultiValueTimeFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(forms.TimeField) + + +class MACAddressFilter(django_filters.CharFilter): + field_class = MACAddressField + + +class MultiValueMACAddressFilter(django_filters.MultipleChoiceFilter): + field_class = multivalue_field_factory(MACAddressField) class TreeNodeMultipleChoiceFilter(django_filters.ModelMultipleChoiceFilter): @@ -47,6 +101,10 @@ class TagFilter(django_filters.ModelMultipleChoiceFilter): super().__init__(*args, **kwargs) +# +# FilterSets +# + class NameSlugSearchFilterSet(django_filters.FilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields @@ -60,6 +118,57 @@ class NameSlugSearchFilterSet(django_filters.FilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) | - Q(slug__icontains=value) + models.Q(name__icontains=value) | + models.Q(slug__icontains=value) ) + + +# +# Update default filters +# + +FILTER_DEFAULTS = django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS +FILTER_DEFAULTS.update({ + models.AutoField: { + 'filter_class': MultiValueNumberFilter + }, + models.CharField: { + 'filter_class': MultiValueCharFilter + }, + models.DateField: { + 'filter_class': MultiValueDateFilter + }, + models.DateTimeField: { + 'filter_class': MultiValueDateTimeFilter + }, + models.DecimalField: { + 'filter_class': MultiValueNumberFilter + }, + models.EmailField: { + 'filter_class': MultiValueCharFilter + }, + models.FloatField: { + 'filter_class': MultiValueNumberFilter + }, + models.IntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.PositiveSmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.SlugField: { + 'filter_class': MultiValueCharFilter + }, + models.SmallIntegerField: { + 'filter_class': MultiValueNumberFilter + }, + models.TimeField: { + 'filter_class': MultiValueTimeFilter + }, + models.URLField: { + 'filter_class': MultiValueCharFilter + }, +}) diff --git a/netbox/utilities/managers.py b/netbox/utilities/managers.py index 724773c46..fc2497063 100644 --- a/netbox/utilities/managers.py +++ b/netbox/utilities/managers.py @@ -37,4 +37,8 @@ class NaturalOrderingManager(Manager): else: ordering.append(field) + # Default to using the _nat indexes if Meta.ordering is empty + if not ordering: + ordering = ('_nat1', '_nat2', '_nat3') + return queryset.order_by(*ordering) diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py index 4e321ab19..360fb29fc 100644 --- a/netbox/utilities/middleware.py +++ b/netbox/utilities/middleware.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db import ProgrammingError from django.http import Http404, HttpResponseRedirect from django.urls import reverse +import urllib from .views import server_error @@ -19,10 +20,15 @@ class LoginRequiredMiddleware(object): def __call__(self, request): if LOGIN_REQUIRED and not request.user.is_authenticated: # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API - # performs its own authentication. + # performs its own authentication. Also metrics can be read without login. api_path = reverse('api-root') - if not request.path_info.startswith(api_path) and request.path_info != settings.LOGIN_URL: - return HttpResponseRedirect('{}?next={}'.format(settings.LOGIN_URL, request.path_info)) + if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL: + return HttpResponseRedirect( + '{}?next={}'.format( + settings.LOGIN_URL, + urllib.parse.quote(request.get_full_path_info()) + ) + ) return self.get_response(request) diff --git a/netbox/utilities/sql.py b/netbox/utilities/sql.py deleted file mode 100644 index d76bc339e..000000000 --- a/netbox/utilities/sql.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import connections, models -from django.db.models.sql.compiler import SQLCompiler - - -class NullsFirstSQLCompiler(SQLCompiler): - - def get_order_by(self): - result = super().get_order_by() - if result: - return [(expr, (sql + ' NULLS FIRST', params, is_ref)) for (expr, (sql, params, is_ref)) in result] - return result - - -class NullsFirstQuery(models.sql.query.Query): - - def get_compiler(self, using=None, connection=None): - if using is None and connection is None: - raise ValueError("Need either using or connection") - if using: - connection = connections[using] - return NullsFirstSQLCompiler(self, connection, using) - - -class NullsFirstQuerySet(models.QuerySet): - """ - Override PostgreSQL's default behavior of ordering NULLs last. This is needed e.g. to order Prefixes in the global - table before those assigned to a VRF. - """ - - def __init__(self, model=None, query=None, using=None, hints=None): - super().__init__(model, query, using, hints) - self.query = query or NullsFirstQuery(self.model) diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index 2b465d54a..e6616d888 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -166,6 +166,26 @@ def fgcolor(value): return '#{}'.format(foreground_color(value)) +@register.filter() +def divide(x, y): + """ + Return x/y (rounded). + """ + if x is None or y is None: + return None + return round(x / y) + + +@register.filter() +def percentage(x, y): + """ + Return x/y as a percentage. + """ + if x is None or y is None: + return None + return round(x / y * 100) + + # # Tags # diff --git a/netbox/utilities/testing.py b/netbox/utilities/testing.py index 86fa8c836..0534a340b 100644 --- a/netbox/utilities/testing.py +++ b/netbox/utilities/testing.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth.models import Permission, User from rest_framework.test import APITestCase as _APITestCase from users.models import Token @@ -22,3 +22,16 @@ class APITestCase(_APITestCase): self.assertEqual(response.status_code, expected_status, err_message.format( expected_status, response.status_code, response.data )) + + +def create_test_user(username='testuser', permissions=list()): + """ + Create a User with the given permissions. + """ + user = User.objects.create_user(username=username) + for perm_name in permissions: + app, codename = perm_name.split('.') + perm = Permission.objects.get(content_type__app_label=app, codename=codename) + user.user_permissions.add(perm) + + return user diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py new file mode 100644 index 000000000..3ff4b3876 --- /dev/null +++ b/netbox/utilities/tests/test_api.py @@ -0,0 +1,119 @@ +from django.urls import reverse +from rest_framework import status + +from dcim.models import Region, Site +from ipam.models import VLAN +from utilities.testing import APITestCase + + +class WritableNestedSerializerTest(APITestCase): + """ + Test the operation of WritableNestedSerializer using VLANSerializer as our test subject. + """ + + def setUp(self): + + super().setUp() + + self.region_a = Region.objects.create(name='Region A', slug='region-a') + self.site1 = Site.objects.create(region=self.region_a, name='Site 1', slug='site-1') + self.site2 = Site.objects.create(region=self.region_a, name='Site 2', slug='site-2') + + def test_related_by_pk(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': self.site1.pk, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['site']['id'], self.site1.pk) + vlan = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan.site, self.site1) + + def test_related_by_pk_no_match(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': 999, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Related object not found")) + + def test_related_by_attributes(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'name': 'Site 1' + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(response.data['site']['id'], self.site1.pk) + vlan = VLAN.objects.get(pk=response.data['id']) + self.assertEqual(vlan.site, self.site1) + + def test_related_by_attributes_no_match(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'name': 'Site X' + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Related object not found")) + + def test_related_by_attributes_multiple_matches(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': { + 'region': { + "name": "Region A", + }, + }, + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) + self.assertTrue(response.data['site'][0].startswith("Multiple objects match")) + + def test_related_by_invalid(self): + + data = { + 'vid': 100, + 'name': 'Test VLAN 100', + 'site': 'XXX', + } + + url = reverse('ipam-api:vlan-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + self.assertEqual(VLAN.objects.count(), 0) diff --git a/netbox/utilities/tests/test_utils.py b/netbox/utilities/tests/test_utils.py index 4e0fec1ba..5d9a98ad5 100644 --- a/netbox/utilities/tests/test_utils.py +++ b/netbox/utilities/tests/test_utils.py @@ -1,13 +1,48 @@ from django.test import TestCase -from utilities.utils import deepmerge +from utilities.utils import deepmerge, dict_to_filter_params + + +class DictToFilterParamsTest(TestCase): + """ + Validate the operation of dict_to_filter_params(). + """ + def setUp(self): + return + + def test_dict_to_filter_params(self): + + input = { + 'a': True, + 'foo': { + 'bar': 123, + 'baz': 456, + }, + 'x': { + 'y': { + 'z': False + } + } + } + + output = { + 'a': True, + 'foo__bar': 123, + 'foo__baz': 456, + 'x__y__z': False, + } + + self.assertEqual(dict_to_filter_params(input), output) + + input['x']['y']['z'] = True + + self.assertNotEqual(dict_to_filter_params(input), output) class DeepMergeTest(TestCase): """ Validate the behavior of the deepmerge() utility. """ - def setUp(self): return diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 1d1f12ddb..4644c432e 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -4,6 +4,7 @@ import datetime import json from django.core.serializers import serialize +from django.db.models import Count, OuterRef, Subquery from dcim.constants import LENGTH_UNIT_CENTIMETER, LENGTH_UNIT_FOOT, LENGTH_UNIT_INCH, LENGTH_UNIT_METER @@ -60,6 +61,34 @@ def dynamic_import(name): return mod +def model_names_to_filter_dict(names): + """ + Accept a list of content types in the format ['.', '.', ...] and return a dictionary + suitable for QuerySet filtering. + """ + # TODO: This should match on the app_label as well as the model name to avoid potential duplicate names + return { + 'model__in': [model.split('.')[1] for model in names], + } + + +def get_subquery(model, field): + """ + Return a Subquery suitable for annotating a child object count. + """ + subquery = Subquery( + model.objects.filter( + **{field: OuterRef('pk')} + ).order_by().values( + field + ).annotate( + c=Count('*') + ).values('c') + ) + + return subquery + + def serialize_object(obj, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like @@ -85,6 +114,38 @@ def serialize_object(obj, extra=None): return data +def dict_to_filter_params(d, prefix=''): + """ + Translate a dictionary of attributes to a nested set of parameters suitable for QuerySet filtering. For example: + + { + "name": "Foo", + "rack": { + "facility_id": "R101" + } + } + + Becomes: + + { + "name": "Foo", + "rack__facility_id": "R101" + } + + And can be employed as filter parameters: + + Device.objects.filter(**dict_to_filter(attrs_dict)) + """ + params = {} + for key, val in d.items(): + k = prefix + key + if isinstance(val, dict): + params.update(dict_to_filter_params(val, k + '__')) + else: + params[k] = val + return params + + def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index f52f4ea9e..bbc58f134 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,5 +1,4 @@ import sys -from collections import OrderedDict from copy import deepcopy from django.conf import settings @@ -12,7 +11,7 @@ from django.forms import CharField, Form, ModelMultipleChoiceField, MultipleHidd from django.http import HttpResponse, HttpResponseServerError from django.shortcuts import get_object_or_404, redirect, render from django.template import loader -from django.template.exceptions import TemplateDoesNotExist, TemplateSyntaxError +from django.template.exceptions import TemplateDoesNotExist from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url @@ -23,6 +22,7 @@ from django.views.generic import View from django_tables2 import RequestConfig from extras.models import CustomField, CustomFieldValue, ExportTemplate +from extras.querysets import CustomFieldQueryset from utilities.forms import BootstrapMixin, CSVDataField from utilities.utils import csv_format from .error_handlers import handle_protectederror @@ -30,23 +30,6 @@ from .forms import ConfirmationForm from .paginator import EnhancedPaginator -class CustomFieldQueryset: - """ - Annotate custom fields on objects within a QuerySet. - """ - - def __init__(self, queryset, custom_fields): - self.queryset = queryset - self.model = queryset.model - self.custom_fields = custom_fields - - def __iter__(self): - for obj in self.queryset: - values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} - obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) - yield obj - - class GetReturnURLMixin(object): """ Provides logic for determining where a user should be redirected after processing a form. @@ -115,8 +98,9 @@ class ObjectListView(View): self.queryset = self.filter(request.GET, self.queryset).qs # If this type of object has one or more custom fields, prefetch any relevant custom field values - custom_fields = CustomField.objects.filter(obj_type=ContentType.objects.get_for_model(model))\ - .prefetch_related('choices') + custom_fields = CustomField.objects.filter( + obj_type=ContentType.objects.get_for_model(model) + ).prefetch_related('choices') if custom_fields: self.queryset = self.queryset.prefetch_related('custom_field_values') @@ -126,10 +110,12 @@ class ObjectListView(View): queryset = CustomFieldQueryset(self.queryset, custom_fields) if custom_fields else self.queryset try: return et.render_to_response(queryset) - except TemplateSyntaxError: + except Exception as e: messages.error( request, - "There was an error rendering the selected export template ({}).".format(et.name) + "There was an error rendering the selected export template ({}): {}".format( + et.name, e + ) ) # Fall back to built-in CSV formatting if export requested but no template specified @@ -157,7 +143,7 @@ class ObjectListView(View): # Construct queryset for tags list if hasattr(model, 'tags'): - tags = model.tags.annotate(count=Count('taggit_taggeditem_items')).order_by('name') + tags = model.tags.annotate(count=Count('extras_taggeditem_items')).order_by('name') else: tags = None diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index fb6e2b0be..47b7e6442 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -19,26 +19,29 @@ __all__ = [ class NestedClusterTypeSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'cluster_count'] class NestedClusterGroupSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'name', 'slug', 'cluster_count'] class NestedClusterSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Cluster - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'name', 'virtualmachine_count'] # diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 1b06dab3b..0b98ce44a 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,8 +1,9 @@ +from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer -from dcim.constants import IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_MODE_CHOICES +from dcim.constants import IFACE_TYPE_CHOICES, IFACE_TYPE_VIRTUAL, IFACE_MODE_CHOICES from dcim.models import Interface from extras.api.customfields import CustomFieldModelSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer @@ -19,17 +20,19 @@ from .nested_serializers import * # class ClusterTypeSerializer(ValidatedModelSerializer): + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterType - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'cluster_count'] class ClusterGroupSerializer(ValidatedModelSerializer): + cluster_count = serializers.IntegerField(read_only=True) class Meta: model = ClusterGroup - fields = ['id', 'name', 'slug'] + fields = ['id', 'name', 'slug', 'cluster_count'] class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): @@ -37,11 +40,14 @@ class ClusterSerializer(TaggitSerializer, CustomFieldModelSerializer): group = NestedClusterGroupSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True) tags = TagListSerializerField(required=False) + device_count = serializers.IntegerField(read_only=True) + virtualmachine_count = serializers.IntegerField(read_only=True) class Meta: model = Cluster fields = [ 'id', 'name', 'type', 'group', 'site', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'device_count', 'virtualmachine_count', ] @@ -80,6 +86,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 'config_context', 'created', 'last_updated', ] + @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_config_context(self, obj): return obj.get_config_context() @@ -90,7 +97,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): virtual_machine = NestedVirtualMachineSerializer() - form_factor = ChoiceField(choices=IFACE_FF_CHOICES, default=IFACE_FF_VIRTUAL, required=False) + type = ChoiceField(choices=IFACE_TYPE_CHOICES, default=IFACE_TYPE_VIRTUAL, required=False) mode = ChoiceField(choices=IFACE_MODE_CHOICES, required=False, allow_null=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( @@ -104,6 +111,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): class Meta: model = Interface fields = [ - 'id', 'virtual_machine', 'name', 'form_factor', 'enabled', 'mtu', 'mac_address', 'description', 'mode', + 'id', 'virtual_machine', 'name', 'type', 'enabled', 'mtu', 'mac_address', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 3b0c02b22..552d5e93f 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,6 +1,9 @@ -from dcim.models import Interface +from django.db.models import Count + +from dcim.models import Device, Interface from extras.api.views import CustomFieldModelViewSet from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery from virtualization import filters from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine from . import serializers @@ -21,19 +24,30 @@ class VirtualizationFieldChoicesViewSet(FieldChoicesViewSet): # class ClusterTypeViewSet(ModelViewSet): - queryset = ClusterType.objects.all() + queryset = ClusterType.objects.annotate( + cluster_count=Count('clusters') + ) serializer_class = serializers.ClusterTypeSerializer filterset_class = filters.ClusterTypeFilter class ClusterGroupViewSet(ModelViewSet): - queryset = ClusterGroup.objects.all() + queryset = ClusterGroup.objects.annotate( + cluster_count=Count('clusters') + ) serializer_class = serializers.ClusterGroupSerializer filterset_class = filters.ClusterGroupFilter class ClusterViewSet(CustomFieldModelViewSet): - queryset = Cluster.objects.select_related('type', 'group').prefetch_related('tags') + queryset = Cluster.objects.select_related( + 'type', 'group', 'site', + ).prefetch_related( + 'tags' + ).annotate( + device_count=get_subquery(Device, 'cluster'), + virtualmachine_count=get_subquery(VirtualMachine, 'cluster') + ) serializer_class = serializers.ClusterSerializer filterset_class = filters.ClusterFilter @@ -50,16 +64,23 @@ class VirtualMachineViewSet(CustomFieldModelViewSet): def get_serializer_class(self): """ - Include rendered config context when retrieving a single VirtualMachine. + Select the specific serializer based on the request context. + + If the `brief` query param equates to True, return the NestedVirtualMachineSerializer + + If the `exclude` query param includes `config_context` as a value, return the VirtualMachineSerializer + + Else, return the VirtualMachineWithConfigContextSerializer """ - if self.action == 'retrieve': - return serializers.VirtualMachineWithConfigContextSerializer request = self.get_serializer_context()['request'] if request.query_params.get('brief', False): return serializers.NestedVirtualMachineSerializer - return serializers.VirtualMachineSerializer + elif 'config_context' in request.query_params.get('exclude', []): + return serializers.VirtualMachineSerializer + + return serializers.VirtualMachineWithConfigContextSerializer class InterfaceViewSet(ModelViewSet): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 0e5ff6cd2..9a6169815 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,12 +1,11 @@ import django_filters -from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -16,14 +15,14 @@ class ClusterTypeFilter(NameSlugSearchFilterSet): class Meta: model = ClusterType - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterGroupFilter(NameSlugSearchFilterSet): class Meta: model = ClusterGroup - fields = ['name', 'slug'] + fields = ['id', 'name', 'slug'] class ClusterFilter(CustomFieldFilterSet): @@ -80,7 +79,7 @@ class ClusterFilter(CustomFieldFilterSet): ) -class VirtualMachineFilter(CustomFieldFilterSet): +class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -151,16 +150,6 @@ class VirtualMachineFilter(CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -175,7 +164,7 @@ class VirtualMachineFilter(CustomFieldFilterSet): class Meta: model = VirtualMachine - fields = ['name', 'cluster'] + fields = ['id', 'name', 'cluster', 'vcpus', 'memory', 'disk'] def search(self, queryset, name, value): if not value.strip(): @@ -209,7 +198,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['name', 'enabled', 'mtu'] + fields = ['id', 'name', 'enabled', 'mtu'] def _mac_address(self, queryset, name, value): value = value.strip() diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 1e5f42160..6ea00ee3e 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -2,12 +2,13 @@ from django import forms from django.core.exceptions import ValidationError from taggit.forms import TagField -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL +from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_ACCESS, IFACE_MODE_TAGGED_ALL from dcim.forms import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Interface, Platform, Rack, Region, Site from extras.forms import AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldFilterForm from ipam.models import IPAddress from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, @@ -18,8 +19,8 @@ from utilities.forms import ( from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine -VIFACE_FF_CHOICES = ( - (IFACE_FF_VIRTUAL, 'Virtual'), +VIFACE_TYPE_CHOICES = ( + (IFACE_TYPE_VIRTUAL, 'Virtual'), ) @@ -336,8 +337,8 @@ class VirtualMachineForm(BootstrapMixin, TenancyForm, CustomFieldForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', - 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', + 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " @@ -520,8 +521,12 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB ] -class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualMachine + field_order = [ + 'q', 'cluster_group', 'cluster_type', 'cluster_id', 'status', 'role', 'region', 'site', 'tenant_group', + 'tenant', 'platform', + ] q = forms.CharField( required=False, label='Search' @@ -591,16 +596,6 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url='/api/tenancy/tenants/', - value_field="slug", - null_option=True, - ) - ) platform = FilterChoiceField( queryset=Platform.objects.all(), to_field_name='slug', @@ -625,12 +620,12 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface fields = [ - 'virtual_machine', 'name', 'form_factor', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', + 'virtual_machine', 'name', 'type', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan', 'tagged_vlans', ] widgets = { 'virtual_machine': forms.HiddenInput(), - 'form_factor': forms.HiddenInput(), + 'type': forms.HiddenInput(), 'mode': StaticSelect2() } labels = { @@ -661,9 +656,9 @@ class InterfaceCreateForm(ComponentForm): name_pattern = ExpandableNameField( label='Name' ) - form_factor = forms.ChoiceField( - choices=VIFACE_FF_CHOICES, - initial=IFACE_FF_VIRTUAL, + type = forms.ChoiceField( + choices=VIFACE_TYPE_CHOICES, + initial=IFACE_TYPE_VIRTUAL, widget=forms.HiddenInput() ) enabled = forms.BooleanField( @@ -737,9 +732,9 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): - form_factor = forms.ChoiceField( - choices=VIFACE_FF_CHOICES, - initial=IFACE_FF_VIRTUAL, + type = forms.ChoiceField( + choices=VIFACE_TYPE_CHOICES, + initial=IFACE_TYPE_VIRTUAL, widget=forms.HiddenInput() ) enabled = forms.BooleanField( diff --git a/netbox/virtualization/migrations/0009_custom_tag_models.py b/netbox/virtualization/migrations/0009_custom_tag_models.py new file mode 100644 index 000000000..04d589bee --- /dev/null +++ b/netbox/virtualization/migrations/0009_custom_tag_models.py @@ -0,0 +1,25 @@ +# Generated by Django 2.1.4 on 2019-02-20 06:56 + +from django.db import migrations +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0008_virtualmachine_local_context_data'), + ('extras', '0019_tag_taggeditem'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index ff9f39ee9..2ef782dfd 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -6,7 +6,7 @@ from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import Device -from extras.models import ConfigContextModel, CustomFieldModel +from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem from utilities.models import ChangeLoggedModel from .constants import DEVICE_STATUS_ACTIVE, VM_STATUS_CHOICES, VM_STATUS_CLASSES @@ -119,7 +119,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = ['name', 'type', 'group', 'site', 'comments'] @@ -238,7 +238,7 @@ class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): object_id_field='obj_id' ) - tags = TaggableManager() + tags = TaggableManager(through=TaggedItem) csv_headers = [ 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 328484a89..f1e372dd4 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,7 +2,7 @@ from django.urls import reverse from netaddr import IPNetwork from rest_framework import status -from dcim.constants import IFACE_FF_VIRTUAL, IFACE_MODE_TAGGED +from dcim.constants import IFACE_TYPE_VIRTUAL, IFACE_MODE_TAGGED from dcim.models import Interface from ipam.models import IPAddress, VLAN from utilities.testing import APITestCase @@ -40,7 +40,7 @@ class ClusterTypeTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['cluster_count', 'id', 'name', 'slug', 'url'] ) def test_create_clustertype(self): @@ -141,7 +141,7 @@ class ClusterGroupTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'slug', 'url'] + ['cluster_count', 'id', 'name', 'slug', 'url'] ) def test_create_clustergroup(self): @@ -245,7 +245,7 @@ class ClusterTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['id', 'name', 'url'] + ['id', 'name', 'url', 'virtualmachine_count'] ) def test_create_cluster(self): @@ -337,6 +337,14 @@ class VirtualMachineTest(APITestCase): self.virtualmachine1 = VirtualMachine.objects.create(name='Test Virtual Machine 1', cluster=self.cluster1) self.virtualmachine2 = VirtualMachine.objects.create(name='Test Virtual Machine 2', cluster=self.cluster1) self.virtualmachine3 = VirtualMachine.objects.create(name='Test Virtual Machine 3', cluster=self.cluster1) + self.virtualmachine_with_context_data = VirtualMachine.objects.create( + name='VM with context data', + cluster=self.cluster1, + local_context_data={ + 'A': 1, + 'B': 2 + } + ) def test_get_virtualmachine(self): @@ -350,7 +358,7 @@ class VirtualMachineTest(APITestCase): url = reverse('virtualization-api:virtualmachine-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 4) def test_list_virtualmachines_brief(self): @@ -373,7 +381,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VirtualMachine.objects.count(), 4) + self.assertEqual(VirtualMachine.objects.count(), 5) virtualmachine4 = VirtualMachine.objects.get(pk=response.data['id']) self.assertEqual(virtualmachine4.name, data['name']) self.assertEqual(virtualmachine4.cluster.pk, data['cluster']) @@ -388,7 +396,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) - self.assertEqual(VirtualMachine.objects.count(), 3) + self.assertEqual(VirtualMachine.objects.count(), 4) def test_create_virtualmachine_bulk(self): @@ -411,7 +419,7 @@ class VirtualMachineTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(VirtualMachine.objects.count(), 6) + self.assertEqual(VirtualMachine.objects.count(), 7) self.assertEqual(response.data[0]['name'], data[0]['name']) self.assertEqual(response.data[1]['name'], data[1]['name']) self.assertEqual(response.data[2]['name'], data[2]['name']) @@ -438,7 +446,7 @@ class VirtualMachineTest(APITestCase): response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(VirtualMachine.objects.count(), 3) + self.assertEqual(VirtualMachine.objects.count(), 4) virtualmachine1 = VirtualMachine.objects.get(pk=response.data['id']) self.assertEqual(virtualmachine1.name, data['name']) self.assertEqual(virtualmachine1.cluster.pk, data['cluster']) @@ -451,7 +459,22 @@ class VirtualMachineTest(APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(VirtualMachine.objects.count(), 2) + self.assertEqual(VirtualMachine.objects.count(), 3) + + def test_config_context_included_by_default_in_list_view(self): + + url = reverse('virtualization-api:virtualmachine-list') + url = '{}?id={}'.format(url, self.virtualmachine_with_context_data.pk) + response = self.client.get(url, **self.header) + + self.assertEqual(response.data['results'][0].get('config_context', {}).get('A'), 1) + + def test_config_context_excluded(self): + + url = reverse('virtualization-api:virtualmachine-list') + '?exclude=config_context' + response = self.client.get(url, **self.header) + + self.assertFalse('config_context' in response.data['results'][0]) class InterfaceTest(APITestCase): @@ -466,17 +489,17 @@ class InterfaceTest(APITestCase): self.interface1 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 1', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.interface2 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 2', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.interface3 = Interface.objects.create( virtual_machine=self.virtualmachine, name='Test Interface 3', - form_factor=IFACE_FF_VIRTUAL + type=IFACE_TYPE_VIRTUAL ) self.vlan1 = VLAN.objects.create(name="Test VLAN 1", vid=1) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 1b86e2015..b967eeefc 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -3,14 +3,16 @@ import urllib.parse from django.test import Client, TestCase from django.urls import reverse +from utilities.testing import create_test_user from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine class ClusterGroupTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_clustergroup']) self.client = Client() + self.client.force_login(user) ClusterGroup.objects.bulk_create([ ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'), @@ -29,8 +31,9 @@ class ClusterGroupTestCase(TestCase): class ClusterTypeTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_clustertype']) self.client = Client() + self.client.force_login(user) ClusterType.objects.bulk_create([ ClusterType(name='Cluster Type 1', slug='cluster-type-1'), @@ -49,8 +52,9 @@ class ClusterTypeTestCase(TestCase): class ClusterTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_cluster']) self.client = Client() + self.client.force_login(user) clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1') clustergroup.save() @@ -85,8 +89,9 @@ class ClusterTestCase(TestCase): class VirtualMachineTestCase(TestCase): def setUp(self): - + user = create_test_user(permissions=['virtualization.view_virtualmachine']) self.client = Client() + self.client.force_login(user) clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') clustertype.save() diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 5fc5997a8..7cc28be51 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from extras.views import ObjectChangeLogView from ipam.views import ServiceCreateView @@ -9,53 +9,53 @@ app_name = 'virtualization' urlpatterns = [ # Cluster types - url(r'^cluster-types/$', views.ClusterTypeListView.as_view(), name='clustertype_list'), - url(r'^cluster-types/add/$', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), - url(r'^cluster-types/import/$', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), - url(r'^cluster-types/delete/$', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), - url(r'^cluster-types/(?P[\w-]+)/edit/$', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), - url(r'^cluster-types/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), + path(r'cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), + path(r'cluster-types/add/', views.ClusterTypeCreateView.as_view(), name='clustertype_add'), + path(r'cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), + path(r'cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), + path(r'cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), + path(r'cluster-types//changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}), # Cluster groups - url(r'^cluster-groups/$', views.ClusterGroupListView.as_view(), name='clustergroup_list'), - url(r'^cluster-groups/add/$', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), - url(r'^cluster-groups/import/$', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), - url(r'^cluster-groups/delete/$', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), - url(r'^cluster-groups/(?P[\w-]+)/edit/$', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), - url(r'^cluster-groups/(?P[\w-]+)/changelog/$', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), + path(r'cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), + path(r'cluster-groups/add/', views.ClusterGroupCreateView.as_view(), name='clustergroup_add'), + path(r'cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), + path(r'cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), + path(r'cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), + path(r'cluster-groups//changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}), # Clusters - url(r'^clusters/$', views.ClusterListView.as_view(), name='cluster_list'), - url(r'^clusters/add/$', views.ClusterCreateView.as_view(), name='cluster_add'), - url(r'^clusters/import/$', views.ClusterBulkImportView.as_view(), name='cluster_import'), - url(r'^clusters/edit/$', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), - url(r'^clusters/delete/$', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), - url(r'^clusters/(?P\d+)/$', views.ClusterView.as_view(), name='cluster'), - url(r'^clusters/(?P\d+)/edit/$', views.ClusterEditView.as_view(), name='cluster_edit'), - url(r'^clusters/(?P\d+)/delete/$', views.ClusterDeleteView.as_view(), name='cluster_delete'), - url(r'^clusters/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), - url(r'^clusters/(?P\d+)/devices/add/$', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), - url(r'^clusters/(?P\d+)/devices/remove/$', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), + path(r'clusters/', views.ClusterListView.as_view(), name='cluster_list'), + path(r'clusters/add/', views.ClusterCreateView.as_view(), name='cluster_add'), + path(r'clusters/import/', views.ClusterBulkImportView.as_view(), name='cluster_import'), + path(r'clusters/edit/', views.ClusterBulkEditView.as_view(), name='cluster_bulk_edit'), + path(r'clusters/delete/', views.ClusterBulkDeleteView.as_view(), name='cluster_bulk_delete'), + path(r'clusters//', views.ClusterView.as_view(), name='cluster'), + path(r'clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), + path(r'clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), + path(r'clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), + path(r'clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), + path(r'clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), # Virtual machines - url(r'^virtual-machines/$', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), - url(r'^virtual-machines/add/$', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), - url(r'^virtual-machines/import/$', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), - url(r'^virtual-machines/edit/$', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), - url(r'^virtual-machines/delete/$', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), - url(r'^virtual-machines/(?P\d+)/$', views.VirtualMachineView.as_view(), name='virtualmachine'), - url(r'^virtual-machines/(?P\d+)/edit/$', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), - url(r'^virtual-machines/(?P\d+)/delete/$', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), - url(r'^virtual-machines/(?P\d+)/config-context/$', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), - url(r'^virtual-machines/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), - url(r'^virtual-machines/(?P\d+)/services/assign/$', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), + path(r'virtual-machines/', views.VirtualMachineListView.as_view(), name='virtualmachine_list'), + path(r'virtual-machines/add/', views.VirtualMachineCreateView.as_view(), name='virtualmachine_add'), + path(r'virtual-machines/import/', views.VirtualMachineBulkImportView.as_view(), name='virtualmachine_import'), + path(r'virtual-machines/edit/', views.VirtualMachineBulkEditView.as_view(), name='virtualmachine_bulk_edit'), + path(r'virtual-machines/delete/', views.VirtualMachineBulkDeleteView.as_view(), name='virtualmachine_bulk_delete'), + path(r'virtual-machines//', views.VirtualMachineView.as_view(), name='virtualmachine'), + path(r'virtual-machines//edit/', views.VirtualMachineEditView.as_view(), name='virtualmachine_edit'), + path(r'virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), + path(r'virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), + path(r'virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), + path(r'virtual-machines//services/assign/', ServiceCreateView.as_view(), name='virtualmachine_service_assign'), # VM interfaces - url(r'^virtual-machines/interfaces/add/$', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), - url(r'^virtual-machines/(?P\d+)/interfaces/add/$', views.InterfaceCreateView.as_view(), name='interface_add'), - url(r'^virtual-machines/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - url(r'^virtual-machines/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^vm-interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), - url(r'^vm-interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), + path(r'virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_interface'), + path(r'virtual-machines//interfaces/add/', views.InterfaceCreateView.as_view(), name='interface_add'), + path(r'virtual-machines//interfaces/edit/', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + path(r'virtual-machines//interfaces/delete/', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + path(r'vm-interfaces//edit/', views.InterfaceEditView.as_view(), name='interface_edit'), + path(r'vm-interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), ] diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index aa8a585a9..712c7acb5 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -22,7 +22,8 @@ from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine # Cluster types # -class ClusterTypeListView(ObjectListView): +class ClusterTypeListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_clustertype' queryset = ClusterType.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterTypeTable template_name = 'virtualization/clustertype_list.html' @@ -57,7 +58,8 @@ class ClusterTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Cluster groups # -class ClusterGroupListView(ObjectListView): +class ClusterGroupListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_clustergroup' queryset = ClusterGroup.objects.annotate(cluster_count=Count('clusters')) table = tables.ClusterGroupTable template_name = 'virtualization/clustergroup_list.html' @@ -92,7 +94,8 @@ class ClusterGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Clusters # -class ClusterListView(ObjectListView): +class ClusterListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_cluster' queryset = Cluster.objects.select_related('type', 'group', 'site') table = tables.ClusterTable filter = filters.ClusterFilter @@ -100,7 +103,8 @@ class ClusterListView(ObjectListView): template_name = 'virtualization/cluster_list.html' -class ClusterView(View): +class ClusterView(PermissionRequiredMixin, View): + permission_required = 'virtualization.view_cluster' def get(self, request, pk): @@ -247,7 +251,8 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View): # Virtual machines # -class VirtualMachineListView(ObjectListView): +class VirtualMachineListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'virtualization.view_virtualmachine' queryset = VirtualMachine.objects.select_related('cluster', 'tenant', 'role', 'primary_ip4', 'primary_ip6') filter = filters.VirtualMachineFilter filter_form = forms.VirtualMachineFilterForm @@ -255,7 +260,8 @@ class VirtualMachineListView(ObjectListView): template_name = 'virtualization/virtualmachine_list.html' -class VirtualMachineView(View): +class VirtualMachineView(PermissionRequiredMixin, View): + permission_required = 'virtualization.view_virtualmachine' def get(self, request, pk): @@ -270,7 +276,8 @@ class VirtualMachineView(View): }) -class VirtualMachineConfigContextView(ObjectConfigContextView): +class VirtualMachineConfigContextView(PermissionRequiredMixin, ObjectConfigContextView): + permission_required = 'virtualization.view_virtualmachine' object_class = VirtualMachine base_template = 'virtualization/virtualmachine.html' diff --git a/requirements.txt b/requirements.txt index 0bc96db8d..3ad165a4b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,19 +1,22 @@ Django>=2.2,<2.3 -django-cors-headers==2.4.0 -django-debug-toolbar==1.11 -django-filter==2.0.0 +django-cacheops==4.1 +django-cors-headers==3.0.2 +django-debug-toolbar==2.0 +django-filter==2.1.0 django-mptt==0.9.1 -django-tables2==2.0.3 -django-taggit==0.23.0 +django-prometheus==1.0.15 +django-rq==2.1.0 +django-tables2==2.0.6 +django-taggit==1.1.0 django-taggit-serializer==0.1.7 django-timezone-field==3.0 -djangorestframework==3.9.0 -drf-yasg[validation]==1.14.0 +djangorestframework==3.9.4 +drf-yasg[validation]==1.16.0 graphviz==0.10.1 -Jinja2==2.10 +Jinja2==2.10.1 Markdown==2.6.11 netaddr==0.7.19 -Pillow==5.3.0 -psycopg2-binary==2.7.6.1 +Pillow==6.0.0 +psycopg2-binary==2.8.3 py-gfm==0.1.4 -pycryptodome==3.7.2 +pycryptodome==3.8.2 diff --git a/upgrade.sh b/upgrade.sh index 24e79f5bd..793e72cda 100755 --- a/upgrade.sh +++ b/upgrade.sh @@ -5,15 +5,11 @@ # Once the script completes, remember to restart the WSGI service (e.g. # gunicorn or uWSGI). +cd "$(dirname "$0")" + PYTHON="python3" PIP="pip3" -# TODO: Remove this in v2.6 as it is no longer needed under Python 3 -# Delete stale bytecode -COMMAND="find . -name \"*.pyc\" -delete" -echo "Cleaning up stale Python bytecode ($COMMAND)..." -eval $COMMAND - # Uninstall any Python packages which are no longer needed COMMAND="${PIP} uninstall -r old_requirements.txt -y" echo "Removing old Python packages ($COMMAND)..."