diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 082c6ba7a..3844b6753 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.9 + placeholder: v3.3.0 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3fcb4a7c3..3c7638f42 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.2.9 + placeholder: v3.3.0 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index 672ce402c..363f97b31 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -34,10 +34,14 @@ django-pglocks # https://github.com/korfuri/django-prometheus django-prometheus -# Django chaching backend using Redis +# Django caching backend using Redis # https://github.com/jazzband/django-redis django-redis +# Django extensions for Rich (terminal text rendering) +# https://github.com/adamchainz/django-rich +django-rich + # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq django-rq @@ -48,8 +52,7 @@ django-tables2 # User-defined tags for objects # https://github.com/alex/django-taggit -# Will evaluate v3.0 during NetBox v3.3 beta -django-taggit>=2.1.0,<3.0 +django-taggit # A Django field for representing time zones # https://github.com/mfogel/django-timezone-field/ diff --git a/docs/_theme/main.html b/docs/_theme/main.html new file mode 100644 index 000000000..4dfc4e14e --- /dev/null +++ b/docs/_theme/main.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block site_meta %} + {{ super() }} + {# Disable search indexing unless we're building for ReadTheDocs #} + {% if not config.extra.readthedocs %} + + {% endif %} +{% endblock %} diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md deleted file mode 100644 index 5077f3a68..000000000 --- a/docs/additional-features/webhooks.md +++ /dev/null @@ -1,57 +0,0 @@ -{!models/extras/webhook.md!} - -## Conditional Webhooks - -A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": - -```json -{ - "and": [ - { - "attr": "status.value", - "value": "active" - } - ] -} -``` - -For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). - -## Webhook Processing - -When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks. - -A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI. - -## Troubleshooting - -To assist with verifying that the content of outgoing webhooks is rendered correctly, NetBox provides a simple HTTP listener that can be run locally to receive and display webhook requests. First, modify the target URL of the desired webhook to `http://localhost:9000/`. This will instruct NetBox to send the request to the local server on TCP port 9000. Then, start the webhook receiver service from the NetBox root directory: - -```no-highlight -$ python netbox/manage.py webhook_receiver -Listening on port http://localhost:9000. Stop with CONTROL-C. -``` - -You can test the receiver itself by sending any HTTP request to it. For example: - -```no-highlight -$ curl -X POST http://localhost:9000 --data '{"foo": "bar"}' -``` - -The server will print output similar to the following: - -```no-highlight -[1] Tue, 07 Apr 2020 17:44:02 GMT 127.0.0.1 "POST / HTTP/1.1" 200 - -Host: localhost:9000 -User-Agent: curl/7.58.0 -Accept: */* -Content-Length: 14 -Content-Type: application/x-www-form-urlencoded - -{"foo": "bar"} ------------- -``` - -Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. - -Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI). diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index 1989e41c0..da1a5443b 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -3,8 +3,8 @@ NetBox includes a `housekeeping` management command that should be run nightly. This command handles: * Clearing expired authentication sessions from the database -* Deleting changelog records older than the configured [retention time](../configuration/dynamic-settings.md#changelog_retention) -* Deleting job result records older than the configured [retention time](../configuration/dynamic-settings.md#jobresult_retention) +* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention) +* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#jobresult_retention) This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. diff --git a/docs/administration/permissions.md b/docs/administration/permissions.md index f859266af..21f259979 100644 --- a/docs/administration/permissions.md +++ b/docs/administration/permissions.md @@ -1,10 +1,77 @@ -# Permissions +# Object-Based Permissions NetBox v2.9 introduced a new object-based permissions framework, which replaces Django's built-in permissions model. Object-based permissions enable an administrator to grant users or groups the ability to perform an action on arbitrary subsets of objects in NetBox, rather than all objects of a certain type. For example, it is possible to grant a user permission to view only sites within a particular region, or to modify only VLANs with a numeric ID within a certain range. -{!models/users/objectpermission.md!} +A permission in NetBox represents a relationship shared by several components: -### Example Constraint Definitions +* Object type(s) - One or more types of object in NetBox +* User(s)/Group(s) - One or more users or groups of users +* Action(s) - The action(s) that can be performed on an object +* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects + +At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). + +## Actions + +There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): + +* **View** - Retrieve an object from the database +* **Add** - Create a new object +* **Change** - Modify an existing object +* **Delete** - Delete an existing object + +In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. + +!!! note + Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`. + +## Constraints + +Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. + +All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. + +```json +{ + "status": "active", + "region__name": "Americas" +} +``` + +The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. + +To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following: + +```json +[ + { + "vid__gte": 100, + "vid__lt": 200 + }, + { + "status": "reserved" + } +] +``` + +Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. + +### User Token + +!!! info "This feature was introduced in NetBox v3.3" + +When defining a permission constraint, administrators may use the special token `$user` to reference the current user at the time of evaluation. This can be helpful to restrict users to editing only their own journal entries, for example. Such a constraint might be defined as: + +```json +{ + "created_by": "$user" +} +``` + +The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes. + + +#### Example Constraint Definitions | Constraints | Description | | ----------- | ----------- | diff --git a/docs/configuration/data-validation.md b/docs/configuration/data-validation.md new file mode 100644 index 000000000..e4eb4baff --- /dev/null +++ b/docs/configuration/data-validation.md @@ -0,0 +1,86 @@ +# Data & Validation Parameters + +## CUSTOM_VALIDATORS + +!!! tip "Dynamic Configuration Parameter" + +This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: + +```python +CUSTOM_VALIDATORS = { + "dcim.site": [ + { + "name": { + "min_length": 5, + "max_length": 30 + } + }, + "my_plugin.validators.Validator1" + ], + "dim.device": [ + "my_plugin.validators.Validator1" + ] +} +``` + +--- + +## FIELD_CHOICES + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) + +The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). + +For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ... + ) +} +``` + +The following model fields support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.Location.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `extras.JournalEntry.kind` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.Cluster.status` +* `virtualization.VirtualMachine.status` + +The following colors are supported: + +* `blue` +* `indigo` +* `purple` +* `pink` +* `red` +* `orange` +* `yellow` +* `green` +* `teal` +* `cyan` +* `gray` +* `black` +* `white` diff --git a/docs/configuration/date-time.md b/docs/configuration/date-time.md new file mode 100644 index 000000000..ab8b5ad13 --- /dev/null +++ b/docs/configuration/date-time.md @@ -0,0 +1,20 @@ +# Date & Time Parameters + +## TIME_ZONE + +Default: UTC + +The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). + +## Date and Time Formatting + +You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. + +```python +DATE_FORMAT = 'N j, Y' # June 26, 2016 +SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 +TIME_FORMAT = 'g:i a' # 1:23 p.m. +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-26 13:23 +``` diff --git a/docs/configuration/default-values.md b/docs/configuration/default-values.md new file mode 100644 index 000000000..6d92858eb --- /dev/null +++ b/docs/configuration/default-values.md @@ -0,0 +1,77 @@ +# Default Value Parameters + +## DEFAULT_USER_PREFERENCES + +!!! tip "Dynamic Configuration Parameter" + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + +## PAGINATE_COUNT + +!!! tip "Dynamic Configuration Parameter" + +Default: 50 + +The default maximum number of objects to display per page within each list of objects. + +--- + +## POWERFEED_DEFAULT_AMPERAGE + +!!! tip "Dynamic Configuration Parameter" + +Default: 15 + +The default value for the `amperage` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_MAX_UTILIZATION + +!!! tip "Dynamic Configuration Parameter" + +Default: 80 + +The default value (percentage) for the `max_utilization` field when creating new power feeds. + +--- + +## POWERFEED_DEFAULT_VOLTAGE + +!!! tip "Dynamic Configuration Parameter" + +Default: 120 + +The default value for the `voltage` field when creating new power feeds. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + +!!! tip "Dynamic Configuration Parameter" + +Default: 22 + +Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. + +--- + +## RACK_ELEVATION_DEFAULT_UNIT_WIDTH + +!!! tip "Dynamic Configuration Parameter" + +Default: 220 + +Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/development.md b/docs/configuration/development.md new file mode 100644 index 000000000..3af56b0e3 --- /dev/null +++ b/docs/configuration/development.md @@ -0,0 +1,21 @@ +# Development Parameters + +## DEBUG + +Default: False + +This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only +clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user +interface. + +!!! warning + Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a + substantial performance penalty. + +--- + +## DEVELOPER + +Default: False + +This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md deleted file mode 100644 index d376dc5c4..000000000 --- a/docs/configuration/dynamic-settings.md +++ /dev/null @@ -1,232 +0,0 @@ -# Dynamic Configuration Settings - -These configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These setting may also be overridden in `configuration.py`; this will prevent them from being modified via the UI. - ---- - -## ALLOWED_URL_SCHEMES - -Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` - -A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all of the default values as well (excluding those schemes which are not desirable). - ---- - -## BANNER_TOP - -## BANNER_BOTTOM - -Setting these variables will display custom content in a banner at the top and/or bottom of the page, respectively. HTML is allowed. To replicate the content of the top banner in the bottom banner, set: - -```python -BANNER_TOP = 'Your banner text' -BANNER_BOTTOM = BANNER_TOP -``` - ---- - -## BANNER_LOGIN - -This defines custom content to be displayed on the login page above the login form. HTML is allowed. - ---- - -## CHANGELOG_RETENTION - -Default: 90 - -The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain -changes in the database indefinitely. - -!!! warning - If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - -## CUSTOM_VALIDATORS - -This is a mapping of models to [custom validators](../customization/custom-validation.md) that have been defined locally to enforce custom validation logic. An example is provided below: - -```python -CUSTOM_VALIDATORS = { - "dcim.site": [ - { - "name": { - "min_length": 5, - "max_length": 30 - } - }, - "my_plugin.validators.Validator1" - ], - "dim.device": [ - "my_plugin.validators.Validator1" - ] -} -``` - ---- - -## DEFAULT_USER_PREFERENCES - -This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: - -```python -DEFAULT_USER_PREFERENCES = { - "pagination": { - "per_page": 100 - } -} -``` - -For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. - ---- - -## ENFORCE_GLOBAL_UNIQUE - -Default: False - -By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. - ---- - -## GRAPHQL_ENABLED - -Default: True - -Setting this to False will disable the GraphQL API. - ---- - -## JOBRESULT_RETENTION - -Default: 90 - -The number of days to retain job results (scripts and reports). Set this to `0` to retain -job results in the database indefinitely. - -!!! warning - If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. - ---- - -## MAINTENANCE_MODE - -Default: False - -Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. - ---- - -## MAPS_URL - -Default: `https://maps.google.com/?q=` (Google Maps) - -This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. - ---- - -## MAX_PAGE_SIZE - -Default: 1000 - -A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. - ---- - -## NAPALM_USERNAME - -## NAPALM_PASSWORD - -NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../additional-features/napalm.md), if installed. Both parameters are optional. - -!!! note - If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. - ---- - -## NAPALM_ARGS - -A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: - -```python -NAPALM_ARGS = { - 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', - 'port': 2222, -} -``` - -Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: - -```python -NAPALM_USERNAME = 'username' -NAPALM_PASSWORD = 'MySecretPassword' -NAPALM_ARGS = { - 'secret': NAPALM_PASSWORD, - # Include any additional args here -} -``` - ---- - -## NAPALM_TIMEOUT - -Default: 30 seconds - -The amount of time (in seconds) to wait for NAPALM to connect to a device. - ---- - -## PAGINATE_COUNT - -Default: 50 - -The default maximum number of objects to display per page within each list of objects. - ---- - -## POWERFEED_DEFAULT_AMPERAGE - -Default: 15 - -The default value for the `amperage` field when creating new power feeds. - ---- - -## POWERFEED_DEFAULT_MAX_UTILIZATION - -Default: 80 - -The default value (percentage) for the `max_utilization` field when creating new power feeds. - ---- - -## POWERFEED_DEFAULT_VOLTAGE - -Default: 120 - -The default value for the `voltage` field when creating new power feeds. - ---- - -## PREFER_IPV4 - -Default: False - -When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - -Default: 22 - -Default height (in pixels) of a unit within a rack elevation. For best results, this should be approximately one tenth of `RACK_ELEVATION_DEFAULT_UNIT_WIDTH`. - ---- - -## RACK_ELEVATION_DEFAULT_UNIT_WIDTH - -Default: 220 - -Default width (in pixels) of a unit within a rack elevation. diff --git a/docs/configuration/index.md b/docs/configuration/index.md index a863ef3dc..42d254027 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,24 +1,50 @@ # NetBox Configuration -NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided as `configuration_example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. +## Configuration File + +NetBox's configuration file contains all the important parameters which control how NetBox functions: database settings, security controls, user preferences, and so on. While the default configuration suffices out of the box for most use cases, there are a few [required parameters](./required-parameters.md) which **must** be defined during installation. + +The configuration file is loaded from `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided at `configuration_example.py`, which you may copy to use as your default config. Note that a configuration file must be defined; NetBox will not run without one. !!! info "Customizing the Configuration Module" A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`. - For the sake of brevity, the NetBox documentation refers to the configuration file simply as `configuration.py`. + To keep things simple, the NetBox documentation refers to the configuration file simply as `configuration.py`. Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI. -## Configuration Parameters +## Dynamic Configuration Parameters -* [Required settings](required-settings.md) -* [Optional settings](optional-settings.md) -* [Dynamic settings](dynamic-settings.md) -* [Remote authentication settings](remote-authentication.md) +Some configuration parameters are primarily controlled via NetBox's admin interface (under Admin > Extras > Configuration Revisions). These are noted where applicable in the documentation. These settings may also be overridden in `configuration.py` to prevent them from being modified via the UI. A complete list of supported parameters is provided below: -## Changing the Configuration +* [`ALLOWED_URL_SCHEMES`](./security.md#allowed_url_schemes) +* [`BANNER_BOTTOM`](./miscellaneous.md#banner_bottom) +* [`BANNER_LOGIN`](./miscellaneous.md#banner_login) +* [`BANNER_TOP`](./miscellaneous.md#banner_top) +* [`CHANGELOG_RETENTION`](./miscellaneous.md#changelog_retention) +* [`CUSTOM_VALIDATORS`](./data-validation.md#custom_validators) +* [`DEFAULT_USER_PREFERENCES`](./default-values.md#default_user_preferences) +* [`ENFORCE_GLOBAL_UNIQUE`](./miscellaneous.md#enforce_global_unique) +* [`GRAPHQL_ENABLED`](./miscellaneous.md#graphql_enabled) +* [`JOBRESULT_RETENTION`](./miscellaneous.md#jobresult_retention) +* [`MAINTENANCE_MODE`](./miscellaneous.md#maintenance_mode) +* [`MAPS_URL`](./miscellaneous.md#maps_url) +* [`MAX_PAGE_SIZE`](./miscellaneous.md#max_page_size) +* [`NAPALM_ARGS`](./napalm.md#napalm_args) +* [`NAPALM_PASSWORD`](./napalm.md#napalm_password) +* [`NAPALM_TIMEOUT`](./napalm.md#napalm_timeout) +* [`NAPALM_USERNAME`](./napalm.md#napalm_username) +* [`PAGINATE_COUNT`](./default-values.md#paginate_count) +* [`POWERFEED_DEFAULT_AMPERAGE`](./default-values.md#powerfeed_default_amperage) +* [`POWERFEED_DEFAULT_MAX_UTILIZATION`](./default-values.md#powerfeed_default_max_utilization) +* [`POWERFEED_DEFAULT_VOLTAGE`](./default-values.md#powerfeed_default_voltage) +* [`PREFER_IPV4`](./miscellaneous.md#prefer_ipv4) +* [`RACK_ELEVATION_DEFAULT_UNIT_HEIGHT`](./default-values.md#rack_elevation_default_unit_height) +* [`RACK_ELEVATION_DEFAULT_UNIT_WIDTH`](./default-values.md#rack_elevation_default_unit_width) -The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before the changes will take effect: +## Modifying the Configuration + +The configuration file may be modified at any time. However, the WSGI service (e.g. Gunicorn) must be restarted before these changes will take effect: ```no-highlight $ sudo systemctl restart netbox diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md new file mode 100644 index 000000000..614e90eac --- /dev/null +++ b/docs/configuration/miscellaneous.md @@ -0,0 +1,159 @@ +# Miscellaneous Parameters + +## ADMINS + +NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example: + +```python +ADMINS = [ + ['Hank Hill', 'hhill@example.com'], + ['Dale Gribble', 'dgribble@example.com'], +] +``` + +--- + +## BANNER_BOTTOM + +!!! tip "Dynamic Configuration Parameter" + +Sets content for the bottom banner in the user interface. + +--- + +## BANNER_LOGIN + +!!! tip "Dynamic Configuration Parameter" + +This defines custom content to be displayed on the login page above the login form. HTML is allowed. + +--- + +## BANNER_TOP + +!!! tip "Dynamic Configuration Parameter" + +Sets content for the top banner in the user interface. + +!!! tip + If you'd like the top and bottom banners to match, set the following: + + ```python + BANNER_TOP = 'Your banner text' + BANNER_BOTTOM = BANNER_TOP + ``` + +--- + +## CHANGELOG_RETENTION + +!!! tip "Dynamic Configuration Parameter" + +Default: 90 + +The number of days to retain logged changes (object creations, updates, and deletions). Set this to `0` to retain +changes in the database indefinitely. + +!!! warning + If enabling indefinite changelog retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + +## ENFORCE_GLOBAL_UNIQUE + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +By default, NetBox will permit users to create duplicate prefixes and IP addresses in the global table (that is, those which are not assigned to any VRF). This behavior can be disabled by setting `ENFORCE_GLOBAL_UNIQUE` to True. + +--- + +## GRAPHQL_ENABLED + +!!! tip "Dynamic Configuration Parameter" + +Default: True + +Setting this to False will disable the GraphQL API. + +--- + +## JOBRESULT_RETENTION + +!!! tip "Dynamic Configuration Parameter" + +Default: 90 + +The number of days to retain job results (scripts and reports). Set this to `0` to retain +job results in the database indefinitely. + +!!! warning + If enabling indefinite job results retention, it is recommended to periodically delete old entries. Otherwise, the database may eventually exceed capacity. + +--- + +## MAINTENANCE_MODE + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +Setting this to True will display a "maintenance mode" banner at the top of every page. Additionally, NetBox will no longer update a user's "last active" time upon login. This is to allow new logins when the database is in a read-only state. Recording of login times will resume when maintenance mode is disabled. + +--- + +## MAPS_URL + +!!! tip "Dynamic Configuration Parameter" + +Default: `https://maps.google.com/?q=` (Google Maps) + +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. + +--- + +## MAX_PAGE_SIZE + +!!! tip "Dynamic Configuration Parameter" + +Default: 1000 + +A web user or API consumer can request an arbitrary number of objects by appending the "limit" parameter to the URL (e.g. `?limit=1000`). This parameter defines the maximum acceptable limit. Setting this to `0` or `None` will allow a client to retrieve _all_ matching objects at once with no limit by specifying `?limit=0`. + +--- + +## METRICS_ENABLED + +Default: False + +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../integrations/prometheus-metrics.md) documentation for more details. + +--- + +## PREFER_IPV4 + +!!! tip "Dynamic Configuration Parameter" + +Default: False + +When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to prefer IPv4 instead. + +--- + +## RELEASE_CHECK_URL + +Default: None (disabled) + +This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks. + +!!! note + The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest). + +--- + +## RQ_DEFAULT_TIMEOUT + +Default: `300` + +The maximum execution time of a background task (such as running a custom script), in seconds. diff --git a/docs/configuration/napalm.md b/docs/configuration/napalm.md new file mode 100644 index 000000000..253bea297 --- /dev/null +++ b/docs/configuration/napalm.md @@ -0,0 +1,51 @@ +# NAPALM Parameters + +## NAPALM_USERNAME + +## NAPALM_PASSWORD + +!!! tip "Dynamic Configuration Parameter" + +NetBox will use these credentials when authenticating to remote devices via the supported [NAPALM integration](../integrations/napalm.md), if installed. Both parameters are optional. + +!!! note + If SSH public key authentication has been set up on the remote device(s) for the system account under which NetBox runs, these parameters are not needed. + +--- + +## NAPALM_ARGS + +!!! tip "Dynamic Configuration Parameter" + +A dictionary of optional arguments to pass to NAPALM when instantiating a network driver. See the NAPALM documentation for a [complete list of optional arguments](https://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: + +```python +NAPALM_ARGS = { + 'api_key': '472071a93b60a1bd1fafb401d9f8ef41', + 'port': 2222, +} +``` + +Some platforms (e.g. Cisco IOS) require an argument named `secret` to be passed in addition to the normal password. If desired, you can use the configured `NAPALM_PASSWORD` as the value for this argument: + +```python +NAPALM_USERNAME = 'username' +NAPALM_PASSWORD = 'MySecretPassword' +NAPALM_ARGS = { + 'secret': NAPALM_PASSWORD, + # Include any additional args here +} +``` + +--- + +## NAPALM_TIMEOUT + +!!! tip "Dynamic Configuration Parameter" + +Default: 30 seconds + +The amount of time (in seconds) to wait for NAPALM to connect to a device. + +--- + diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md deleted file mode 100644 index 3b1c848a7..000000000 --- a/docs/configuration/optional-settings.md +++ /dev/null @@ -1,487 +0,0 @@ -# Optional Configuration Settings - -## ADMINS - -NetBox will email details about critical errors to the administrators listed here. This should be a list of (name, email) tuples. For example: - -```python -ADMINS = [ - ['Hank Hill', 'hhill@example.com'], - ['Dale Gribble', 'dgribble@example.com'], -] -``` - ---- - -## AUTH_PASSWORD_VALIDATORS - -This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). - -```python -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - 'OPTIONS': { - 'min_length': 10, - } - }, -] -``` - ---- - -## BASE_PATH - -Default: None - -The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set: - -```python -BASE_PATH = 'netbox/' -``` - ---- - -## CORS_ORIGIN_ALLOW_ALL - -Default: False - -If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). - ---- - -## CORS_ORIGIN_WHITELIST - -## 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.) For example: - -```python -CORS_ORIGIN_WHITELIST = [ - 'https://example.com', -] -``` - ---- - -## CSRF_COOKIE_NAME - -Default: `csrftoken` - -The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. - ---- - -## CSRF_TRUSTED_ORIGINS - -Default: `[]` - -Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). - -```python -CSRF_TRUSTED_ORIGINS = ( - 'http://netbox.local', - 'https://netbox.local', -) -``` - ---- - -## DEBUG - -Default: False - -This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only -clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user -interface. - -!!! warning - Never enable debugging on a production system, as it can expose sensitive data to unauthenticated users and impose a - substantial performance penalty. - ---- - -## DEVELOPER - -Default: False - -This parameter serves as a safeguard to prevent some potentially dangerous behavior, such as generating new database schema migrations. Set this to `True` **only** if you are actively developing the NetBox code base. - ---- - -## DOCS_ROOT - -Default: `$INSTALL_ROOT/docs/` - -The filesystem path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. By default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.) - ---- - -## EMAIL - -In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: - -* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) -* `PORT` - TCP port to use for the connection (default: `25`) -* `USERNAME` - Username with which to authenticate -* `PASSSWORD` - Password with which to authenticate -* `USE_SSL` - Use SSL when connecting to the server (default: `False`) -* `USE_TLS` - Use TLS when connecting to the server (default: `False`) -* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) -* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional) -* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`) -* `FROM_EMAIL` - Sender address for emails sent by NetBox - -!!! note - The `USE_SSL` and `USE_TLS` parameters are mutually exclusive. - -Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell: - -```no-highlight -# python ./manage.py nbshell ->>> from django.core.mail import send_mail ->>> send_mail( - 'Test Email Subject', - 'Test Email Body', - 'noreply-netbox@example.com', - ['users@example.com'], - fail_silently=False -) -``` - ---- - -## EXEMPT_VIEW_PERMISSIONS - -Default: Empty list - -A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. - -List models in the form `.`. For example: - -```python -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.) - -```python -EXEMPT_VIEW_PERMISSIONS = ['*'] -``` - -!!! note - Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually. - ---- - -## FIELD_CHOICES - -Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) - -The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). - -For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: - -```python -FIELD_CHOICES = { - 'dcim.Site.status': ( - ('foo', 'Foo', 'red'), - ('bar', 'Bar', 'green'), - ('baz', 'Baz', 'blue'), - ) -} -``` - -Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: - -```python -FIELD_CHOICES = { - 'dcim.Site.status+': ( - ... - ) -} -``` - -The following model fields support configurable choices: - -* `circuits.Circuit.status` -* `dcim.Device.status` -* `dcim.PowerFeed.status` -* `dcim.Rack.status` -* `dcim.Site.status` -* `extras.JournalEntry.kind` -* `ipam.IPAddress.status` -* `ipam.IPRange.status` -* `ipam.Prefix.status` -* `ipam.VLAN.status` -* `virtualization.VirtualMachine.status` - -The following colors are supported: - -* `blue` -* `indigo` -* `purple` -* `pink` -* `red` -* `orange` -* `yellow` -* `green` -* `teal` -* `cyan` -* `gray` -* `black` -* `white` - ---- - -## HTTP_PROXIES - -Default: None - -A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: - -```python -HTTP_PROXIES = { - 'http': 'http://10.10.1.10:3128', - 'https': 'http://10.10.1.10:1080', -} -``` - ---- - -## JINJA2_FILTERS - -Default: `{}` - -A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: - -```python -def uppercase(x): - return str(x).upper() - -JINJA2_FILTERS = { - 'uppercase': uppercase, -} -``` - ---- - -## INTERNAL_IPS - -Default: `('127.0.0.1', '::1')` - -A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For -example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP -addresses (and [`DEBUG`](#debug) is true). - ---- - -## LOGGING - -By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins). - -The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file: - -```python -LOGGING = { - 'version': 1, - 'disable_existing_loggers': False, - 'handlers': { - 'file': { - 'level': 'INFO', - 'class': 'logging.FileHandler', - 'filename': '/var/log/netbox.log', - }, - }, - 'loggers': { - 'django': { - 'handlers': ['file'], - 'level': 'INFO', - }, - }, -} -``` - -### Available Loggers - -* `netbox..` - Generic form for model-specific log messages -* `netbox.auth.*` - Authentication events -* `netbox.api.views.*` - Views which handle business logic for the REST API -* `netbox.reports.*` - Report execution (`module.name`) -* `netbox.scripts.*` - Custom script execution (`module.name`) -* `netbox.views.*` - Views which handle business logic for the web UI - ---- - -## LOGIN_PERSISTENCE - -Default: False - -If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. - -Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. - ---- - -## LOGIN_REQUIRED - -Default: False - -Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. - ---- - -## LOGIN_TIMEOUT - -Default: 1209600 seconds (14 days) - -The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. - ---- - -## MEDIA_ROOT - -Default: $INSTALL_ROOT/netbox/media/ - -The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. - ---- - -## METRICS_ENABLED - -Default: False - -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. - ---- - -## PLUGINS - -Default: Empty - -A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. - -!!! warning - Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. - ---- - -## PLUGINS_CONFIG - -Default: Empty - -This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: - -```python -PLUGINS_CONFIG = { - 'plugin1': { - 'foo': 123, - 'bar': True - }, - 'plugin2': { - 'foo': 456, - }, -} -``` - -Note that a plugin must be listed in `PLUGINS` for its configuration to take effect. - ---- - -## RELEASE_CHECK_URL - -Default: None (disabled) - -This parameter defines the URL of the repository that will be checked for new NetBox releases. When a new release is detected, a message will be displayed to administrative users on the home page. This can be set to the official repository (`'https://api.github.com/repos/netbox-community/netbox/releases'`) or a custom fork. Set this to `None` to disable automatic update checks. - -!!! note - The URL provided **must** be compatible with the [GitHub REST API](https://docs.github.com/en/rest). - ---- - -## REPORTS_ROOT - -Default: `$INSTALL_ROOT/netbox/reports/` - -The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. - ---- - -## RQ_DEFAULT_TIMEOUT - -Default: `300` - -The maximum execution time of a background task (such as running a custom script), in seconds. - ---- - -## SCRIPTS_ROOT - -Default: `$INSTALL_ROOT/netbox/scripts/` - -The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. - ---- - -## SESSION_COOKIE_NAME - -Default: `sessionid` - -The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. - ---- - -## SESSION_FILE_PATH - -Default: None - -HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path. - ---- - -## STORAGE_BACKEND - -Default: None (local storage) - -The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used. - -The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. - ---- - -## STORAGE_CONFIG - -Default: Empty - -A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail. - -If `STORAGE_BACKEND` is not defined, this setting will be ignored. - ---- - -## TIME_ZONE - -Default: UTC - -The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones). - ---- - -## Date and Time Formatting - -You may define custom formatting for date and times. For detailed instructions on writing format strings, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#date). Default formats are listed below. - -```python -DATE_FORMAT = 'N j, Y' # June 26, 2016 -SHORT_DATE_FORMAT = 'Y-m-d' # 2016-06-26 -TIME_FORMAT = 'g:i a' # 1:23 p.m. -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-26 13:23 -``` diff --git a/docs/configuration/plugins.md b/docs/configuration/plugins.md new file mode 100644 index 000000000..aea60f389 --- /dev/null +++ b/docs/configuration/plugins.md @@ -0,0 +1,35 @@ +# Plugin Parameters + +## PLUGINS + +Default: Empty + +A list of installed [NetBox plugins](../../plugins/) to enable. Plugins will not take effect unless they are listed here. + +!!! warning + Plugins extend NetBox by allowing external code to run with the same access and privileges as NetBox itself. Only install plugins from trusted sources. The NetBox maintainers make absolutely no guarantees about the integrity or security of your installation with plugins enabled. + +--- + +## PLUGINS_CONFIG + +Default: Empty + +This parameter holds configuration settings for individual NetBox plugins. It is defined as a dictionary, with each key using the name of an installed plugin. The specific parameters supported are unique to each plugin: Reference the plugin's documentation to determine the supported parameters. An example configuration is shown below: + +```python +PLUGINS_CONFIG = { + 'plugin1': { + 'foo': 123, + 'bar': True + }, + 'plugin2': { + 'foo': 456, + }, +} +``` + +Note that a plugin must be listed in `PLUGINS` for its configuration to take effect. + +--- + diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index 2c3a7002f..07adf5c6a 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -47,6 +47,22 @@ NetBox can be configured to support remote user authentication by inferring user --- +## REMOTE_AUTH_GROUP_HEADER + +Default: `'HTTP_REMOTE_USER_GROUP'` + +When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + +## REMOTE_AUTH_GROUP_SEPARATOR + +Default: `|` (Pipe) + +The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) + +--- + ## REMOTE_AUTH_GROUP_SYNC_ENABLED Default: `False` @@ -63,14 +79,6 @@ When remote user authentication is in use, this is the name of the HTTP header w --- -## REMOTE_AUTH_GROUP_HEADER - -Default: `'HTTP_REMOTE_USER_GROUP'` - -When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User-Groups` it needs to be set to `HTTP_X_REMOTE_USER_GROUPS`. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - ## REMOTE_AUTH_SUPERUSER_GROUPS Default: `[]` (Empty list) @@ -100,11 +108,3 @@ The list of groups that promote an remote User to Staff on Login. If group isn't Default: `[]` (Empty list) The list of users that get promoted to Staff on Login. If user isn't present in list on next Login, the Role gets revoked. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) - ---- - -## REMOTE_AUTH_GROUP_SEPARATOR - -Default: `|` (Pipe) - -The Seperator upon which `REMOTE_AUTH_GROUP_HEADER` gets split into individual Groups. This needs to be coordinated with your authentication Proxy. (Requires `REMOTE_AUTH_ENABLED` and `REMOTE_AUTH_GROUP_SYNC_ENABLED` ) diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-parameters.md similarity index 100% rename from docs/configuration/required-settings.md rename to docs/configuration/required-parameters.md diff --git a/docs/configuration/security.md b/docs/configuration/security.md new file mode 100644 index 000000000..6aa363b1a --- /dev/null +++ b/docs/configuration/security.md @@ -0,0 +1,144 @@ +# Security & Authentication Parameters + +## ALLOWED_URL_SCHEMES + +!!! tip "Dynamic Configuration Parameter" + +Default: `('file', 'ftp', 'ftps', 'http', 'https', 'irc', 'mailto', 'sftp', 'ssh', 'tel', 'telnet', 'tftp', 'vnc', 'xmpp')` + +A list of permitted URL schemes referenced when rendering links within NetBox. Note that only the schemes specified in this list will be accepted: If adding your own, be sure to replicate all the default values as well (excluding those schemes which are not desirable). + +--- + +## AUTH_PASSWORD_VALIDATORS + +This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). + +```python +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + } + }, +] +``` + +--- + +## CORS_ORIGIN_ALLOW_ALL + +Default: False + +If True, cross-origin resource sharing (CORS) requests will be accepted from all origins. If False, a whitelist will be used (see below). + +--- + +## CORS_ORIGIN_WHITELIST + +## 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.) For example: + +```python +CORS_ORIGIN_WHITELIST = [ + 'https://example.com', +] +``` + +--- + +## CSRF_COOKIE_NAME + +Default: `csrftoken` + +The name of the cookie to use for the cross-site request forgery (CSRF) authentication token. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#csrf-cookie-name) for more detail. + +--- + +--- + +## CSRF_TRUSTED_ORIGINS + +Default: `[]` + +Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). + +```python +CSRF_TRUSTED_ORIGINS = ( + 'http://netbox.local', + 'https://netbox.local', +) +``` + +--- + +## EXEMPT_VIEW_PERMISSIONS + +Default: Empty list + +A list of NetBox models to exempt from the enforcement of view permissions. Models listed here will be viewable by all users, both authenticated and anonymous. + +List models in the form `.`. For example: + +```python +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.) + +```python +EXEMPT_VIEW_PERMISSIONS = ['*'] +``` + +!!! note + Using a wildcard will not affect certain potentially sensitive models, such as user permissions. If there is a need to exempt these models, they must be specified individually. + +--- + +## LOGIN_PERSISTENCE + +Default: False + +If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days. + +Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely. + +--- + +## LOGIN_REQUIRED + +Default: False + +Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes. + +--- + +## LOGIN_TIMEOUT + +Default: 1209600 seconds (14 days) + +The lifetime (in seconds) of the authentication cookie issued to a NetBox user upon login. + +--- + +## SESSION_COOKIE_NAME + +Default: `sessionid` + +The name used for the session cookie. See the [Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#session-cookie-name) for more detail. + +--- + +## SESSION_FILE_PATH + +Default: None + +HTTP session data is used to track authenticated users when they access NetBox. By default, NetBox stores session data in its PostgreSQL database. However, this inhibits authentication to a standby instance of NetBox without write access to the database. Alternatively, a local file path may be specified here and NetBox will store session data as files instead of using the database. Note that the NetBox system user must have read and write permissions to this path. diff --git a/docs/configuration/system.md b/docs/configuration/system.md new file mode 100644 index 000000000..21607e566 --- /dev/null +++ b/docs/configuration/system.md @@ -0,0 +1,178 @@ +# System Parameters + +## BASE_PATH + +Default: None + +The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at https://example.com/netbox/, set: + +```python +BASE_PATH = 'netbox/' +``` + +--- + +## DOCS_ROOT + +Default: `$INSTALL_ROOT/docs/` + +The filesystem path to NetBox's documentation. This is used when presenting context-sensitive documentation in the web UI. By default, this will be the `docs/` directory within the root NetBox installation path. (Set this to `None` to disable the embedded documentation.) + +--- + +## EMAIL + +In order to send email, NetBox needs an email server configured. The following items can be defined within the `EMAIL` configuration parameter: + +* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) +* `PORT` - TCP port to use for the connection (default: `25`) +* `USERNAME` - Username with which to authenticate +* `PASSSWORD` - Password with which to authenticate +* `USE_SSL` - Use SSL when connecting to the server (default: `False`) +* `USE_TLS` - Use TLS when connecting to the server (default: `False`) +* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) +* `SSL_KEYFILE` - Path to the PEM-formatted SSL private key file (optional) +* `TIMEOUT` - Amount of time to wait for a connection, in seconds (default: `10`) +* `FROM_EMAIL` - Sender address for emails sent by NetBox + +!!! note + The `USE_SSL` and `USE_TLS` parameters are mutually exclusive. + +Email is sent from NetBox only for critical events or if configured for [logging](#logging). If you would like to test the email server configuration, Django provides a convenient [send_mail()](https://docs.djangoproject.com/en/stable/topics/email/#send-mail) function accessible within the NetBox shell: + +```no-highlight +# python ./manage.py nbshell +>>> from django.core.mail import send_mail +>>> send_mail( + 'Test Email Subject', + 'Test Email Body', + 'noreply-netbox@example.com', + ['users@example.com'], + fail_silently=False +) +``` + +--- + +## HTTP_PROXIES + +Default: None + +A dictionary of HTTP proxies to use for outbound requests originating from NetBox (e.g. when sending webhook requests). Proxies should be specified by schema (HTTP and HTTPS) as per the [Python requests library documentation](https://2.python-requests.org/en/master/user/advanced/). For example: + +```python +HTTP_PROXIES = { + 'http': 'http://10.10.1.10:3128', + 'https': 'http://10.10.1.10:1080', +} +``` + +--- + +## INTERNAL_IPS + +Default: `('127.0.0.1', '::1')` + +A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For +example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP +addresses (and [`DEBUG`](#debug) is true). + +--- + +## JINJA2_FILTERS + +Default: `{}` + +A dictionary of custom jinja2 filters with the key being the filter name and the value being a callable. For more information see the [Jinja2 documentation](https://jinja.palletsprojects.com/en/3.1.x/api/#custom-filters). For example: + +```python +def uppercase(x): + return str(x).upper() + +JINJA2_FILTERS = { + 'uppercase': uppercase, +} +``` + +--- + +## LOGGING + +By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins). + +The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file: + +```python +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': '/var/log/netbox.log', + }, + }, + 'loggers': { + 'django': { + 'handlers': ['file'], + 'level': 'INFO', + }, + }, +} +``` + +### Available Loggers + +* `netbox..` - Generic form for model-specific log messages +* `netbox.auth.*` - Authentication events +* `netbox.api.views.*` - Views which handle business logic for the REST API +* `netbox.reports.*` - Report execution (`module.name`) +* `netbox.scripts.*` - Custom script execution (`module.name`) +* `netbox.views.*` - Views which handle business logic for the web UI + +--- + +## MEDIA_ROOT + +Default: $INSTALL_ROOT/netbox/media/ + +The file path to the location where media files (such as image attachments) are stored. By default, this is the `netbox/media/` directory within the base NetBox installation path. + +--- + +## REPORTS_ROOT + +Default: `$INSTALL_ROOT/netbox/reports/` + +The file path to the location where [custom reports](../customization/reports.md) will be kept. By default, this is the `netbox/reports/` directory within the base NetBox installation path. + +--- + +## SCRIPTS_ROOT + +Default: `$INSTALL_ROOT/netbox/scripts/` + +The file path to the location where [custom scripts](../customization/custom-scripts.md) will be kept. By default, this is the `netbox/scripts/` directory within the base NetBox installation path. + +--- + +## STORAGE_BACKEND + +Default: None (local storage) + +The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used. + +The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting. + +--- + +## STORAGE_CONFIG + +Default: Empty + +A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail. + +If `STORAGE_BACKEND` is not defined, this setting will be ignored. + +--- diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md deleted file mode 100644 index b1b02e300..000000000 --- a/docs/core-functionality/circuits.md +++ /dev/null @@ -1,10 +0,0 @@ -# Circuits - -{!models/circuits/provider.md!} -{!models/circuits/providernetwork.md!} - ---- - -{!models/circuits/circuit.md!} -{!models/circuits/circuittype.md!} -{!models/circuits/circuittermination.md!} diff --git a/docs/core-functionality/contacts.md b/docs/core-functionality/contacts.md deleted file mode 100644 index 76a005fc0..000000000 --- a/docs/core-functionality/contacts.md +++ /dev/null @@ -1,5 +0,0 @@ -# Contacts - -{!models/tenancy/contact.md!} -{!models/tenancy/contactgroup.md!} -{!models/tenancy/contactrole.md!} diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md deleted file mode 100644 index ec5cbacdb..000000000 --- a/docs/core-functionality/device-types.md +++ /dev/null @@ -1,41 +0,0 @@ -# Device Types - -{!models/dcim/devicetype.md!} -{!models/dcim/manufacturer.md!} - ---- - -## Device Component Templates - -Each device type is assigned a number of component templates which define the physical components within a device. These are: - -* Console ports -* Console server ports -* Power ports -* Power outlets -* Network interfaces -* Front ports -* Rear ports -* Device bays (which house child devices) - -Whenever a new device is created, its components are automatically created per the templates assigned to its device type. For example, a Juniper EX4300-48T device type might have the following component templates defined: - -* One template for a console port ("Console") -* Two templates for power ports ("PSU0" and "PSU1") -* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") -* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") - -Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. - -!!! note - Assignment of components from templates occurs only at the time of device creation. If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components on existing devices. - -{!models/dcim/consoleporttemplate.md!} -{!models/dcim/consoleserverporttemplate.md!} -{!models/dcim/powerporttemplate.md!} -{!models/dcim/poweroutlettemplate.md!} -{!models/dcim/interfacetemplate.md!} -{!models/dcim/frontporttemplate.md!} -{!models/dcim/rearporttemplate.md!} -{!models/dcim/modulebaytemplate.md!} -{!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md deleted file mode 100644 index 35c978210..000000000 --- a/docs/core-functionality/devices.md +++ /dev/null @@ -1,40 +0,0 @@ -# Devices and Cabling - -{!models/dcim/device.md!} -{!models/dcim/devicerole.md!} -{!models/dcim/platform.md!} - ---- - -## Device Components - -Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. - -{!models/dcim/consoleport.md!} -{!models/dcim/consoleserverport.md!} -{!models/dcim/powerport.md!} -{!models/dcim/poweroutlet.md!} -{!models/dcim/interface.md!} -{!models/dcim/frontport.md!} -{!models/dcim/rearport.md!} -{!models/dcim/modulebay.md!} -{!models/dcim/devicebay.md!} -{!models/dcim/inventoryitem.md!} - ---- - -{!models/dcim/virtualchassis.md!} - ---- - -{!models/dcim/cable.md!} - -In the example below, three individual cables comprise a path between devices A and D: - -![Cable path](../media/models/dcim_cable_trace.png) - -Traced from Interface 1 on Device A, NetBox will show the following path: - -* Cable 1: Interface 1 to Front Port 1 -* Cable 2: Rear Port 1 to Rear Port 2 -* Cable 3: Front Port 2 to Interface 2 diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md deleted file mode 100644 index 01bb3c76d..000000000 --- a/docs/core-functionality/ipam.md +++ /dev/null @@ -1,28 +0,0 @@ -# IP Address Management - -{!models/ipam/aggregate.md!} -{!models/ipam/rir.md!} - ---- - -{!models/ipam/prefix.md!} -{!models/ipam/role.md!} - ---- - -{!models/ipam/iprange.md!} -{!models/ipam/ipaddress.md!} - ---- - -{!models/ipam/vrf.md!} -{!models/ipam/routetarget.md!} - ---- - -{!models/ipam/fhrpgroup.md!} -{!models/ipam/fhrpgroupassignment.md!} - ---- - -{!models/ipam/asn.md!} diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md deleted file mode 100644 index 4d32fe18c..000000000 --- a/docs/core-functionality/modules.md +++ /dev/null @@ -1,4 +0,0 @@ -# Modules - -{!models/dcim/moduletype.md!} -{!models/dcim/module.md!} diff --git a/docs/core-functionality/power.md b/docs/core-functionality/power.md deleted file mode 100644 index 4d7d5f0ab..000000000 --- a/docs/core-functionality/power.md +++ /dev/null @@ -1,8 +0,0 @@ -# Power Tracking - -{!models/dcim/powerpanel.md!} -{!models/dcim/powerfeed.md!} - -# Example Power Topology - -![Power distribution model](../media/power_distribution.png) diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md deleted file mode 100644 index 316c7fe00..000000000 --- a/docs/core-functionality/services.md +++ /dev/null @@ -1,4 +0,0 @@ -# Service Mapping - -{!models/ipam/servicetemplate.md!} -{!models/ipam/service.md!} diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md deleted file mode 100644 index c78f2120a..000000000 --- a/docs/core-functionality/sites-and-racks.md +++ /dev/null @@ -1,12 +0,0 @@ -# Sites and Racks - -{!models/dcim/region.md!} -{!models/dcim/sitegroup.md!} -{!models/dcim/site.md!} -{!models/dcim/location.md!} - ---- - -{!models/dcim/rack.md!} -{!models/dcim/rackrole.md!} -{!models/dcim/rackreservation.md!} diff --git a/docs/core-functionality/tenancy.md b/docs/core-functionality/tenancy.md deleted file mode 100644 index fbe1ea8b9..000000000 --- a/docs/core-functionality/tenancy.md +++ /dev/null @@ -1,4 +0,0 @@ -# Tenancy Assignment - -{!models/tenancy/tenant.md!} -{!models/tenancy/tenantgroup.md!} diff --git a/docs/core-functionality/virtualization.md b/docs/core-functionality/virtualization.md deleted file mode 100644 index 220030ab2..000000000 --- a/docs/core-functionality/virtualization.md +++ /dev/null @@ -1,10 +0,0 @@ -# Virtualization - -{!models/virtualization/cluster.md!} -{!models/virtualization/clustertype.md!} -{!models/virtualization/clustergroup.md!} - ---- - -{!models/virtualization/virtualmachine.md!} -{!models/virtualization/vminterface.md!} diff --git a/docs/core-functionality/vlans.md b/docs/core-functionality/vlans.md deleted file mode 100644 index d69128765..000000000 --- a/docs/core-functionality/vlans.md +++ /dev/null @@ -1,4 +0,0 @@ -# VLAN Management - -{!models/ipam/vlan.md!} -{!models/ipam/vlangroup.md!} diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md deleted file mode 100644 index 57133f756..000000000 --- a/docs/core-functionality/wireless.md +++ /dev/null @@ -1,8 +0,0 @@ -# Wireless Networks - -{!models/wireless/wirelesslan.md!} -{!models/wireless/wirelesslangroup.md!} - ---- - -{!models/wireless/wirelesslink.md!} diff --git a/docs/customization/custom-fields.md b/docs/customization/custom-fields.md index 757416626..c443fa9f6 100644 --- a/docs/customization/custom-fields.md +++ b/docs/customization/custom-fields.md @@ -1,4 +1,76 @@ -{!models/extras/customfield.md!} +# Custom Fields + +Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. + +However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. + +Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. + +## Creating Custom Fields + +Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports many types of custom field: + +* Text: Free-form text (intended for single-line use) +* Long text: Free-form of any length; supports Markdown rendering +* Integer: A whole number (positive or negative) +* Boolean: True or false +* Date: A date in ISO 8601 format (YYYY-MM-DD) +* URL: This will be presented as a link in the web UI +* JSON: Arbitrary data stored in JSON format +* Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` + +Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. + +Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. + +A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. + +### Filtering + +The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. + +### Grouping + +!!! note + This feature was introduced in NetBox v3.3. + +Related custom fields can be grouped together within the UI by assigning each the same group name. When at least one custom field for an object type has a group defined, it will appear under the group heading within the custom fields panel under the object view. All custom fields with the same group name will appear under that heading. (Note that the group names must match exactly, or each will appear as a separate heading.) + +This parameter has no effect on the API representation of custom field data. + +### Visibility + +!!! note + This feature was introduced in NetBox v3.3. + +When creating a custom field, there are three options for UI visibility. These control how and whether the custom field is displayed within the NetBox UI. + +* **Read/write** (default): The custom field is included when viewing and editing objects. +* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. + +Note that this setting has no impact on the REST or GraphQL APIs: Custom field data will always be available via either API. + +### Validation + +NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: + +* Text: Regular expression (optional) +* Integer: Minimum and/or maximum value (optional) +* Selection: Must exactly match one of the prescribed choices + +### Custom Selection Fields + +Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. + +If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. ## Custom Fields in Templates diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md new file mode 100644 index 000000000..16ba9d2af --- /dev/null +++ b/docs/customization/custom-links.md @@ -0,0 +1,66 @@ +# Custom Links + +Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). + +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. + +For example, you might define a link like this: + +* Text: `View NMS` +* URL: `https://nms.example.com/nodes/?name={{ obj.name }}` + +When viewing a device named Router4, this link would render as: + +```no-highlight +View NMS +``` + +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. + +!!! warning + Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. + +## Context Data + +The following context data is available within the template when rendering a custom link's text or URL. + +| Variable | Description | +|-----------|-------------------------------------------------------------------------------------------------------------------| +| `object` | The NetBox object being displayed | +| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 | +| `debug` | A boolean indicating whether debugging is enabled | +| `request` | The current WSGI request | +| `user` | The current user (if authenticated) | +| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | + +While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. + +Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list. + +## Conditional Rendering + +Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered. + +For example, if you only want to display a link for active devices, you could set the link text to + +```jinja2 +{% if obj.status == 'active' %}View NMS{% endif %} +``` + +The link will not appear when viewing a device with any status other than "active." + +As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this: + +```jinja2 +{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} +``` + +The link will only appear when viewing a device with a manufacturer name of "Cisco." + +## Link Groups + +Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. + +## Table Columns + +Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL. diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index f88cd309b..30198117f 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -50,7 +50,7 @@ The `fail()` method may optionally specify a field with which to associate the s ## Assigning Custom Validators -Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/dynamic-settings.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined: +Custom validators are associated with specific NetBox models under the [CUSTOM_VALIDATORS](../configuration/data-validation.md#custom_validators) configuration parameter. There are three manners by which custom validation rules can be defined: 1. Plain JSON mapping (no custom logic) 2. Dotted path to a custom validator class diff --git a/docs/customization/export-templates.md b/docs/customization/export-templates.md index affd39aae..640a97531 100644 --- a/docs/customization/export-templates.md +++ b/docs/customization/export-templates.md @@ -1,8 +1,45 @@ -{!models/extras/exporttemplate.md!} +# Export Templates + +NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. + +Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. + +Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). + +!!! note + The name `table` is reserved for internal use. + +!!! warning + Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. + +The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: + +```jinja2 +{% for rack in queryset %} +Rack: {{ rack.name }} +Site: {{ rack.site.name }} +Height: {{ rack.u_height }}U +{% endfor %} +``` + +To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. + +If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: +``` +{% for server in queryset %} +{% set data = server.get_config_context() %} +{{ data.syslog }} +{% endfor %} +``` + +The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) + +A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. + ## REST API Integration -When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/optional-settings.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example: +When it is necessary to provide authentication credentials (such as when [`LOGIN_REQUIRED`](../configuration/security.md#login_required) has been enabled), it is recommended to render export templates via the REST API. This allows the client to specify an authentication token. To render an export template via the REST API, make a `GET` request to the model's list endpoint and append the `export` parameter specifying the export template name. For example: ``` GET /api/dcim/sites/?export=MyTemplateName diff --git a/docs/customization/reports.md b/docs/customization/reports.md index ae4ceb9aa..150c32f40 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -12,7 +12,7 @@ A NetBox report is a mechanism for validating the integrity of data within NetBo ## Writing Reports -Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/optional-settings.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. +Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/system.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test. !!! warning The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index dbbe8378d..38d521de6 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -4,12 +4,12 @@ Getting started with NetBox development is pretty straightforward, and should feel very familiar to anyone with Django development experience. There are a few things you'll need: -* A Linux system or environment +* A Linux system or compatible environment * A PostgreSQL server, which can be installed locally [per the documentation](../installation/1-postgresql.md) * A Redis server, which can also be [installed locally](../installation/2-redis.md) -* A supported version of Python +* Python 3.8 or later -### Fork the Repo +### 1. Fork the Repo Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) Click the "fork" button at top right (be sure that you've logged into GitHub first). @@ -21,7 +21,7 @@ Copy the URL provided in the dialog box. You can then clone your GitHub fork locally for development: -```no-highlight +```no-highlight hl_lines="1 9" $ git clone https://github.com/$username/netbox.git Cloning into 'netbox'... remote: Enumerating objects: 85949, done. @@ -38,90 +38,85 @@ CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scri The NetBox project utilizes three persistent git branches to track work: * `master` - Serves as a snapshot of the current stable release -* `develop` - All development on the upcoming stable release occurs here -* `feature` - Tracks work on an upcoming major release +* `develop` - All development on the upcoming stable (patch) release occurs here +* `feature` - Tracks work on an upcoming minor release -Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release. +Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. For example, assume that the current NetBox release is v3.3.5. Work applied to the `develop` branch will appear in v3.3.6, and work done under the `feature` branch will be included in the next minor release (v3.4.0). -For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0). +!!! warning + **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release. -### Enable Pre-Commit Hooks +### 2. Enable Pre-Commit Hooks NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`: ```no-highlight -$ cd .git/hooks/ -$ ln -s ../../scripts/git-hooks/pre-commit +cd .git/hooks/ +ln -s ../../scripts/git-hooks/pre-commit ``` -### Create a Python Virtual Environment +### 3. Create a Python Virtual Environment A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production. Create a virtual environment using the `venv` Python module: ```no-highlight -$ mkdir ~/.venv -$ python3 -m venv ~/.venv/netbox +mkdir ~/.venv +python3 -m venv ~/.venv/netbox ``` This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`. -!!! info "Where to Create Your Virtual Environments" - Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple venvs. +!!! tip "Virtual Environments" + Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple environments. Once created, activate the virtual environment: ```no-highlight -$ source ~/.venv/netbox/bin/activate -(netbox) $ +source ~/.venv/netbox/bin/activate ``` Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment. -### Install Dependencies +### 4. Install Required Packages -With the virtual environment activated, install the project's required Python packages using the `pip` module: +With the virtual environment activated, install the project's required Python packages using the `pip` module. Required packages are defined in `requirements.txt`. Each line in this file specifies the name and specific version of a required package. ```no-highlight -(netbox) $ python -m pip install -r requirements.txt -Collecting Django==3.1 (from -r requirements.txt (line 1)) - Cache entry deserialization failed, entry ignored - Using cached https://files.pythonhosted.org/packages/2b/5a/4bd5624546912082a1bd2709d0edc0685f5c7827a278d806a20cf6adea28/Django-3.1-py3-none-any.whl -... +python -m pip install -r requirements.txt ``` -### Configure NetBox +### 5. Configure NetBox Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes * `DATABASE`: PostgreSQL database connection parameters -* `REDIS`: Redis configuration, if different from the defaults +* `REDIS`: Redis configuration (if different from the defaults) * `SECRET_KEY`: Set to a random string (use `generate_secret_key.py` in the parent directory to generate a suitable key) * `DEBUG`: Set to `True` * `DEVELOPER`: Set to `True` (this enables the creation of new database migrations) -### Start the Development Server +### 6. Start the Development Server -Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command: +Django provides a lightweight, auto-updating [HTTP/WSGI server](https://docs.djangoproject.com/en/stable/ref/django-admin/#runserver) for development use. It is started with the `runserver` management command: -```no-highlight +```no-highlight hl_lines="1" $ ./manage.py runserver -Watching for file changes with StatReloader Performing system checks... System check identified no issues (0 silenced). -February 18, 2022 - 20:29:57 -Django version 4.0.2, using settings 'netbox.settings' +August 18, 2022 - 15:17:52 +Django version 4.0.7, using settings 'netbox.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. ``` -This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server. +This ensures that your development environment is now complete and operational. The development server will monitor the development environment and automatically reload in response to any changes made. -!!! info "IDE Integration" - Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment. +!!! tip "IDE Integration" + Some IDEs, such as the highly-recommended [PyCharm](https://www.jetbrains.com/pycharm/), will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment. ## Populating Demo Data @@ -131,48 +126,51 @@ The demo data is provided in JSON format and loaded into an empty database using ## Running Tests -Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository. +Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure that the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository. To avoid potential issues with your local configuration file, set the `NETBOX_CONFIGURATION` to point to the packaged test configuration at `netbox/configuration_testing.py`. This will handle things like ensuring that the dummy plugin is enabled for comprehensive testing. ```no-highlight -$ export NETBOX_CONFIGURATION=netbox.configuration_testing -$ cd netbox/ -$ python manage.py test +export NETBOX_CONFIGURATION=netbox.configuration_testing +cd netbox/ +python manage.py test ``` In cases where you haven't made any changes to the database schema (which is typical), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.) ```no-highlight -$ python manage.py test --keepdb +python manage.py test --keepdb ``` You can also reduce testing time by enabling parallel test execution with the `--parallel` flag. (By default, this will run as many parallel tests as you have processors. To avoid sluggishness, it's a good idea to specify a lower number of parallel tests.) This flag can be combined with `--keepdb`, although if you encounter any strange errors, try running the test suite again with parallelization disabled. ```no-highlight -$ python manage.py test --parallel +python manage.py test --parallel ``` Finally, it's possible to limit the run to a specific set of tests, specified by their Python path. For example, to run only IPAM and DCIM view tests: ```no-highlight -$ python manage.py test dcim.tests.test_views ipam.tests.test_views +python manage.py test dcim.tests.test_views ipam.tests.test_views ``` This is handy for instances where just a few tests are failing and you want to re-run them individually. +!!! info + NetBox uses [django-rich](https://github.com/adamchainz/django-rich) to enhance Django's default `test` management command. + ## Submitting Pull Requests -Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged. +Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. Be sure to prefix your commit message with the word "Fixes" or "Closes" and the relevant issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged. ```no-highlight -$ git commit -m "Closes #1234: Add IPv5 support" -$ git push origin +git commit -m "Closes #1234: Add IPv5 support" +git push origin ``` Once your fork has the new commit, submit a [pull request](https://github.com/netbox-community/netbox/compare) to the NetBox repo to propose the changes. Be sure to provide a detailed accounting of the changes being made and the reasons for doing so. Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically. -!!! note "Remember to Open an Issue First" +!!! warning Remember, pull requests are permitted only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted. (The one exception to this is trivial changes to the documentation or other non-critical resources.) diff --git a/docs/development/index.md b/docs/development/index.md index 85762d0fe..0d570abe6 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -1,22 +1,18 @@ # NetBox Development -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. Each pull request must be preceded by an **approved** issue. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. +Thanks for your interest in contributing to NetBox! This introduction covers a few important things to know before you get started. -## Communication +## The Code -There are several official forums for communication among the developers and community members: +NetBox and many of its related projects are maintained on [GitHub](https://github.com/netbox-community/netbox). GitHub also serves as one of our primary discussion forums. While all the code and discussion is publicly accessible, you'll need register for a [free GitHub account](https://github.com/signup) to engage in participation. Most people begin by [forking](https://docs.github.com/en/get-started/quickstart/fork-a-repo) the NetBox repository under their own GitHub account to begin working on the code. -* [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 a GitHub issue. -* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. -* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. +![GitHub](../media/development/github.png) -## Governance +There are three permanent branches in the repository: -NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions. - -## Project Structure - -All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. Only pull requests representing new releases should be merged into `master`. +* `master` - The current stable release. Individual changes should never be pushed directly to this branch, but rather merged from `develop`. +* `develop` - Active development for the upcoming patch release. Pull requests will typically be based on this branch unless they introduce breaking changes that must be deferred until the next minor release. +* `feature` - New feature work to be introduced in the next minor release (e.g. from v3.3 to v3.4). NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function: @@ -31,3 +27,34 @@ NetBox components are arranged into Django apps. Each app holds the models, view * `wireless`: Wireless links and LANs All core functionality is stored within the `netbox/` subdirectory. HTML templates are stored in a common `templates/` directory, with model- and view-specific templates arranged by app. Documentation is kept in the `docs/` root directory. + +## Proposing Changes + +All substantial changes made to the code base are tracked using [GitHub issues](https://docs.github.com/en/issues). Feature requests, bug reports, and similar proposals must all be filed as issues and approved by a maintainer before work begins. This ensures that all changes to the code base are properly documented for future reference. + +To submit a new feature request or bug report for NetBox, select and complete the appropriate [issue template](https://github.com/netbox-community/netbox/issues/new/choose). Once your issue has been approved, you're welcome to submit a [pull request](https://docs.github.com/en/pull-requests) containing your proposed changes. + +![Opening a new GitHub issue](../media/development/github_new_issue.png) + +Check out our [issue intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Policy) for an overview of the issue triage and approval processes. + +!!! tip + Avoid starting work on a proposal before it has been accepted. Not all proposed changes will be accepted, and we'd hate for you to waste time working on code that might not make it into the project. + +## Getting Help + +There are two primary forums for getting assistance with NetBox development: + +* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature requests prior to submitting an issue. +* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained indefinitely. + +!!! note + Don't use GitHub issues to ask for help: These are reserved for proposed code changes only. + +## Governance + +NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions. + +## Licensing + +The entire NetBox project is licensed as open source under the [Apache 2.0 license](https://github.com/netbox-community/netbox/blob/master/LICENSE.txt). This is a very permissive license which allows unlimited redistribution of all code within the project. Note that all submissions to the project are subject to the same license. diff --git a/docs/development/models.md b/docs/development/models.md index ae1bab7e7..01070fa3d 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -2,18 +2,18 @@ ## Model Types -A NetBox model represents a discrete object type such as a device or IP address. Each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. +A NetBox model represents a discrete object type such as a device or IP address. Per [Django convention](https://docs.djangoproject.com/en/stable/topics/db/models/), each model is defined as a Python class and has its own SQL table. All NetBox data models can be categorized by type. The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/contenttypes/) framework can be used to reference models within the database. A ContentType instance references a model by its `app_label` and `name`: For example, the Site model is referred to as `dcim.site`. The content type combined with an object's primary key form a globally unique identifier for the object (e.g. `dcim.site:123`). ### Features Matrix -* [Change logging](../additional-features/change-logging.md) - Changes to these objects are automatically recorded in the change log -* [Webhooks](../additional-features/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects +* [Change logging](../features/change-logging.md) - Changes to these objects are automatically recorded in the change log +* [Webhooks](../integrations/webhooks.md) - NetBox is capable of generating outgoing webhooks for these objects * [Custom fields](../customization/custom-fields.md) - These models support the addition of user-defined fields * [Export templates](../customization/export-templates.md) - Users can create custom export templates for these models * [Tagging](../models/extras/tag.md) - The models can be tagged with user-defined tags -* [Journaling](../additional-features/journaling.md) - These models support persistent historical commentary +* [Journaling](../features/journaling.md) - These models support persistent historical commentary * Nesting - These models can be nested recursively to create a hierarchy | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | @@ -45,6 +45,8 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/ * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPRange](../models/ipam/iprange.md) +* [ipam.L2VPN](../models/ipam/l2vpn.md) +* [ipam.L2VPNTermination](../models/ipam/l2vpntermination.md) * [ipam.Prefix](../models/ipam/prefix.md) * [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.Service](../models/ipam/service.md) diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 17c27948d..efb0f44b9 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -1,62 +1,62 @@ # Release Checklist -## Minor Version Bumps +This documentation describes the process of packaging and publishing a new NetBox release. There are three types of release: -### Address Pinned Dependencies +* Major release (e.g. v2.11 to v3.0) +* Minor release (e.g. v3.2 to v3.3) +* Patch release (e.g. v3.3.0 to v3.3.1) -Check `base_requirements.txt` for any dependencies pinned to a specific version, and upgrade them to their most stable release (where possible). +While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging. -### Link to the Release Notes Page +## Minor Version Releases -Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and add a summary of the major changes to `index.md`. +### Address Constrained Dependencies -### Manually Perform a New Install - -Install `mkdocs` in your local environment, then start the documentation server: - -```no-highlight -$ pip install -r docs/requirements.txt -$ mkdocs serve -``` - -Follow these instructions to perform a new installation of NetBox. This process must _not_ be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release. - -### Close the Release Milestone - -Close the release milestone on GitHub after ensuring there are no remaining open issues associated with it. - -### Merge the Release Branch - -Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. - ---- - -## All Releases - -### Update Requirements - -Required Python packages are maintained in two files. `base_requirements.txt` contains a list of all the packages required by NetBox. Some of them may be pinned to a specific version of the package due to a known issue. For example: +Sometimes it becomes necessary to constrain dependencies to a particular version, e.g. to work around a bug in a newer release or to avoid a breaking change that we have yet to accommodate. (Another common example is to limit the upstream Django release.) For example: ``` # https://github.com/encode/django-rest-framework/issues/6053 djangorestframework==3.8.1 ``` -The other file is `requirements.txt`, which lists each of the required packages pinned to its current stable version. When NetBox is installed, the Python environment is configured to match this file. This helps ensure that a new release of a dependency doesn't break NetBox. +These version constraints are added to `base_requirements.txt` to ensure that newer packages are not installed when updating the pinned dependencies in `requirements.txt` (see the [Update Requirements](#update-requirements) section below). Before each new minor version of NetBox is released, all such constraints on dependent packages should be addressed if feasible. This guards against the collection of stale constraints over time. -Every release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this: +### Close the Release Milestone -1. Create a new virtual environment. -2. Install the latest version of all required packages `pip install -U -r base_requirements.txt`). -3. Run all tests and check that the UI and API function as expected. -4. Review each requirement's release notes for any breaking or otherwise noteworthy changes. -5. Update the package versions in `requirements.txt` as appropriate. +Close the [release milestone](https://github.com/netbox-community/netbox/milestones) on GitHub after ensuring there are no remaining open issues associated with it. -In cases where upgrading a dependency to its most recent release is breaking, it should be pinned to its current minor version in `base_requirements.txt` (with an explanatory comment) and revisited for the next major NetBox release. +### Update the Release Notes -### Verify CI Build Status +Check that a link to the release notes for the new version is present in the navigation menu (defined in `mkdocs.yml`), and that a summary of all major new features has been added to `docs/index.md`. -Ensure that continuous integration testing on the `develop` branch is completing successfully. +### Manually Perform a New Install + +Start the documentation server and navigate to the current version of the installation docs: + +```no-highlight +mkdocs serve +``` + +Follow these instructions to perform a new installation of NetBox in a temporary environment. This process must not be automated: The goal of this step is to catch any errors or omissions in the documentation, and ensure that it is kept up-to-date for each release. Make any necessary changes to the documentation before proceeding with the release. + +### Merge the Release Branch + +Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. + +--- + +## Patch Releases + +### Update Requirements + +Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this: + +1. Upgrade the installed version of all required packages in your environment (`pip install -U -r base_requirements.txt`). +2. Run all tests and check that the UI and API function as expected. +3. Review each requirement's release notes for any breaking or otherwise noteworthy changes. +4. Update the package versions in `requirements.txt` as appropriate. + +In cases where upgrading a dependency to its most recent release is breaking, it should be constrained to its current minor version in `base_requirements.txt` with an explanatory comment and revisited for the next major NetBox release (see the [Address Constrained Dependencies](#address-constrained-dependencies) section above). ### Update Version and Changelog @@ -64,28 +64,35 @@ Ensure that continuous integration testing on the `develop` branch is completing * Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`. * Replace the "FUTURE" placeholder in the release notes with the current date. -Commit these changes to the `develop` branch. +Commit these changes to the `develop` branch and push upstream. + +### Verify CI Build Status + +Ensure that continuous integration testing on the `develop` branch is completing successfully. If it fails, take action to correct the failure before proceding with the release. ### Submit a Pull Request -Submit a pull request title **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body. +Submit a pull request titled **"Release vX.Y.Z"** to merge the `develop` branch into `master`. Copy the documented release notes into the pull request's body. -Once CI has completed on the PR, merge it. +Once CI has completed on the PR, merge it. This effects a new release in the `master` branch. ### Create a New Release -Draft a [new release](https://github.com/netbox-community/netbox/releases/new) with the following parameters. +Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters. -* **Tag:** Current version (e.g. `v2.9.9`) +* **Tag:** Current version (e.g. `v3.3.1`) * **Target:** `master` -* **Title:** Version and date (e.g. `v2.9.9 - 2020-11-09`) +* **Title:** Version and date (e.g. `v3.3.1 - 2022-08-25`) +* **Description:** Copy from the pull request body -Copy the description from the pull request to the release. +Once created, the release will become available for users to install. ### Update the Development Version -On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v2.9.9, set: +On the `develop` branch, update `VERSION` in `settings.py` to point to the next release. For example, if you just released v3.3.1, set: ``` -VERSION = 'v2.9.10-dev' +VERSION = 'v3.3.2-dev' ``` + +Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream. diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 2a6d86ab0..283ad698c 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -1,34 +1,53 @@ # Style Guide -NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh` for details. +NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. -## PEP 8 Exceptions +## Code -* Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: - * The library being import contains only constant declarations (e.g. `constants.py`) - * The library being imported explicitly defines `__all__` +### General Guidance -* Maximum line length is 120 characters (E501) - * This does not apply to HTML templates or to automatically generated code (e.g. database migrations). +* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and submit a separate bug report so that the entire code base can be evaluated at a later point. -* Line breaks are permitted following binary operators (W504) +* Prioritize readability over concision. Python is a very flexible language that typically offers several multiple options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. -## Enforcing Code Style +* Include a newline at the end of every file. -The `pycodestyle` utility (previously `pep8`) is used by the CI process to enforce code style. It is strongly recommended to include as part of your commit process. A git commit hook is provided in the source at `scripts/git-hooks/pre-commit`. Linking to this script from `.git/hooks/` will invoke `pycodestyle` prior to every commit attempt and abort if the validation fails. +* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary code is best avoided entirely. -``` -$ cd .git/hooks/ -$ ln -s ../../scripts/git-hooks/pre-commit -``` +* Constants (variables which do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. -To invoke `pycodestyle` manually, run: +* Every model must have a [docstring](https://peps.python.org/pep-0257/). Every custom method should include an explanation of its function. + +* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. + +### PEP 8 Exceptions + +NetBox ignores certain PEP8 assertions. These are listed below. + +#### Wildcard Imports + +Wildcard imports (for example, `from .constants import *`) are acceptable under any of the following conditions: + +* The library being import contains only constant declarations (e.g. `constants.py`) +* The library being imported explicitly defines `__all__` + +#### Maximum Line Length (E501) + +NetBox does not restrict lines to a maximum length of 79 characters. We use a maximum line length of 120 characters, however this is not enforced by CI. The maximum length does not apply to HTML templates or to automatically generated code (e.g. database migrations). + +#### Line Breaks Following Binary Operators (W504) + +Line breaks are permitted following binary operators. + +### Enforcing Code Style + +The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process to enforce code style. A [pre-commit hook](./getting-started.md#2-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `pycodestyle` manually, run: ``` pycodestyle --ignore=W504,E501 netbox/ ``` -## Introducing New Dependencies +### Introducing New Dependencies The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks. @@ -39,24 +58,22 @@ If there's a strong case for introducing a new dependency, it must meet the foll * It must be actively maintained, with no longer than one year between releases. * It must be available via the [Python Package Index](https://pypi.org/) (PyPI). -When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release and simplify support efforts. +When adding a new dependency, a short description of the package and the URL of its code repository must be added to `base_requirements.txt`. Additionally, a line specifying the package name pinned to the current stable release must be added to `requirements.txt`. This ensures that NetBox will install only the known-good release. -## General Guidance +## Written Works -* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and submit a separate bug report so that the entire code base can be evaluated at a later point. +### General Guidance -* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. +* Written material must always meet a reasonable professional standard, with proper grammar, spelling, and punctuation. -* No easter eggs. While they can be fun, NetBox must be considered as a business-critical tool. The potential, however minor, for introducing a bug caused by unnecessary logic is best avoided entirely. +* Use two line breaks between paragraphs. -* Constants (variables which generally do not change) should be declared in `constants.py` within each app. Wildcard imports from the file are acceptable. +* Use only a single space between sentences. -* Every model should have a docstring. Every custom method should include an explanation of its function. +* All documentation is to be written in [Markdown](../reference/markdown.md), with modest amounts of HTML permitted where needed to overcome technical limitations. -* Nested API serializers generate minimal representations of an object. These are stored separately from the primary serializers to avoid circular dependencies. Always import nested serializers from other apps directly. For example, from within the DCIM app you would write `from ipam.api.nested_serializers import NestedIPAddressSerializer`. +### Branding -## Branding - -* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. But never "Netbox" or any other deviation. +* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. but never "Netbox" or any other deviation. * There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. diff --git a/docs/features/api-integration.md b/docs/features/api-integration.md new file mode 100644 index 000000000..50c31ec4f --- /dev/null +++ b/docs/features/api-integration.md @@ -0,0 +1,41 @@ +# API & Integration + +NetBox includes a slew of features which enable integration with other tools and resources powering your network. + +## REST API + +NetBox's REST API, powered by the [Django REST Framework](https://www.django-rest-framework.org/), provides a robust yet accessible interface for creating, modifying, and deleting objects. Employing HTTP for transfer and JSON for data encapsulation, the REST API is easily consumed by clients on any platform and extremely well suited for automation tasks. + +```no-highlight +curl -s -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/ipam/prefixes/ \ +--data '{"prefix": "192.0.2.0/24", "site": {"name": "Branch 12"}}' +``` + +The REST API employs token-based authentication, which maps API clients to user accounts and their assigned permissions. The API endpoints are fully documented using OpenAPI, and NetBox even includes a convenient browser-based version of the API for exploration. The open source [pynetbox](https://github.com/netbox-community/pynetbox) and [go-netbox](https://github.com/netbox-community/go-netbox) API client libraries are also available for Python and Go, respectively. + +To learn more about this feature, check out the [REST API documentation](../integrations/rest-api.md). + +## GraphQL API + +NetBox also provides a [GraphQL](https://graphql.org/) API to complement its REST API. GraphQL enables complex queries for arbitrary objects and fields, enabling the client to retrieve only the specific data it needs from NetBox. This is a special-purpose read-only API intended for efficient queries. Like the REST API, the GraphQL API employs token-based authentication. + +To learn more about this feature, check out the [GraphQL API documentation](../integrations/graphql-api.md). + +## Webhooks + +A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are an excellent mechanism for building event-based automation processes. + +To learn more about this feature, check out the [webhooks documentation](../integrations/webhooks.md). + +## NAPALM + +[NAPALM](https://github.com/napalm-automation/napalm) is a Python library which enables direct interaction with network devices of various platforms. When configured, NetBox supports fetching live operational and status data directly from network devices to be compared to what has been defined in NetBox. This allows for easily validating the device's operational state against its desired state. Additionally, NetBox's REST API can act as a sort of proxy for NAPALM commands, allowing external clients to interact with network devices by sending HTTP requests to the appropriate API endpoint. + +To learn more about this feature, check out the [NAPALM documentation](../integrations/napalm.md). + +## Prometheus Metrics + +NetBox includes a special `/metrics` view which exposes metrics for a [Prometheus](https://prometheus.io/) scraper, powered by the open source [django-prometheus](https://github.com/korfuri/django-prometheus) library. To learn more about this feature, check out the [Prometheus metrics documentation](../integrations/prometheus-metrics.md). diff --git a/docs/features/authentication-permissions.md b/docs/features/authentication-permissions.md new file mode 100644 index 000000000..14e13d5cd --- /dev/null +++ b/docs/features/authentication-permissions.md @@ -0,0 +1,49 @@ +# Authentication & Permissions + +## Object-Based Permissions + +NetBox boasts a very robust permissions system which extends well beyond the model-based permissions of the underlying Django framework. Assigning permissions in NetBox involves several dimensions: + +* The type(s) of object to which the permission applies +* The users and/or groups being granted the permissions +* The action(s) permitted by the permission (e.g. view, add, change, etc.) +* Any constraints limiting application of the permission to a particular subset of objects + +The implementation of constrains is what enables NetBox administrators to assign per-object permissions: Users can be limited to viewing or interacting with arbitrary subsets of objects based on the objects' attributes. For example, you might restrict a particular user to viewing only those prefixes or IP addresses within a particular VRF. Or you might restrict a group to modifying devices within a particular region. + +Permission constraints are declared in JSON format when creating a permission, and operate very similarly to Django ORM queries. For instance, here's a constraint that matches reserved VLANs with a VLAN ID between 100 and 199: + +```json +[ + { + "vid__gte": 100, + "vid__lt": 200 + }, + { + "status": "reserved" + } +] +``` + +Check out the [permissions documentation](../administration/permissions.md) for more information about permission constraints. + +## LDAP Authentication + +NetBox includes a built-in authentication backend for authenticating users against a remote LDAP server. The [installation documentation](../installation/6-ldap.md) provides more detail on this capability. + +## Single Sign-On (SSO) + +NetBox integrates with the open source [python-social-auth](https://github.com/python-social-auth) library to provide [myriad options](https://python-social-auth.readthedocs.io/en/latest/backends/index.html#supported-backends) for single sign-on (SSO) authentication. These include: + +* Cognito +* GitHub & GitHub Enterprise +* GitLab +* Google +* Hashicorp Vault +* Keycloak +* Microsoft Azure AD +* Microsoft Graph +* Okta +* OIDC + +...and many others. It's also possible to build your own custom backends as needed using python-social-auth's base OAuth, OpenID, and SAML classes. You can find some examples of configuring SSO in NetBox' [authentication documentation](../administration/authentication/overview.md). diff --git a/docs/additional-features/change-logging.md b/docs/features/change-logging.md similarity index 100% rename from docs/additional-features/change-logging.md rename to docs/features/change-logging.md diff --git a/docs/features/circuits.md b/docs/features/circuits.md new file mode 100644 index 000000000..7739efb4c --- /dev/null +++ b/docs/features/circuits.md @@ -0,0 +1,33 @@ +# Circuits + +NetBox is ideal for managing your network's transit and peering providers and circuits. It provides all the flexibility needed to model physical circuits in both data center and enterprise environments, and allows for "connecting" circuits directly to device interfaces via cables. + +```mermaid +flowchart TD + ASN --> Provider + Provider --> ProviderNetwork & Circuit + CircuitType --> Circuit + +click ASN "../../models/circuits/asn/" +click Circuit "../../models/circuits/circuit/" +click CircuitType "../../models/circuits/circuittype/" +click Provider "../../models/circuits/provider/" +click ProviderNetwork "../../models/circuits/providernetwork/" +``` + +## Providers + +A provider is any organization which provides Internet or private connectivity. Typically, these are large carriers, however they might also include regional providers or even internal services. Each provider can be assigned account and contact details, and may have one or more AS numbers assigned to it. + +Sometimes you'll need to model provider networks into which you don't have full visibility; these are typically represented on topology diagrams with cloud icons. NetBox facilitates this through its provider network model: A provider network represents a "black box" network to which your circuits can connect. A common example is a provider MPLS network connecting multiple sites. + +## Circuits + +A circuit is a physical connection between two points, which is installed and maintained by an external provider. For example, an Internet connection delivered as a fiber optic cable would be modeled as a circuit in NetBox. + +Each circuit is associated with a provider and assigned a circuit ID, which must be unique to that provider. A circuit is also assigned a user-defined type, operational status, and various other operating characteristics. + +Each circuit may have up to two terminations (A and Z) defined. Each termination can be associated with a particular site or provider network. In the case of the former, a cable can be connected between the circuit termination and a device component to map its physical connectivity. + +!!! warning "Physical vs. Virtual Circuits" + The circuit model in NetBox represents **physical** connections. Don't confuse these with _virtual_ circuits which may be offered by providers overlaid on physical infrastructure. (For example, a VLAN-tagged subinterface would be a virtual circuit.) A good rule of thumb: If you can't point to it, it's not a physical circuit. diff --git a/docs/features/contacts.md b/docs/features/contacts.md new file mode 100644 index 000000000..40e8dd12c --- /dev/null +++ b/docs/features/contacts.md @@ -0,0 +1,45 @@ +# Contacts + +Much like [tenancy](./tenancy.md), contact assignment enables you to track ownership of resources modeled in NetBox. A contact represents an individual responsible for a resource within the context of its assigned role. + +```mermaid +flowchart TD + ContactGroup --> ContactGroup & Contact + ContactRole & Contact --> assignment([Assignment]) + assignment --> Object + +click Contact "../../models/tenancy/contact/" +click ContactGroup "../../models/tenancy/contactgroup/" +click ContactRole "../../models/tenancy/contactrole/" +``` + +## Contact Groups + +Contacts can be grouped arbitrarily into a recursive hierarchy, and a contact can be assigned to a group at any level within the hierarchy. + +## Contact Roles + +A contact role defines the relationship of a contact to an assigned object. For example, you might define roles for administrative, operational, and emergency contacts. + +## Contacts + +A contact should represent an individual or permanent point of contact. Each contact must define a name, and may optionally include a title, phone number, email address, and related details. + +Contacts are reused for assignments, so each unique contact must be created only once and can be assigned to any number of NetBox objects, and there is no limit to the number of assigned contacts an object may have. Most core objects in NetBox can have contacts assigned to them. + +The following models support the assignment of contacts: + +* circuits.Circuit +* circuits.Provider +* dcim.Device +* dcim.Location +* dcim.Manufacturer +* dcim.PowerPanel +* dcim.Rack +* dcim.Region +* dcim.Site +* dcim.SiteGroup +* tenancy.Tenant +* virtualization.Cluster +* virtualization.ClusterGroup +* virtualization.VirtualMachine diff --git a/docs/features/context-data.md b/docs/features/context-data.md new file mode 100644 index 000000000..04e795fd5 --- /dev/null +++ b/docs/features/context-data.md @@ -0,0 +1,84 @@ +# Context Data + +Configuration context data (or "config contexts" for short) is a powerful feature that enables users to define arbitrary data that applies to device and virtual machines based on certain characteristics. For example, suppose you want to define syslog servers for devices assigned to sites within a particular region. In NetBox, you can create a config context instance containing this data and apply it to the desired region. All devices within this region will now include this data when fetched via an API. + +```json +{ + "syslog-servers": [ + "192.168.43.107", + "192.168.48.112" + ] +} +``` + +Config contexts can be computed for objects based on the following criteria: + +| Type | Devices | Virtual Machines | +|---------------|------------------|------------------| +| Region | :material-check: | :material-check: | +| Site group | :material-check: | :material-check: | +| Site | :material-check: | :material-check: | +| Location | :material-check: | | +| Device type | :material-check: | | +| Role | :material-check: | :material-check: | +| Platform | :material-check: | :material-check: | +| Cluster type | | :material-check: | +| Cluster group | | :material-check: | +| Cluster | | :material-check: | +| Tenant group | :material-check: | :material-check: | +| Tenant | :material-check: | :material-check: | +| Tag | :material-check: | :material-check: | + +There are no restrictions around what data can be stored in a configuration context, so long as it can be expressed in JSON. + +## Hierarchical Rendering + +While this is handy on its own, the real power of context data stems from its ability to be merged and overridden using multiple instances. For example, perhaps you need to define _different_ syslog servers within the region for a particular device role. You can create a second config context with the appropriate data and a higher weight, and apply it to the desired role. This will override the lower-weight data that applies to the entire region. As you can imagine, this flexibility can cater to many complex use cases. + +For example, suppose we want to specify a set of syslog and NTP servers for all devices within a region. We could create a config context instance with a weight of 1000 assigned to the region, with the following JSON data: + +```json +{ + "ntp-servers": [ + "172.16.10.22", + "172.16.10.33" + ], + "syslog-servers": [ + "172.16.9.100", + "172.16.9.101" + ] +} +``` + +But suppose there's a problem at one particular site within this region preventing traffic from reaching the regional syslog server. Devices there need to use a local syslog server instead of the two defined above. We'll create a second config context assigned only to that site with a weight of 2000 and the following data: + +```json +{ + "syslog-servers": [ + "192.168.43.107" + ] +} +``` + +When the context data for a device at this site is rendered, the second, higher-weight data overwrite the first, resulting in the following: + +```json +{ + "ntp-servers": [ + "172.16.10.22", + "172.16.10.33" + ], + "syslog-servers": [ + "192.168.43.107" + ] +} +``` + +Data from the higher-weight context overwrites conflicting data from the lower-weight context, while the non-conflicting portion of the lower-weight context (the list of NTP servers) is preserved. + +## Local Context Data + +Devices and virtual machines may also have a local context data defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. This is useful in situations where we need to call out a specific deviation in the data for a particular object. + +!!! warning + If you find that you're routinely defining local context data for many individual devices or virtual machines, [custom fields](./customization.md#custom-fields) may offer a more effective solution. diff --git a/docs/features/customization.md b/docs/features/customization.md new file mode 100644 index 000000000..813914ae2 --- /dev/null +++ b/docs/features/customization.md @@ -0,0 +1,84 @@ +# Customization + +While NetBox strives to meet the needs of every network, the needs of users to cater to their own unique environments cannot be ignored. NetBox was built with this in mind, and can be customized in many ways to better suit your particular needs. + +## Tags + +Most objects in NetBox can be assigned user-created tags to aid with organization and filtering. Tag values are completely arbitrary: They may be used to store data in key-value pairs, or they may be employed simply as labels against which objects can be filtered. Each tag can also be assigned a color for quicker differentiation in the user interface. + +Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": + +```no-highlight +GET /api/dcim/devices/?tag=monitored +``` + +The `tag` filter can be specified multiple times to match only objects which have _all_ the specified tags assigned: + +```no-highlight +GET /api/dcim/devices/?tag=monitored&tag=deprecated +``` + +## Custom Fields + +While NetBox provides a rather extensive data model out of the box, the need may arise to store certain additional data associated with NetBox objects. For example, you might need to record the invoice ID alongside an installed device, or record an approving authority when creating a new IP prefix. NetBox administrators can create custom fields on built-in objects to meet these needs. + +NetBox supports many types of custom field, from basic data types like strings and integers, to complex structures like selection lists or raw JSON. It's even possible to add a custom field which references other NetBox objects. Custom field data is stored directly alongside the object to which it is applied in the database, which ensures minimal performance impact. And custom field data can be written and read via the REST API, just like built-in fields. + +To learn more about this feature, check out the [custom field documentation](../customization/custom-fields.md). + +## Custom Links + +Custom links allow you to conveniently reference external resources related to NetBox objects from within the NetBox UI. For example, you might wish to link each virtual machine modeled in NetBox to its corresponding view in some orchestration application. You can do this by creating a templatized custom link for the virtual machine model, specifying something like the following for the link URL: + +```no-highlight +http://server.local/vms/?name={{ object.name }} +``` + +Now, when viewing a virtual machine in NetBox, a user will see a handy button with the chosen title and link (complete with the name of the VM being viewed). Both the text and URL of custom links can be templatized in this manner, and custom links can be grouped together into dropdowns for more efficient display. + +To learn more about this feature, check out the [custom link documentation](../customization/custom-links.md). + +## Custom Validation + +While NetBox employs robust built-in object validation to ensure the integrity of its database, you might wish to enforce additional rules governing the creation and modification of certain objects. For example, perhaps you require that every device defined in NetBox adheres to a particular naming scheme and includes an asset tag. You can configure a custom validation rule in NetBox to enforce these requirements for the device model: + +```python +CUSTOM_VALIDATORS = { + "dcim.device": [ + { + "name": { + "regex": "[a-z]+\d{3}" + }, + "asset_tag": { + "required": True + } + } + ] +} +``` + +To learn more about this feature, check out the [custom validation documentation](../customization/custom-validation.md). + +## Export Templates + +Most NetBox objects can be exported in bulk in two built-in CSV formats: The current view (what the user currently sees in the objects list), or all available data. NetBox also provides the capability to define your own custom data export formats via export templates. An export template is essentially [Jinja2](https://jinja.palletsprojects.com/) template code associated with a particular object type. From the objects list in the NetBox UI, a user can select any of the created export templates to export the objects according to the template logic. + +An export template doesn't have to render CSV data: Its output can be in any character-based format. For example, you might want to render data using tabs as delimiters, or even create DNS address records directly from the IP addresses list. Export templates are a great way to get the data you need in the format you need quickly. + +To learn more about this feature, check out the [export template documentation](../customization/export-templates.md). + +## Reports + +NetBox administrators can install custom Python scripts, known as _reports_, which run within NetBox and can be executed and analyzed within the NetBox UI. Reports are a great way to evaluate NetBox objects against a set of arbitrary rules. For example, you could write a report to check that every router has a loopback interface with an IP address assigned, or that every site has a minimum set of VLANs defined. + +When a report runs, its logs messages pertaining to the operations being performed, and will ultimately result in either a pass or fail. Reports can be executed via the UI, REST API, or CLI (as a management command). + +To learn more about this feature, check out the [documentation for reports](../customization/reports.md). + +## Custom Scripts + +Custom scripts are similar to reports, but more powerful. A custom script can prompt the user for input via a form (or API data), and is built to do much more than just reporting. Custom scripts are generally used to automate tasks, such as the population of new objects in NetBox, or exchanging data with external systems. + +The complete Python environment is available to a custom script, including all of NetBox's internal mechanisms: There are no artificial restrictions on what a script can do. As such, custom scripting is considered an advanced feature and requires sufficient familiarity with Python and NetBox's data model. + +To learn more about this feature, check out the [documentation for custom scripts](../customization/custom-scripts.md). diff --git a/docs/features/devices-cabling.md b/docs/features/devices-cabling.md new file mode 100644 index 000000000..bec3e56de --- /dev/null +++ b/docs/features/devices-cabling.md @@ -0,0 +1,87 @@ +# Devices & Cabling + +At its heart, NetBox is a tool for modeling your network infrastructure, and the device object is pivotal to that function. A device can be any piece of physical hardware installed within your network, such as server, router, or switch, and may optionally be mounted within a rack. Within each device, resources such as network interfaces and console ports are modeled as discrete components, which may optionally be grouped into modules. + +NetBox uses device types to represent unique real-world device models. This allows a user to define a device type and all its components once, and easily replicate an unlimited number of device instances from it. + +```mermaid +flowchart TD + Manufacturer -.-> Platform & DeviceType & ModuleType + Manufacturer --> DeviceType & ModuleType + DeviceRole & Platform & DeviceType --> Device + Device & ModuleType ---> Module + Device & Module --> Interface & ConsolePort & PowerPort & ... + +click Device "../../models/dcim/device/" +click DeviceRole "../../models/dcim/devicerole/" +click DeviceType "../../models/dcim/devicetype/" +click Manufacturer "../../models/dcim/manufacturer/" +click Module "../../models/dcim/module/" +click ModuleType "../../models/dcim/moduletype/" +click Platform "../../models/dcim/platform/" +``` + +## Manufacturers + +A manufacturer generally represents an organization which produces hardware devices. These can be defined by users, however they should represent an actual entity rather than some abstract idea. + +## Device Types + +A device type represents a unique combination of manufacturer and hardware model which maps to discrete make and model of device which exists in the real world. Each device type typically has a number of components created on it, representing network interfaces, device bays, and so on. New devices of this type can then be created in NetBox, and any associated components will be automatically replicated from the device type. This avoids needing to tediously recreate components for each device as it is added in NetBox. + +!!! tip "The Device Type Library" + While users are always free to create their own device types in NetBox, many find it convenient to draw from our [community library](https://github.com/netbox-community/devicetype-library) of pre-defined device types. This is possible because a particular make and model of device is applicable universally and never changes. + +All the following can be modeled as components: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power outlets +* Pass-through ports (front and rear) +* Module bays (which house modules) +* Device bays (which house child devices) + +For example, a Juniper EX4300-48T device type might have the following component templates defined: + +* One template for a console port ("Console") +* Two templates for power ports ("PSU0" and "PSU1") +* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") +* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") + +Once component templates have been created, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. + +!!! note "Component Instantiation is not Retroactive" + The instantiation of components from a device type definition occurs only at the time of device creation. If you modify the components assigned to a device type, it will not affect devices which have already been created. This guards against any inadvertent changes to existing devices. However, you always have the option of adding, modifying, or deleting components on existing devices. (These changes can easily be applied to multiple devices at once using the bulk operations available in the UI.) + +## Devices + +Whereas a device type defines the make and model of a device, a device itself represents an actual piece of installed hardware somewhere in the real world. A device can be installed at a particular position within an equipment rack, or simply associated with a site (and optionally with a location within that site). + +Each device can have an operational status, functional role, and software platform assigned. Device components are instantiated automatically from the assigned device type upon creation. + +### Virtual Chassis + +Sometimes it is necessary to model a set of physical devices as sharing a single management plane. Perhaps the most common example of such a scenario is stackable switches. These can be modeled as virtual chassis in NetBox, with one device acting as the chassis master and the rest as members. All components of member devices will appear on the master. + +## Module Types & Modules + +Much like device types and devices, module types can instantiate discrete modules, which are hardware components installed within devices. Modules often have their own child components, which become available to the parent device. For example, when modeling a chassis-based switch with multiple line cards in NetBox, the chassis would be created (from a device type) as a device, and each of its line cards would be instantiated from a module type as a module installed in one of the device's module bays. + +!!! tip "Device Bays vs. Module Bays" + What's the difference between device bays and module bays? Device bays are appropriate when the installed hardware has its own management plane, isolated from the parent device. A common example is a blade server chassis in which the blades share power but operate independently. In contrast, a module bay holds a module which does _not_ operate independently of its parent device, as with the chassis switch line card example mentioned above. + +One especially nice feature of modules is that templated components can be automatically renamed according to the module bay into which the parent module is installed. For example, if we create a module type with interfaces named `Gi{module}/0/1-48` and install a module of this type into module bay 7 of a device, NetBox will create interfaces named `Gi7/0/1-48`. + +## Cables + +NetBox models cables as connections among certain types of device components and other objects. Each cable can be assigned a type, color, length, and label. NetBox will enforce basic sanity checks to prevent invalid connections. (For example, a network interface cannot be connected to a power outlet.) + +Either end of a cable may terminate to multiple objects of the same type. For example, a network interface can be connected via a fiber optic cable to two discrete ports on a patch panel (each port attaching to an individual fiber strand in the patch cable). + +```mermaid +flowchart LR + Interface --> Cable + Cable --> fp1[Front Port] & fp2[Front Port] +``` diff --git a/docs/features/facilities.md b/docs/features/facilities.md new file mode 100644 index 000000000..84c7c5733 --- /dev/null +++ b/docs/features/facilities.md @@ -0,0 +1,66 @@ +# Facilities + +From global regions down to individual equipment racks, NetBox allows you to model your network's entire presence. This is accomplished through the use of several purpose-built models. The graph below illustrates these models and their relationships. + +```mermaid +flowchart TD + Region --> Region + SiteGroup --> SiteGroup + Region & SiteGroup --> Site + Site --> Location & Device + Location --> Location + Location --> Rack & Device + Rack --> Device + Site --> Rack + RackRole --> Rack + +click Device "../../models/dcim/device/" +click Location "../../models/dcim/location/" +click Rack "../../models/dcim/rack/" +click RackRole "../../models/dcim/rackrole/" +click Region "../../models/dcim/region/" +click Site "../../models/dcim/site/" +click SiteGroup "../../models/dcim/sitegroup/" +``` + +## Regions + +Regions represent geographic domains in which your network or its customers have a presence. These are typically used to model countries, states, and cities, although NetBox does not prescribe any precise uses and your needs may differ. + +Regions are self-nesting, so you can define child regions within a parent, and grandchildren within each child. For example, you might create a hierarchy like this: + +* Europe + * France + * Germany + * Spain +* North America + * Canada + * United States + * California + * New York + * Texas + +Regions will always be listed alphabetically by name within each parent, and there is no maximum depth for the hierarchy. + +## Site Groups + +Like regions, site groups can be arranged in a recursive hierarchy for grouping sites. However, whereas regions are intended for geographic organization, site groups may be used for functional grouping. For example, you might classify sites as corporate, branch, or customer sites in addition to where they are physically located. + +The use of both regions and site groups affords to independent but complementary dimensions across which sites can be organized. + +## Sites + +A site typically represents a building within a region and/or site group. Each site is assigned an operational status (e.g. active or planned), and can have a discrete mailing address and GPS coordinates assigned to it. + +## Locations + +A location can be any logical subdivision within a building, such as a floor or room. Like regions and site groups, locations can be nested into a self-recursive hierarchy for maximum flexibility. And like sites, each location has an operational status assigned to it. + +## Racks + +Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions. + +Each rack must be associated to a site, but the assignment to a location within that site is optional. Users can also create custom roles to which racks can be assigned. NetBox supports tracking rack space in half-unit increments, so it's possible to mount devices at e.g. position 2.5 within a rack. + +!!! tip "Devices" + You'll notice in the diagram above that a device can be installed within a site, location, or rack. This approach affords plenty of flexibility as not all sites need to define child locations, and not all devices reside in racks. diff --git a/docs/features/ipam.md b/docs/features/ipam.md new file mode 100644 index 000000000..d67645b17 --- /dev/null +++ b/docs/features/ipam.md @@ -0,0 +1,69 @@ +# IP Address Management + +IP address management (IPAM) is one of NetBox's core features. It supports full parity for IP4 and IPv6, advanced VRF assignment, automatic hierarchy formation, and much more. + +## IP Hierarchy + +NetBox employs several object types to represent a hierarchy of IP resources: + +* **Aggregate** - A prefix which represents the root of an addressing hierarchy. This is typically a large swath of public or private address space allocated for use by your organization. Each aggregate is assigned to an authoritative RIR. +* **Prefix** - A subnet defined within an aggregate. Prefixes extend the hierarchy by nesting within one another. (For example, 192.168.123.0/24 will appear within 192.168.0.0/16.) Each prefix can be assigned a functional role as well as an operational status. +* **IP Range** - An arbitrary range of individual IP addresses within a prefix, all sharing the same mask. Ranges are commonly affiliated with DHCP scopes, but can be used for any similar purpose. +* **IP Address** - An individual IP address along with its subnet mask, automatically arranged beneath its parent prefix. + +```mermaid +flowchart TD + RIR --> Aggregate + Aggregate & Role --> Prefix + Prefix --> Prefix + Prefix --> IPRange & IPAddress + +click Aggregate "../../models/ipam/aggregate/" +click IPAddress "../../models/ipam/ipaddress/" +click IPRange "../../models/ipam/iprange/" +click Prefix "../../models/ipam/prefix/" +click RIR "../../models/ipam/rir/" +click Role "../../models/ipam/role/" +``` + +!!! tip "Automatic Hierarchies" + IP objects in NetBox never need to be manually assigned to the parent objects. The construction of hierarchies is handled automatically by the application according to the inherent rules of IP addressing. + +An example hierarchy might look like this: + +* 100.64.0.0/10 (aggregate) + * 100.64.0.0/20 (prefix) + * 100.64.16.0/20 (prefix) + * 100.64.16.0/24 (prefix) + * 100.64.16.1/24 (address) + * 100.64.16.2/24 (address) + * 100.64.16.3/24 (address) + * 100.64.16.9/24 (prefix) + * 100.64.32.0/20 (prefix) + * 100.64.32.1/24 (address) + * 100.64.32.10-99/24 (range) + +## Utilization Stats + +The utilization rate for each prefix is calculated automatically depending on its status. _Container_ prefixes are those which house child prefixes; their utilization rate is determined based on how much of their available IP space is consumed by child prefixes. The utilization rate for any other type of prefix is determined by the aggregate usage of any child IP addresses and/or ranges defined. + +Similarly, utilization rates for aggregates is determined based on the space consumed by their child prefixes. + +## VRF Tracking + +NetBox supports the modeling of discrete virtual routing and forwarding (VRF) instances to represent multiple routing tables, including those with overlapping address space. Each type of IP object within an aggregate - prefix, IP range, and IP address - can be assigned to a particular VRF. Consequently, each VRF maintains its own isolated IP hierarchy. This makes it very easy to track overlapping IP space. + +VRF modeling in NetBox very closely follows what you find in real-world network configurations, with each VRF assigned a standards-compliant route distinguisher. You can even create route targets to manage the import and export of routing information among VRFs. + +!!! tip "Enforcing Unique IP Space" + Each VRF can be independently configured to permit or prohibit duplicate IP objects. For example, a VRF which has been configured to enforce unique IP space will not allow the creation of two 192.0.2.0/24 prefixes. The ability to toggle this restriction per VRF affords the user maximum flexibility in modeling their IP space. + +## AS Numbers + +An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR. + +## Service Mapping + +NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools. + +To model services in NetBox, begin by creating a service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new services by hand, without a template, however this approach can be tedious. diff --git a/docs/additional-features/journaling.md b/docs/features/journaling.md similarity index 100% rename from docs/additional-features/journaling.md rename to docs/features/journaling.md diff --git a/docs/features/l2vpn-overlay.md b/docs/features/l2vpn-overlay.md new file mode 100644 index 000000000..51fbf2a78 --- /dev/null +++ b/docs/features/l2vpn-overlay.md @@ -0,0 +1,5 @@ +# L2VPN & Overlay + +L2VPN and overlay networks, such as VXLAN and EVPN, can be defined in NetBox and tied to interfaces and VLANs. This allows for easy tracking of overlay assets and their relationships with underlay resources. + +Each L2VPN instance has a type and optional unique identifier. Like VRFs, L2VPNs can also have import and export route targets assigned to them. Terminations can then be created to assign VLANs and/or device and virtual machine interfaces to the overlay. diff --git a/docs/features/power-tracking.md b/docs/features/power-tracking.md new file mode 100644 index 000000000..e19ec421b --- /dev/null +++ b/docs/features/power-tracking.md @@ -0,0 +1,20 @@ +# Power Tracking + +As part of its DCIM feature set, NetBox supports modeling facility power as discrete power panels and feeds. These are most commonly used to document power distribution within a data center, but can serve more traditional environments as well. + +![Power distribution model](../media/power_distribution.png) + +## Power Panels + +A power panel is the furthest upstream power element modeled in NetBox. It typically represents a power distribution panel (or breaker panel) where facility power is split into multiple discrete circuits, which are modeled as feeds. + +Each power panel is associated with a site, and may optionally be associated with a particular location within that site. There is no limit to how many power feeds a single panel can supply, however both of these object types should map to real-world objects. + +## Power Feeds + +A power feed represents a discrete power circuit originating from an upstream power panel. Each power feed can be assigned a name, operational status, and various electrical characteristics such as supply (AC or DC), voltage, amperage, and so on. + +A device power port can be connected to a power feed via a cable. Only one port can be connected to a feed: Where multiple devices draw power from the same feed, a power distribution unit (PDU) must be modeled as an individual device mapping a power port to multiple power outlets to which the downstream devices can connect (as in the example above). + +!!! tip "Primary and Redundant Power" + Each power feed in NetBox is assigned a type: primary or redundant. This allows easily modeling redundant power distribution topologies. In scenarios involving only a single, non-redundant power supply, mark all power feeds as primary. diff --git a/docs/features/tenancy.md b/docs/features/tenancy.md new file mode 100644 index 000000000..470905f20 --- /dev/null +++ b/docs/features/tenancy.md @@ -0,0 +1,38 @@ +# Tenancy + +Most core objects within NetBox's data model support _tenancy_. This is the association of an object with a particular tenant to convey ownership or dependency. For example, an enterprise might represent its internal business units as tenants, whereas a managed services provider might create a tenant in NetBox to represent each of its customers. + +```mermaid +flowchart TD + TenantGroup --> TenantGroup & Tenant + Tenant --> Site & Device & Prefix & Circuit & ... + +click Tenant "../../models/tenancy/tenant/" +click TenantGroup "../../models/tenancy/tenantgroup/" +``` + +## Tenant Groups + +Tenants can be grouped by any logic that your use case demands, and groups can be nested recursively for maximum flexibility. For example, You might define a parent "Customers" group with child groups "Current" and "Past" within it. A tenant can be assigned to a group at any level within the hierarchy. + +## Tenants + +Typically, the tenant model is used to represent a customer or internal organization, however it can be used for whatever purpose meets your needs. + +Most core objects within NetBox can be assigned to particular tenant, so this model provides a very convenient way to correlate ownership across object types. For example, each of your customers might have its own racks, devices, IP addresses, circuits and so on: These can all be easily tracked via tenant assignment. + +The following objects can be assigned to tenants: + +* Sites +* Racks +* Rack reservations +* Devices +* VRFs +* Prefixes +* IP addresses +* VLANs +* Circuits +* Clusters +* Virtual machines + +Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. diff --git a/docs/features/virtualization.md b/docs/features/virtualization.md new file mode 100644 index 000000000..8e1bf35ef --- /dev/null +++ b/docs/features/virtualization.md @@ -0,0 +1,26 @@ +# Virtualization + +Virtual machines and clusters can be modeled in NetBox alongside physical infrastructure. IP addresses and other resources are assigned to these objects just like physical objects, providing a seamless integration between physical and virtual networks. + +```mermaid +flowchart TD + ClusterGroup & ClusterType --> Cluster + Cluster --> VirtualMachine + Platform --> VirtualMachine + VirtualMachine --> VMInterface + +click Cluster "../../models/virtualization/cluster/" +click ClusterGroup "../../models/virtualization/clustergroup/" +click ClusterType "../../models/virtualization/clustertype/" +click Platform "../../models/dcim/platform/" +click VirtualMachine "../../models/virtualization/virtualmachine/" +click VMInterface "../../models/virtualization/vminterface/" +``` + +## Clusters + +A cluster is one or more physical host devices on which virtual machines can run. Each cluster must have a type and operational status, and may be assigned to a group. (Both types and groups are user-defined.) Each cluster may designate one or more devices as hosts, however this is optional. + +## Virtual Machines + +A virtual machine is a virtualized compute instance. These behave in NetBox very similarly to device objects, but without any physical attributes. For example, a VM may have interfaces assigned to it with IP addresses and VLANs, however its interfaces cannot be connected via cables (because they are virtual). Each VM may also define its compute, memory, and storage resources as well. diff --git a/docs/features/vlan-management.md b/docs/features/vlan-management.md new file mode 100644 index 000000000..c74c9015c --- /dev/null +++ b/docs/features/vlan-management.md @@ -0,0 +1,24 @@ +# VLAN Management + +Complementing its IPAM capabilities, NetBox also tracks VLAN information to assist with layer two network configurations. VLANs are defined per IEEE 802.1Q and related standards, and can be assigned to groups and functional roles. + +```mermaid +flowchart TD + VLANGroup & Role --> VLAN + +click Role "../../models/ipam/role/" +click VLAN "../../models/ipam/vlan/" +click VLANGroup "../../models/ipam/vlangroup/" +``` + +## VLAN Groups + +A VLAN group is a collection of VLANs defined within a particular scope. Each VLAN group can be associated with a particular site, location, rack, or similar object to indicate its domain, and designates a minimum and maximum VLAN ID within the group. (By default, these are the standard minimum and maximum values of 1 and 4094, respectively.) + +Within a group, each VLAN must have a unique ID and name. There is no limit to how many groups can be created per scope. + +## VLANs + +NetBox models VLANs according to their definition under IEEE 802.1Q, with a 12-bit VLAN ID and a name. Each VLAN also has an operational status, and may be assigned a function role, just like prefixes. Each VLAN can be assigned to a VLAN group or site to convey the domain in which the VLAN exists. + +Once defined, VLANs can be associated with device and virtual machine interfaces. Each interface can be assigned an 802.1Q mode (access or tagged), and the relevant VLANs can be applied as tagged or untagged. diff --git a/docs/features/wireless.md b/docs/features/wireless.md new file mode 100644 index 000000000..c78387efb --- /dev/null +++ b/docs/features/wireless.md @@ -0,0 +1,29 @@ +# Wireless + +Just as NetBox provides robust modeling for physical cable plants, it also supports modeling wireless LANs and point-to-point links. + +## Wireless LANs + +```mermaid +flowchart TD + WirelessLANGroup --> WirelessLANGroup & WirelessLAN + +click WirelessLAN "../../models/wireless/wirelesslan/" +click WirelessLANGroup "../../models/wireless/wirelesslangroup/" +``` + +A wireless LAN is a multi-access network shared by multiple wireless clients, identified by a common service set identifier (SSID) and authentication parameters. Wireless LANs can be organized into self-nesting groups, and each wireless LAN may optionally be bound to a particular VLAN. This allows easily mapping wireless networks to their wired counterparts. + +Authentication attributes for wireless LANs include: + +* **Type** - Open, WEP, WPA, etc. +* **Cipher** - Auto, TKIP, or AES +* **Pre-shared key (PSK)** - The secret key configured on all participating clients + +The definition of authentication parameters is optional. + +## Wireless Links + +Whereas a wireless LAN represents a physical multi-access segment with any number of clients, a wireless link is a point-to-point connection between exactly two stations. These links behave much like cables, but more accurately model the nature of wireless communications. + +Like wireless LANs, wireless links also have an SSID and (optional) authentication attributes. diff --git a/docs/getting-started/planning.md b/docs/getting-started/planning.md new file mode 100644 index 000000000..5dbe6e54e --- /dev/null +++ b/docs/getting-started/planning.md @@ -0,0 +1,180 @@ +# Planning Your Move + +This guide outlines the steps necessary for planning a successful migration to NetBox. Although it is written under the context of a completely new installation, the general approach outlined here works just as well for adding new data to existing NetBox deployments. + +## Identify Current Sources of Truth + +Before beginning to use NetBox for your own data, it's crucial to first understand where your existing sources of truth reside. A "source of truth" is really just any repository of data that is authoritative for a given domain. For example, you may have a spreadsheet which tracks all IP prefixes in use on your network. So long as everyone involved agrees that this spreadsheet is _authoritative_ for the entire network, it is your source of truth for IP prefixes. + +Anything can be a source of truth, provided it meets two conditions: + +1. It is agreed upon by all relevant parties that this source of data is correct. +2. The domain to which it applies is well-defined. + + + +Dedicate some time to take stock of your own sources of truth for your infrastructure. Upon attempting to catalog these, you're very likely to encounter some challenges, such as: + +* **Multiple conflicting sources** for a given domain. For example, there may be multiple versions of a spreadsheet circulating, each of which asserts a conflicting set of data. +* **Sources with no domain defined.** You may encounter that different teams within your organization use different tools for the same purpose, with no normal definition of when either should be used. +* **Inaccessible data formatting.** Some tools are better suited for programmatic usage than others. For example, spreadsheets are generally very easy to parse and export, however free-form notes on wiki or similar application are much more difficult to consume. +* **There is no source of truth.** Sometimes you'll find that a source of truth simply doesn't exist for a domain. For example, when assigning IP addresses, operators may be just using any (presumed) available IP from a subnet without ever recording its usage. + +See if you can identify each domain of infrastructure data for your organization, and the source of truth for each. Once you have these compiled, you'll need to determine what belongs in NetBox. + +## Determine What to Move + +The general rule when determining what data to put into NetBox is this: If there's a model for it, it belongs in NetBox. For instance, NetBox has purpose-built models for racks, devices, cables, IP prefixes, VLANs, and so on. These are very straightforward to use. However, you'll inevitably reach the limits of NetBox's data model and question what additional data might make sense to record in NetBox. For example, you might wonder whether NetBox should function as the source of truth for infrastructure DNS records or DHCP scopes. + +NetBox provides two core mechanisms for extending its data model. The first is custom fields: Most models in NetBox support the addition of custom fields to hold additional data for which a built-in field does not exist. For example, you might wish to add an "inventory ID" field to the device model. The second mechanism is plugins. Users can create their own plugins to introduce entirely new models, views, and API endpoints in NetBox. This can be incredibly powerful, as it enables rapid development and tight integration with core models. + +That said, it doesn't always make sense to migrate a domain of data to NetBox. For example, many organizations opt to use only the IPAM components or only the DCIM components of NetBox, and integrate with other sources of truth for different domains. This is an entirely valid approach (so long as everyone involved agrees which tool is authoritative for each domain). Ultimately, you'll need to weigh the value of having non-native data models in NetBox against the effort required to define and maintain those models. + +Consider also that NetBox is under constant development. Although the current release might not support a particular type of object, there may be plans to add support for it in a future release. (And if there aren't, consider submitting a feature request citing your use case.) + +## Validate Existing Data + +The last step before migrating data to NetBox is the most crucial: **validation**. The GIGO (garbage in, garbage out) principle is in full effect: Your source of truth is only as good as the data it holds. While NetBox has very powerful data validation tools (including support for custom validation rules), ultimately the onus falls to a human operator to assert what is correct and what is not. For example, NetBox can validate the connection of a cable between two interfaces, but it cannot say whether the cable _should_ be there. + +Here are some tips to help ensure you're only importing valid data into NetBox: + +* Ensure you're starting with complete, well-formatted data. JSON or CSV is highly recommended for the best portability. +* Consider defining custom validation rules in NetBox prior to import. (For example, to enforce device naming schemes.) +* Use custom scripts to automatically populate patterned data. (For example, to automatically create a set of standard VLANs for each site.) + +There are several methods available to import data into NetBox, which we'll cover in the next section. + +## Order of Operations + +When starting with a completely empty database, it might not be immediately clear where to begin. Many models in NetBox rely on the advance creation of other types. For example, you cannot create a device type until after you have created its manufacturer. + +Below is the (rough) recommended order in which NetBox objects should be created or imported. While it is not required to follow this exact order, doing so will help ensure the smoothest workflow. + +1. Tenant groups and tenants +2. Regions, site groups, sites, and locations +3. Rack roles and racks +4. Manufacturers, device types, and module types +5. Platforms and device roles +6. Devices and modules +7. Providers and provider networks +8. Circuit types and circuits +9. Wireless LAN groups and wireless LANs +10. Route targets and VRFs +11. RIRs and aggregates +12. IP/VLAN roles +13. Prefixes, IP ranges, and IP addresses +14. VLAN groups and VLANs +15. Cluster types, cluster groups, and clusters +16. Virtual machines and VM interfaces + +This is not a comprehensive list, but should suffice for the initial data imports. Beyond these, it the order in which objects are added doesn't have much if any impact. + +The graphs below illustrate some of the core dependencies among different models in NetBox for your reference. + +!!! note "Self-Nesting Models" + Each model in the graphs below which show a looping arrow pointing back to itself can be nested in a recursive hierarchy. For example, you can have regions representing both countries and cities, with the latter nested underneath the former. + +### Tenancy + +```mermaid +flowchart TD + TenantGroup --> TenantGroup & Tenant + Tenant --> Site & Device & Prefix & VLAN & ... + +click Device "../../models/dcim/device/" +click Prefix "../../models/ipam/prefix/" +click Site "../../models/dcim/site/" +click Tenant "../../models/tenancy/tenant/" +click TenantGroup "../../models/tenancy/tenantgroup/" +click VLAN "../../models/ipam/vlan/" +``` + +### Sites, Racks, and Devices + +```mermaid +flowchart TD + Region --> Region + SiteGroup --> SiteGroup + DeviceRole & Platform --> Device + Region & SiteGroup --> Site + Site --> Location & Device + Location --> Location + Location --> Rack & Device + Rack --> Device + Manufacturer --> DeviceType & ModuleType + DeviceType --> Device + Device & ModuleType ---> Module + Device & Module --> Interface + +click Device "../../models/dcim/device/" +click DeviceRole "../../models/dcim/devicerole/" +click DeviceType "../../models/dcim/devicetype/" +click Interface "../../models/dcim/interface/" +click Location "../../models/dcim/location/" +click Manufacturer "../../models/dcim/manufacturer/" +click Module "../../models/dcim/module/" +click ModuleType "../../models/dcim/moduletype/" +click Platform "../../models/dcim/platform/" +click Rack "../../models/dcim/rack/" +click RackRole "../../models/dcim/rackrole/" +click Region "../../models/dcim/region/" +click Site "../../models/dcim/site/" +click SiteGroup "../../models/dcim/sitegroup/" +``` + +### VRFs, Prefixes, IP Addresses, and VLANs + +```mermaid +flowchart TD + VLANGroup --> VLAN + Role --> VLAN & IPRange & Prefix + RIR --> Aggregate + RouteTarget --> VRF + Aggregate & VRF --> Prefix + VRF --> IPRange & IPAddress + Prefix --> VLAN & IPRange & IPAddress + +click Aggregate "../../models/ipam/aggregate/" +click IPAddress "../../models/ipam/ipaddress/" +click IPRange "../../models/ipam/iprange/" +click Prefix "../../models/ipam/prefix/" +click RIR "../../models/ipam/rir/" +click Role "../../models/ipam/role/" +click VLAN "../../models/ipam/vlan/" +click VLANGroup "../../models/ipam/vlangroup/" +click VRF "../../models/ipam/vrf/" +``` + +### Circuits + +```mermaid +flowchart TD + Provider & CircuitType --> Circuit + Provider --> ProviderNetwork + Circuit --> CircuitTermination + +click Circuit "../../models/circuits/circuit/" +click CircuitTermination "../../models/circuits/circuittermination/" +click CircuitType "../../models/circuits/circuittype/" +click Provider "../../models/circuits/provider/" +click ProviderNetwork "../../models/circuits/providernetwork/" +``` + +### Clusters and Virtual Machines + +```mermaid +flowchart TD + ClusterGroup & ClusterType --> Cluster + Cluster --> VirtualMachine + Site --> Cluster & VirtualMachine + Device & Platform --> VirtualMachine + VirtualMachine --> VMInterface + +click Cluster "../../models/virtualization/cluster/" +click ClusterGroup "../../models/virtualization/clustergroup/" +click ClusterType "../../models/virtualization/clustertype/" +click Device "../../models/dcim/device/" +click Platform "../../models/dcim/platform/" +click VirtualMachine "../../models/virtualization/virtualmachine/" +click VMInterface "../../models/virtualization/vminterface/" +``` diff --git a/docs/getting-started/populating-data.md b/docs/getting-started/populating-data.md new file mode 100644 index 000000000..bb0e8e17f --- /dev/null +++ b/docs/getting-started/populating-data.md @@ -0,0 +1,42 @@ +# Populating Data + +This section covers the mechanisms which are available to populate data in NetBox. + +## Manual Object Creation + +The simplest and most direct way of populating data in NetBox is to use the object creation forms in the user interface. + +!!! warning "Not Ideal for Large Imports" + While convenient and readily accessible to even novice users, creating objects one at a time by manually completing these forms obviously does not scale well. For large imports, you're generally best served by using one of the other methods discussed in this section. + +To create a new object in NetBox, find the object type in the navigation menu and click the green "Add" button. + +!!! info "Missing Button?" + If you don't see an "add" button for certain object types, it's likely that your account does not have sufficient permission to create these types. Ask your NetBox administrator to grant the required permissions. + + Also note that some object types, such as device components, cannot be created directly from the navigation menu. These must be created within the context of a parent object (such as a parent device). + + + +## Bulk Import (CSV/YAML) + +NetBox supports the bulk import of new objects using CSV-formatted data. This method can be ideal for importing spreadsheet data, which is very easy to convert to CSV data. CSV data can be imported either as raw text using the form field, or by uploading a properly formatted CSV file. + +When viewing the CSV import form for an object type, you'll notice that the headers for the required columns have been pre-populated. Each form has a table beneath it titled "CSV Field Options," which lists _all_ supported columns for your reference. (Generally, these map to the fields you see in the corresponding creation form for individual objects.) + + + +Note that some models (namely device types and module types) do not support CSV import. Instead, they accept YAML-formatted data to facilitate the import of both the parent object as well as child components. + +## Scripting + +Sometimes you'll find that data you need to populate in NetBox can be easily reduced to a pattern. For example, suppose you have one hundred branch sites and each site gets five VLANs, numbered 101 through 105. While it's certainly possible to explicitly define each of these 500 VLANs in a CSV file for import, it may be quicker to draft a simple custom script to automatically create these VLANs according to the pattern. This ensures a high degree of confidence in the validity of the data, since it's impossible for a script to "miss" a VLAN here or there. + +!!! tip "Reconstruct Existing Data with Scripts" + Sometimes, you might want to write a script to populate objects even if you have the necessary data ready for import. This is because using a script eliminates the need to manually verify existing data prior to import. + +## REST API + +You can also use the REST API to facilitate the population of data in NetBox. The REST API offers full programmatic control over the creation of objects, subject to the same validation rules enforced by the UI forms. Additionally, the REST API supports the bulk creation of multiple objects using a single request. + +For more information about this option, see the [REST API documentation](../integrations/rest-api.md). diff --git a/docs/index.md b/docs/index.md index 81c899387..c233dedb7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,63 +1,53 @@ ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} -# What is NetBox? +# The Premiere Network Source of Truth -NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: +NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure. -* **IP address management (IPAM)** - IP networks and addresses, VRFs, and VLANs -* **Equipment racks** - Organized by group and site -* **Devices** - Types of devices and where they are installed -* **Connections** - Network, console, and power connections among devices -* **Virtualization** - Virtual machines and clusters -* **Data circuits** - Long-haul communications circuits and providers +## :material-server-network: Built for Networks -## What NetBox Is Not +Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more: -While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: +* Hierarchical regions, sites, and locations +* Racks, devices, and device components +* Cables and wireless connections +* Power distribution tracking +* Data circuits and providers +* Virtual machines and clusters +* IP prefixes, ranges, and addresses +* VRFs and route targets +* FHRP groups (VRRP, HSRP, etc.) +* AS numbers +* VLANs and scoped VLAN groups +* L2VPN overlays +* Tenancy assignments +* Contact management -* Network monitoring -* DNS server -* RADIUS server -* Configuration management -* Facilities management +## :material-hammer-wrench: Customizable & Extensible -That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions. +In addition to its expansive and robust data model, NetBox offers myriad mechanisms through which it can be customized and extended. Its powerful plugins architecture enables users to extend the application to meet their needs with minimal development effort. -## Design Philosophy +* Custom fields +* Custom model validation +* Export templates +* Webhooks +* Plugins +* REST & GraphQL APIs -NetBox was designed with the following tenets foremost in mind. +## :material-lock-open: Always Open -### Replicate the Real World +Because NetBox is an open source application licensed under [Apache 2](https://www.apache.org/licenses/LICENSE-2.0.html), its entire code base is completely accessible to the end user, and there's never a risk of vendor lock-in. Additionally, NetBox development is an entirely public, community-driven process to which everyone can provide input. -Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it. +!!! tip "NetBox Development" + Interested in contributing to NetBox? Check out our [GitHub repository](https://github.com/netbox-community/netbox) to get started! -### Serve as a "Source of Truth" +## :material-language-python: Powered by Python -NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence. +NetBox is built on the enormously popular [Django](http://www.djangoproject.com/) framework for the Python programming language, already a favorite among network engineers. Users can leverage their existing skills coding Python tools to extend NetBox's already vast functionality via custom scripts and plugins. -### Keep it Simple +## :material-flag: Getting Started -When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve. - -## Application Stack - -NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. - -| Function | Component | -|--------------------|-------------------| -| HTTP service | nginx or Apache | -| WSGI service | gunicorn or uWSGI | -| Application | Django/Python | -| Database | PostgreSQL 10+ | -| Task queuing | Redis/django-rq | -| Live device access | NAPALM (optional) | - -## Supported Python Versions - -NetBox supports Python 3.8, 3.9, and 3.10 environments. - -## Getting Started - -Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible. - -Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox. +* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in +* The [installation guide](./installation/index.md) will help you get your own deployment up and running +* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach +* [NetBox Cloud](https://www.getnetbox.io/) is a hosted solution offered by NS1 diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 50b350d3a..eeb5e6f20 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -142,7 +142,7 @@ ALLOWED_HOSTS = ['*'] ### DATABASE -This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-settings.md#database) for more detail on individual parameters. +This parameter holds the database configuration details. You must define the username and password used when you configured PostgreSQL. If the service is running on a remote host, update the `HOST` and `PORT` parameters accordingly. See the [configuration documentation](../configuration/required-parameters.md#database) for more detail on individual parameters. ```python DATABASE = { @@ -157,7 +157,7 @@ DATABASE = { ### REDIS -Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-settings.md#redis) for more detail on individual parameters. +Redis is a in-memory key-value store used by NetBox for caching and background task queuing. Redis typically requires minimal configuration; the values below should suffice for most installations. See the [configuration documentation](../configuration/required-parameters.md#redis) for more detail on individual parameters. Note that NetBox requires the specification of two separate Redis databases: `tasks` and `caching`. These may both be provided by the same Redis service, however each should have a unique numeric database ID. @@ -201,7 +201,7 @@ All Python packages required by NetBox are listed in `requirements.txt` and will ### NAPALM -Integration with the [NAPALM automation](../additional-features/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. +Integration with the [NAPALM automation](../integrations/napalm.md) library allows NetBox to fetch live data from devices and return it to a requester via its REST API. The `NAPALM_USERNAME` and `NAPALM_PASSWORD` configuration parameters define the credentials to be used when connecting to a device. ```no-highlight sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt" @@ -209,7 +209,7 @@ sudo sh -c "echo 'napalm' >> /opt/netbox/local_requirements.txt" ### Remote File Storage -By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/optional-settings.md#storage_backend) in `configuration.py`. +By default, NetBox will use the local filesystem to store uploaded files. To use a remote filesystem, install the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) library and configure your [desired storage backend](../configuration/system.md#storage_backend) in `configuration.py`. ```no-highlight sudo sh -c "echo 'django-storages' >> /opt/netbox/local_requirements.txt" diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index 281554f75..163ace70d 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -142,7 +142,7 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 `systemctl restart netbox` restarts the NetBox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/messages`. -For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/optional-settings.md#logging) configuration to `configuration.py`: +For troubleshooting LDAP user/group queries, add or merge the following [logging](../configuration/system.md#logging) configuration to `configuration.py`: ```python LOGGING = { diff --git a/docs/installation/migrating-to-systemd.md b/docs/installation/migrating-to-systemd.md deleted file mode 100644 index a71b748fd..000000000 --- a/docs/installation/migrating-to-systemd.md +++ /dev/null @@ -1,55 +0,0 @@ -# Migrating to systemd - -This document contains instructions for migrating from a legacy NetBox deployment using [supervisor](http://supervisord.org/) to a systemd-based approach. - -## Ubuntu - -### Uninstall supervisord - -```no-highlight -# apt-get remove -y supervisor -``` - -### Configure systemd - -!!! note - These instructions assume the presence of a Python virtual environment at `/opt/netbox/venv`. If you have not created this environment, please refer to the [installation instructions](3-netbox.md#set-up-python-environment) for direction. - -We'll use systemd to control the daemonization of NetBox services. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory: - -```no-highlight -# cp contrib/*.service /etc/systemd/system/ -``` - -!!! note - You may need to modify the user that the systemd service runs as. Please verify the user for httpd on your specific release and edit both files to match your httpd service under user and group. The username could be "nobody", "nginx", "apache", "www-data", or something else. - -Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time: - -```no-highlight -# systemctl daemon-reload -# systemctl start netbox netbox-rq -# systemctl enable netbox netbox-rq -``` - -You can use the command `systemctl status netbox` to verify that the WSGI service is running: - -``` -# systemctl status netbox.service -● netbox.service - NetBox WSGI Service - Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled) - Active: active (running) since Sat 2020-10-24 19:23:40 UTC; 25s ago - Docs: https://docs.netbox.dev/ - Main PID: 11993 (gunicorn) - Tasks: 6 (limit: 2362) - CGroup: /system.slice/netbox.service - ├─11993 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... - ├─12015 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... - ├─12016 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/... -... -``` - -At this point, you should be able to connect to the HTTP service at the server name or IP address you provided. If you are unable to connect, check that the nginx service is running and properly configured. If you receive a 502 (bad gateway) error, this indicates that gunicorn is misconfigured or not running. Issue the command `journalctl -xe` to see why the services were unable to start. - -!!! info - Please keep in mind that the configurations provided here are bare minimums required to get NetBox up and running. You may want to make adjustments to better suit your production environment. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 014dffaf8..deeec883a 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -18,6 +18,21 @@ NetBox v3.0 and later require the following: As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. +!!! warning + Use the same method as you used to install Netbox originally + +If you are not sure how Netbox was installed originally, check with this +command: + +``` +ls -ld /opt/netbox /opt/netbox/.git +``` + +If Netbox was installed from a release package, then `/opt/netbox` will be a +symlink pointing to the current version, and `/opt/netbox/.git` will not +exist. If it was installed from git, then `/opt/netbox` and +`/opt/netbox/.git` will both exist as normal directories. + ### Option A: Download a Release 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`. diff --git a/docs/graphql-api/overview.md b/docs/integrations/graphql-api.md similarity index 95% rename from docs/graphql-api/overview.md rename to docs/integrations/graphql-api.md index 57dfb22bd..4fc6d2dd8 100644 --- a/docs/graphql-api/overview.md +++ b/docs/integrations/graphql-api.md @@ -67,4 +67,4 @@ Authorization: Token $TOKEN ## Disabling the GraphQL API -If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/dynamic-settings.md#graphql_enabled) configuration parameter to False and restarting NetBox. +If not needed, the GraphQL API can be disabled by setting the [`GRAPHQL_ENABLED`](../configuration/miscellaneous.md#graphql_enabled) configuration parameter to False and restarting NetBox. diff --git a/docs/additional-features/napalm.md b/docs/integrations/napalm.md similarity index 88% rename from docs/additional-features/napalm.md rename to docs/integrations/napalm.md index 2387bc8b7..60d8014e2 100644 --- a/docs/additional-features/napalm.md +++ b/docs/integrations/napalm.md @@ -29,7 +29,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../configuration/dynamic-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/dynamic-settings.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. +By default, the [`NAPALM_USERNAME`](../configuration/napalm.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/napalm.md#napalm_password) configuration parameters are used for NAPALM authentication. They can be overridden for an individual API call by specifying the `X-NAPALM-Username` and `X-NAPALM-Password` headers. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/additional-features/prometheus-metrics.md b/docs/integrations/prometheus-metrics.md similarity index 100% rename from docs/additional-features/prometheus-metrics.md rename to docs/integrations/prometheus-metrics.md diff --git a/docs/rest-api/overview.md b/docs/integrations/rest-api.md similarity index 76% rename from docs/rest-api/overview.md rename to docs/integrations/rest-api.md index 27a9b6a7e..3a5aed055 100644 --- a/docs/rest-api/overview.md +++ b/docs/integrations/rest-api.md @@ -91,7 +91,7 @@ Lists of objects can be filtered using a set of query parameters. For example, t GET /api/dcim/interfaces/?device_id=123 ``` -See the [filtering documentation](filtering.md) for more details. +See the [filtering documentation](../reference/filtering.md) for more details. ## Serialization @@ -269,7 +269,7 @@ The brief format is supported for both lists and individual objects. ### Excluding Config Contexts -When retrieving devices and virtual machines via the REST API, each will included its rendered [configuration context data](../models/extras/configcontext.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. +When retrieving devices and virtual machines via the REST API, each will include its rendered [configuration context data](../features/context-data.md) by default. Users with large amounts of context data will likely observe suboptimal performance when returning multiple objects, particularly with very high page sizes. To combat this, context data may be excluded from the response data by attaching the query parameter `?exclude=config_context` to the request. This parameter works for both list and detail views. ## Pagination @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../configuration/dynamic-settings.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: +The default page is determined by the [`PAGINATE_COUNT`](../configuration/default-values.md#paginate_count) configuration parameter, which defaults to 50. However, this can be overridden per request by specifying the desired `offset` and `limit` query parameters. For example, if you wish to retrieve a hundred devices at a time, you would make a request for: ``` http://netbox/api/dcim/devices/?limit=100 @@ -325,7 +325,7 @@ The response will return devices 1 through 100. The URL provided in the `next` a } ``` -The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/dynamic-settings.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. +The maximum number of objects that can be returned is limited by the [`MAX_PAGE_SIZE`](../configuration/miscellaneous.md#max_page_size) configuration parameter, which is 1000 by default. Setting this to `0` or `None` will remove the maximum limit. An API consumer can then pass `?limit=0` to retrieve _all_ matching objects with a single request. !!! warning Disabling the page size limit introduces a potential for very resource-intensive requests, since one API request can effectively retrieve an entire table from the database. @@ -387,7 +387,7 @@ curl -s -X GET http://netbox/api/ipam/ip-addresses/5618/ | jq '.' ### Creating a New Object -To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication documentation](authentication.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. +To create a new object, make a `POST` request to the model's _list_ endpoint with JSON data pertaining to the object being created. Note that a REST API token is required for all write operations; see the [authentication section](#authenticating-to-the-api) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. ```no-highlight curl -s -X POST \ @@ -561,3 +561,96 @@ http://netbox/api/dcim/sites/ \ !!! note The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. + +## Authentication + +The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. + +### Tokens + +A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. + +!!! note + All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. + +Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. + +By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. + +Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. + +#### Client IP Restriction + +!!! note + This feature was introduced in NetBox v3.3. + +Each API token can optionally be restricted by client IP address. If one or more allowed IP prefixes/addresses is defined for a token, authentication will fail for any client connecting from an IP address outside the defined range(s). This enables restricting the use a token to a specific client. (By default, any client IP address is permitted.) + + +### Authenticating to the API + +An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: + +``` +$ curl -H "Authorization: Token $TOKEN" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/dcim/sites/ +{ + "count": 10, + "next": null, + "previous": null, + "results": [...] +} +``` + +A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/security.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: + +``` +$ curl https://netbox/api/dcim/sites/ +{ + "detail": "Authentication credentials were not provided." +} +``` + +When a token is used to authenticate a request, its `last_updated` time updated to the current time if its last use was recorded more than 60 seconds ago (or was never recorded). This allows users to determine which tokens have been active recently. + +!!! note + The "last used" time for tokens will not be updated while maintenance mode is enabled. + +### Initial Token Provisioning + +Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. + +To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: + +``` +$ curl -X POST \ +-H "Content-Type: application/json" \ +-H "Accept: application/json; indent=4" \ +https://netbox/api/users/tokens/provision/ \ +--data '{ + "username": "hankhill", + "password": "I<3C3H8", +}' +``` + +Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled. + +```json +{ + "id": 6, + "url": "https://netbox/api/users/tokens/6/", + "display": "3c9cb9 (hankhill)", + "user": { + "id": 2, + "url": "https://netbox/api/users/users/2/", + "display": "hankhill", + "username": "hankhill" + }, + "created": "2021-06-11T20:09:13.339367Z", + "expires": null, + "key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9", + "write_enabled": true, + "description": "" +} +``` diff --git a/docs/integrations/webhooks.md b/docs/integrations/webhooks.md new file mode 100644 index 000000000..9a1094988 --- /dev/null +++ b/docs/integrations/webhooks.md @@ -0,0 +1,127 @@ +# Webhooks + +NetBox can be configured to transmit outgoing webhooks to remote systems in response to internal object changes. The receiver can act on the data in these webhook messages to perform related tasks. + +For example, suppose you want to automatically configure a monitoring system to start monitoring a device when its operational status is changed to active, and remove it from monitoring for any other status. You can create a webhook in NetBox for the device model and craft its content and destination URL to effect the desired change on the receiving system. Webhooks will be sent automatically by NetBox whenever the configured constraints are met. + +Each webhook must be associated with at least one NetBox object type and at least one event (create, update, or delete). Users can specify the receiver URL, HTTP request type (`GET`, `POST`, etc.), content type, and headers. A request body can also be specified; if left blank, this will default to a serialized representation of the affected object. + +!!! warning "Security Notice" + Webhooks support the inclusion of user-submitted code to generate the URL, custom headers, and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + +## Jinja2 Template Support + +[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. + +For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: + +* Object type: IPAM > IP address +* HTTP method: `POST` +* URL: Slack incoming webhook URL +* HTTP content type: `application/json` +* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}` + +### Available Context + +The following data is available as context for Jinja2 templates: + +* `event` - The type of event which triggered the webhook: created, updated, or deleted. +* `model` - The NetBox model which triggered the change. +* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). +* `username` - The name of the user account associated with the change. +* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. +* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. +* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. + +### Default Request Body + +If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows: + +```json +{ + "event": "created", + "timestamp": "2021-03-09 17:55:33.968016+00:00", + "model": "site", + "username": "jstretch", + "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", + "data": { + "id": 19, + "name": "Site 1", + "slug": "site-1", + "status": + "value": "active", + "label": "Active", + "id": 1 + }, + "region": null, + ... + }, + "snapshots": { + "prechange": null, + "postchange": { + "created": "2021-03-09", + "last_updated": "2021-03-09T17:55:33.851Z", + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + } + } +} +``` + +## Conditional Webhooks + +A webhook may include a set of conditional logic expressed in JSON used to control whether a webhook triggers for a specific object. For example, you may wish to trigger a webhook for devices only when the `status` field of an object is "active": + +```json +{ + "and": [ + { + "attr": "status.value", + "value": "active" + } + ] +} +``` + +For more detail, see the reference documentation for NetBox's [conditional logic](../reference/conditions.md). + +## Webhook Processing + +When a change is detected, any resulting webhooks are placed into a Redis queue for processing. This allows the user's request to complete without needing to wait for the outgoing webhook(s) to be processed. The webhooks are then extracted from the queue by the `rqworker` process and HTTP requests are sent to their respective destinations. The current webhook queue and any failed webhooks can be inspected in the admin UI under System > Background Tasks. + +A request is considered successful if the response has a 2XX status code; otherwise, the request is marked as having failed. Failed requests may be retried manually via the admin UI. + +## Troubleshooting + +To assist with verifying that the content of outgoing webhooks is rendered correctly, NetBox provides a simple HTTP listener that can be run locally to receive and display webhook requests. First, modify the target URL of the desired webhook to `http://localhost:9000/`. This will instruct NetBox to send the request to the local server on TCP port 9000. Then, start the webhook receiver service from the NetBox root directory: + +```no-highlight +$ python netbox/manage.py webhook_receiver +Listening on port http://localhost:9000. Stop with CONTROL-C. +``` + +You can test the receiver itself by sending any HTTP request to it. For example: + +```no-highlight +$ curl -X POST http://localhost:9000 --data '{"foo": "bar"}' +``` + +The server will print output similar to the following: + +```no-highlight +[1] Tue, 07 Apr 2020 17:44:02 GMT 127.0.0.1 "POST / HTTP/1.1" 200 - +Host: localhost:9000 +User-Agent: curl/7.58.0 +Accept: */* +Content-Length: 14 +Content-Type: application/x-www-form-urlencoded + +{"foo": "bar"} +------------ +``` + +Note that `webhook_receiver` does not actually _do_ anything with the information received: It merely prints the request headers and body for inspection. + +Now, when the NetBox webhook is triggered and processed, you should see its headers and content appear in the terminal where the webhook receiver is listening. If you don't, check that the `rqworker` process is running and that webhook events are being placed into the queue (visible under the NetBox admin UI). diff --git a/docs/introduction.md b/docs/introduction.md new file mode 100644 index 000000000..cffcb37dd --- /dev/null +++ b/docs/introduction.md @@ -0,0 +1,79 @@ +# Introduction to NetBox + +## Origin Story + +NetBox was originally developed by its lead maintainer, [Jeremy Stretch](https://github.com/jeremystretch), while he was working as a network engineer at [DigitalOcean](https://www.digitalocean.com/) in 2015 as part of an effort to automate their network provisioning. Recognizing the new tool's potential, DigitalOcean agreed to release it as an open source project in June 2016. + +Since then, thousands of organizations around the world have embraced NetBox as their central network source of truth to empower both network operators and automation. + +## Key Features + +NetBox was built specifically to serve the needs of network engineers and operators. Below is a very brief overview of the core features it provides. + +* IP address management (IPAM) with full IPv4/IPv6 parity +* Automatic provisioning of next available prefix/IP +* VRFs with import & export route targets +* VLANs with variably-scoped groups +* AS number (ASN) management +* Rack elevations with SVG rendering +* Device modeling using pre-defined types +* Network, power, and console cabling with SVG traces +* Power distribution modeling +* Data circuit and provider tracking +* Wireless LAN and point-to-point links +* L2 VPN overlays +* FHRP groups (VRRP, HSRP, etc.) +* Application service bindings +* Virtual machines & clusters +* Flexible hierarchy for sites and locations +* Tenant ownership assignment +* Device & VM configuration contexts for advanced configuration rendering +* Custom fields for data model extension +* Support for custom validation rules +* Custom reports & scripts executable directly within the UI +* Extensive plugin framework for adding custom functionality +* Single sign-on (SSO) authentication +* Robust object-based permissions +* Detailed, automatic change logging +* NAPALM integration + +## What NetBox Is Not + +While NetBox strives to cover many areas of network management, the scope of its feature set is necessarily limited. This ensures that development focuses on core functionality and that scope creep is reasonably contained. To that end, it might help to provide some examples of functionality that NetBox **does not** provide: + +* Network monitoring +* DNS server +* RADIUS server +* Configuration management +* Facilities management + +That said, NetBox _can_ be used to great effect in populating external tools with the data they need to perform these functions. + +## Design Philosophy + +NetBox was designed with the following tenets foremost in mind. + +### Replicate the Real World + +Careful consideration has been given to the data model to ensure that it can accurately reflect a real-world network. For instance, IP addresses are assigned not to devices, but to specific interfaces attached to a device, and an interface may have multiple IP addresses assigned to it. + +### Serve as a "Source of Truth" + +NetBox intends to represent the _desired_ state of a network versus its _operational_ state. As such, automated import of live network state is strongly discouraged. All data created in NetBox should first be vetted by a human to ensure its integrity. NetBox can then be used to populate monitoring and provisioning systems with a high degree of confidence. + +### Keep it Simple + +When given a choice between a relatively simple [80% solution](https://en.wikipedia.org/wiki/Pareto_principle) and a much more complex complete solution, the former will typically be favored. This ensures a lean codebase with a low learning curve. + +## Application Stack + +NetBox is built on the [Django](https://djangoproject.com/) Python framework and utilizes a [PostgreSQL](https://www.postgresql.org/) database. It runs as a WSGI service behind your choice of HTTP server. + +| Function | Component | +|--------------------|-------------------| +| HTTP service | nginx or Apache | +| WSGI service | gunicorn or uWSGI | +| Application | Django/Python | +| Database | PostgreSQL 10+ | +| Task queuing | Redis/django-rq | +| Live device access | NAPALM (optional) | diff --git a/docs/media/development/github.png b/docs/media/development/github.png new file mode 100644 index 000000000..6ac58f5fb Binary files /dev/null and b/docs/media/development/github.png differ diff --git a/docs/media/development/github_new_issue.png b/docs/media/development/github_new_issue.png new file mode 100644 index 000000000..d97fd571d Binary files /dev/null and b/docs/media/development/github_new_issue.png differ diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index 9421f94fb..50637ab4e 100644 --- a/docs/models/circuits/circuit.md +++ b/docs/models/circuits/circuit.md @@ -1,19 +1,49 @@ # Circuits -A communications circuit represents a single _physical_ link connecting exactly two endpoints, commonly referred to as its A and Z terminations. A circuit in NetBox may have zero, one, or two terminations defined. It is common to have only one termination defined when you don't necessarily care about the details of the provider side of the circuit, e.g. for Internet access circuits. Both terminations would likely be modeled for circuits which connect one customer site to another. +A circuit represents a physical point-to-point data connection, typically used to interconnect sites across considerable distances (e.g. to deliver Internet connectivity). -Each circuit is associated with a provider and a user-defined type. For example, you might have Internet access circuits delivered to each site by one provider, and private MPLS circuits delivered by another. Each circuit must be assigned a circuit ID, each of which must be unique per provider. +## Fields -Each circuit is also assigned one of the following operational statuses: +### Provider -* Planned -* Provisioning -* Active -* Offline -* Deprovisioning -* Decommissioned +The [provider](./provider.md) to which this circuit belongs. -Circuits also have optional fields for annotating their installation date and commit rate, and may be assigned to NetBox tenants. +### Circuit ID -!!! note - NetBox currently models only physical circuits: those which have exactly two endpoints. It is common to layer virtualized constructs (_virtual circuits_) such as MPLS or EVPN tunnels on top of these, however NetBox does not yet support virtual circuit modeling. +An identifier for this circuit. This must be unique to the assigned provider. (Circuits assigned to different providers may have the same circuit ID.) + +### Circuit Type + +Each circuit is classified by a user-defined [circuit type](./circuittype.md). Generally this is something like "Internet access," "MPLS/VPN," etc. + +### Status + +The operational status of the circuit. By default, the following statuses are available: + +| Name | +|----------------| +| Planned | +| Provisioning | +| Active | +| Offline | +| Deprovisioning | +| Decommissioned | + +!!! tip "Custom circuit statuses" + Additional circuit statuses may be defined by setting `Circuit.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Description + +A brief description of the circuit. + +### Installation Date + +The date on which the circuit was installed. + +### Termination Date + +The date on which the circuit is scheduled to be disconnected. + +### Commit Rate + +The committed rate (throughput) of the circuit, in kilobits per second. diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index beea2f85a..c6aa966d0 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -1,10 +1,46 @@ # Circuit Terminations -The association of a circuit with a particular site and/or device is modeled separately as a circuit termination. A circuit may have up to two terminations, labeled A and Z. A single-termination circuit can be used when you don't know (or care) about the far end of a circuit (for example, an Internet access circuit which connects to a transit provider). A dual-termination circuit is useful for tracking circuits which connect two sites. - -Each circuit termination is attached to either a site or to a provider network. Site terminations may optionally be connected via a cable to a specific device interface or port within that site. Each termination must be assigned a port speed, and can optionally be assigned an upstream speed if it differs from the downstream speed (a common scenario with e.g. DOCSIS cable modems). Fields are also available to track cross-connect and patch panel details. +Each circuit may have up to two terminations, designated A and Z. At either termination, a circuit may connect to a site, device interface (via a cable), or to a provider network. In adherence with NetBox's philosophy of closely modeling the real world, a circuit may be connected only to a physical interface. For example, circuits may not terminate to LAG interfaces, which are virtual in nature. In such cases, a separate physical circuit is associated with each LAG member interface and each needs to be modeled discretely. !!! note A circuit in NetBox represents a physical link, and cannot have more than two endpoints. When modeling a multi-point topology, each leg of the topology must be defined as a discrete circuit, with one end terminating within the provider's infrastructure. The provider network model is ideal for representing these networks. + +## Fields + +### Circuit + +The [circuit](./circuit.md) to which this termination belongs. + +### Termination Side + +Designates the termination as forming either the A or Z end of the circuit. + +### Mark Connected + +If selected, the circuit termination will be considered "connected" even if no cable has been connected to it in NetBox. + +### Site + +The [site](../dcim/site.md) with which this circuit termination is associated. Once created, a cable can be connected between the circuit termination and a device interface (or similar component). + +### Provider Network + +Circuits which do not connect to a site modeled by NetBox can instead be terminated to a [provider network](./providernetwork.md) representing an unknown network operated by a [provider](./provider.md). + +### Port Speed + +The operating speed of the terminated interface, in kilobits per second. This is useful for documenting the speed of a circuit when the actual interface to which it terminates is not being modeled in NetBox. + +### Upstream Speed + +The upstream speed of the terminated interface (in kilobits per second), if different from the downstream speed (a common scenario with e.g. DOCSIS cable modems). + +### Cross-connect ID + +In a data center environment, circuits are often delivered via a local cross-connect. While it may not be appropriate to model the cross-connect itself in NetBox, it's a good idea to record its ID for reference where applicable. + +### Patch Panel & Port(s) + +Similar to the cross-connect ID, this field can be used to track physical connection details which may be outside the scope of what is being modeled in NetBox. diff --git a/docs/models/circuits/circuittype.md b/docs/models/circuits/circuittype.md index aa8258e04..d0baa0f89 100644 --- a/docs/models/circuits/circuittype.md +++ b/docs/models/circuits/circuittype.md @@ -1,8 +1,18 @@ # Circuit Types -Circuits are classified by functional type. These types are completely customizable, and are typically used to convey the type of service being delivered over a circuit. For example, you might define circuit types for: +[Circuits](./circuit.md) are classified by functional type. These types are completely customizable, and are typically used to convey the type of service being delivered over a circuit. For example, you might define circuit types for: * Internet transit * Out-of-band connectivity * Peering * Private backhaul + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/circuits/provider.md b/docs/models/circuits/provider.md index e0847b72f..a4835199e 100644 --- a/docs/models/circuits/provider.md +++ b/docs/models/circuits/provider.md @@ -1,5 +1,40 @@ # Providers -A circuit provider is any entity which provides some form of connectivity of among sites or organizations within a site. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. Each circuit within NetBox must be assigned a provider and a circuit ID which is unique to that provider. +A provider is any entity which provides some form of connectivity of among sites or organizations within a site. While this obviously includes carriers which offer Internet and private transit service, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. Each [circuit](./circuit.md) within NetBox must be assigned a provider and a circuit ID which is unique to that provider. -Each provider may be assigned an autonomous system number (ASN), an account number, and contact information. +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### ASN + +The AS number assigned to this provider. + +!!! warning "Legacy field" + This field is being removed in NetBox v3.4. Users are highly encouraged to use the [ASN model](../ipam/asn.md) to track AS number assignment for providers. + +### ASNs + +The [AS numbers](../ipam/asn.md) assigned to this provider (optional). + +### Account Number + +The administrative account identifier tied to this provider for your organization. + +### Portal URL + +The URL for the provider's customer service portal. + +### NOC Contact + +Contact details for the provider's network operations center (NOC). + +### Admin Contact + +Administrative contact details for the provider. diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md index 42c46e13c..90db02573 100644 --- a/docs/models/circuits/providernetwork.md +++ b/docs/models/circuits/providernetwork.md @@ -2,4 +2,16 @@ This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. -Each provider network must be assigned to a provider, and may optionally be assigned an arbitrary service ID. A circuit may terminate to either a provider network or to a site. +## Fields + +### Provider + +The [provider](./provider.md) responsible for the operation of this network. + +### Name + +A human-friendly name, unique to the provider. + +### Service ID + +An arbitrary identifier used as an alternate reference for the type of connectivity or service being delivered. diff --git a/docs/models/dcim/cable.md b/docs/models/dcim/cable.md index 43c0abfab..20f6c03c7 100644 --- a/docs/models/dcim/cable.md +++ b/docs/models/dcim/cable.md @@ -1,24 +1,42 @@ # Cables -All connections between device components in NetBox are represented using cables. A cable represents a direct physical connection between two termination points, such as between a console port and a patch panel port, or between two network interfaces. +All connections between device components in NetBox are represented using cables. A cable represents a direct physical connection between two sets of endpoints (A and B), such as a console port and a patch panel port, or between two network interfaces. Cables may be connected to the following objects: -Each cable must have two endpoints defined. These endpoints are sometimes referenced as A and B for clarity, however cables are direction-agnostic and the order in which terminations are made has no meaning. Cables may be connected to the following objects: - -* Circuit terminations +* Network interfaces * Console ports * Console server ports -* Interfaces * Pass-through ports (front and rear) -* Power feeds -* Power outlets +* Circuit terminations * Power ports +* Power outlets +* Power feeds -Each cable may be assigned a type, label, length, and color. Each cable is also assigned one of three operational statuses: +## Fields + +### Status + +The cable's operational status. Choices include: * Active (default) * Planned * Decommissioning +### Type + +The cable's physical medium or classification. + +### Label + +An arbitrary label used to identify the cable. + +### Color + +The color of the cable. + +### Length + +The numeric length of the cable, including a unit designation (e.g. 100 meters or 25 feet). + ## Tracing Cables -A cable may be traced from either of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user. +A cable may be traced from any of its endpoints by clicking the "trace" button. (A REST API endpoint also provides this functionality.) NetBox will follow the path of connected cables from this termination across the directly connected cable to the far-end termination. If the cable connects to a pass-through port, and the peer port has another cable connected, NetBox will continue following the cable path until it encounters a non-pass-through or unconnected termination point. The entire path will be displayed to the user. diff --git a/docs/models/dcim/consoleport.md b/docs/models/dcim/consoleport.md index 1a0782f25..e9cd09836 100644 --- a/docs/models/dcim/consoleport.md +++ b/docs/models/dcim/consoleport.md @@ -1,5 +1,36 @@ -## Console Ports +# Console Ports -A console port provides connectivity to the physical console of a device. These are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access provided via a networked console server. Each console port may be assigned a physical type. +A console port provides connectivity to the physical console of a device. These are typically used for temporary access by someone who is physically near the device, or for remote out-of-band access provided via a networked console server. -Cables can connect console ports to console server ports or pass-through ports. +!!! tip + Like most device components, console ports are instantiated automatically from [console port templates](./consoleporttemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this console port belongs. + +### Module + +The installed module within the assigned device to which this console port belongs (optional). + +### Name + +The name of the console port. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the console port. + +### Type + +The type of console port. + +### Speed + +Operating speed, in bits per second (bps). + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/consoleporttemplate.md b/docs/models/dcim/consoleporttemplate.md index 3462ff253..67d91de68 100644 --- a/docs/models/dcim/consoleporttemplate.md +++ b/docs/models/dcim/consoleporttemplate.md @@ -1,3 +1,3 @@ -## Console Port Templates +# Console Port Templates -A template for a console port that will be created on all instantiations of the parent device type. Each console port can be assigned a physical type. +A template for a console port that will be created on all instantiations of the parent device type. See the [console port](./consoleport.md) documentation for more detail. diff --git a/docs/models/dcim/consoleserverport.md b/docs/models/dcim/consoleserverport.md index da1ee8986..5c45451db 100644 --- a/docs/models/dcim/consoleserverport.md +++ b/docs/models/dcim/consoleserverport.md @@ -1,5 +1,36 @@ -## Console Server Ports +# Console Server Ports -A console server is a device which provides remote access to the local consoles of connected devices. They are typically used to provide remote out-of-band access to network devices. Each console server port may be assigned a physical type. +A console server is a device which provides remote access to the local consoles of connected devices. They are typically used to provide remote out-of-band access to network devices, and generally connect to [console ports](./consoleport.md). -Cables can connect console server ports to console ports or pass-through ports. +!!! tip + Like most device components, console server ports are instantiated automatically from [console server port templates](./consoleserverporttemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this console server port belongs. + +### Module + +The installed module within the assigned device to which this console server port belongs (optional). + +### Name + +The name of the console server port. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the console server port. + +### Type + +The type of console server port. + +### Speed + +Operating speed, in bits per second (bps). + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/consoleserverporttemplate.md b/docs/models/dcim/consoleserverporttemplate.md index cc4e8bcd3..c599e738a 100644 --- a/docs/models/dcim/consoleserverporttemplate.md +++ b/docs/models/dcim/consoleserverporttemplate.md @@ -1,3 +1,3 @@ -## Console Server Port Templates +# Console Server Port Templates -A template for a console server port that will be created on all instantiations of the parent device type. Each console server port can be assigned a physical type. +A template for a console server port that will be created on all instantiations of the parent device type. See the [console server port](./consoleserverport.md) documentation for more detail. diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index a99078472..33d07e07e 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -8,8 +8,93 @@ A device is said to be full-depth if its installation on one rack face prevents Each device must be instantiated from a pre-created device type, and its default components (console ports, power ports, interfaces, etc.) will be created automatically. (The device type associated with a device may be changed after its creation, however its components will not be updated retroactively.) -Each device must be assigned a site, device role, and operational status, and may optionally be assigned to a specific location and/or rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. - Device names must be unique within a site, unless the device has been assigned to a tenant. Devices may also be unnamed. When a device has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6. + +## Fields + +### Name + +The device's configured name. This field is optional; devices can be unnamed. However, if set, the name must be unique to the assigned site and tenant. + +### Device Role + +The functional [role](./devicerole.md) assigned to this device. + +### Device Type + +The hardware [device type](./devicetype.md) which defines the device's make & model. Upon creating, all templated components assigned to the device type will be replicated on the new device. + +### Airflow + +The direction in which air circulates through the device chassis for cooling. + +### Serial Number + +The unique physical serial number assigned to this device by its manufacturer. + +### Asset Tag + +A unique, locally-administered label used to identify hardware resources. + +### Site + +The [site](./site.md) in which this device is located. + +### Location + +A specific [location](./location.md) where this device resides within the assigned site (optional). + +### Rack + +The [rack](./rack.md) within which this device is installed (optional). + +### Rack Face + +If installed in a rack, this field denotes the primary face on which the device is mounted. + +### Position + +If installed in a rack, this field indicates the base rack unit in which the device is mounted. + +!!! tip + Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. + +### Status + +The device's operational status. + +!!! tip + Additional statuses may be defined by setting `Device.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Platform + +A device may be associated with a particular [platform](./platform.md) to indicate its operating system. Note that only platforms assigned to the associated manufacturer (or to no manufacturer) will be available for selection. + +### Primary IPv4 & IPv6 Addresses + +Each device may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. + +!!! tip + NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. + +### Cluster + +If this device will serve as a host for a virtualization [cluster](../virtualization/cluster.md), it can be assigned here. (Host devices can also be assigned by editing the cluster.) + +### Virtual Chassis + +The [virtual chassis](./virtualchassis.md) of which this device is a member, if any. + +### VC Position + +If assigned to a [virtual chassis](./virtualchassis.md), this field indicates the device's member position. + +### VC Priority + +If assigned to a [virtual chassis](./virtualchassis.md), this field indicates the device's priority for master election. + +### Local Config Context Data + +Any unique [context data](../../features/context-data.md) to be associated with the device. diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index e79c426dc..5bbb125f8 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -1,8 +1,25 @@ -## Device Bays +# Device Bays Device bays represent a space or slot within a parent device in which a child device may be installed. For example, a 2U parent chassis might house four individual blade servers. The chassis would appear in the rack elevation as a 2U device with four device bays, and 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 count as consuming rack units. Child devices are first-class Devices in their own right: That is, they are 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 components. LAG interfaces may not group interfaces belonging to different child devices. !!! note - Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as modules installed within module bays. + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as [modules](./module.md) installed within [module bays](./modulebay.md). + +!!! tip + Like most device components, device bays are instantiated automatically from [device bay templates](./devicebaytemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this device bay belongs. + +### Name + +The device bay's name. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the device bay. diff --git a/docs/models/dcim/devicebaytemplate.md b/docs/models/dcim/devicebaytemplate.md index a4c50067a..060b799c0 100644 --- a/docs/models/dcim/devicebaytemplate.md +++ b/docs/models/dcim/devicebaytemplate.md @@ -1,3 +1,3 @@ -## Device Bay Templates +# Device Bay Templates -A template for a device bay that will be created on all instantiations of the parent device type. Device bays hold child devices, such as blade servers. +A template for a device bay that will be created on all instantiations of the parent device type. See the [device bay](./devicebay.md) documentation for more detail. diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index 13b8f021e..e9bdc0fa6 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -1,3 +1,21 @@ # Device Roles Devices can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for core switches, distribution switches, and access switches within your network. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Color + +The color used when displaying the role in the NetBox UI. + +### VM Role + +If selected, this role may be assigned to [virtual machines](../virtualization/virtualmachine.md) diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index cf42185f4..050f93244 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -4,13 +4,43 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively. -Some devices house child devices which share physical resources, like space and power, but which function independently. A common example of this is blade server chassis. Each device type is designated as one of the following: - -* A parent device (which has device bays) -* A child device (which must be installed within a device bay) -* Neither - !!! note This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. -A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. +## Fields + +### Manufacturer + +The [manufacturer](./manufacturer.md) which produces this type of device. + +### Model + +The model number assigned to this device type by its manufacturer. Must be unique to the manufacturer. + +### Slug + +A unique URL-friendly representation of the model identifier. (This value can be used for filtering.) + +### Part Number + +An alternative part number to uniquely identify the device type. + +### Height + +The height of the physical device in rack units. (For device types that are not rack-mountable, this should be `0`.) + +### Is Full Depth + +If selected, this device type is considered to occupy both the front and rear faces of a rack, regardless of which face it is assigned. + +### Parent/Child Status + +Indicates whether this is a parent type (capable of housing child devices), a child type (which must be installed within a device bay), or neither. + +### Airflow + +The default direction in which airflow circulates within the device chassis. This may be configured differently for instantiated devices (e.g. because of different fan modules). + +### Front & Rear Images + +Users can upload illustrations of the device's front and rear panels. If present, these will be used to render the device in [rack](./rack.md) elevation diagrams. diff --git a/docs/models/dcim/frontport.md b/docs/models/dcim/frontport.md index 6f12e8cbf..7ea617250 100644 --- a/docs/models/dcim/frontport.md +++ b/docs/models/dcim/frontport.md @@ -1,3 +1,43 @@ -## Front Ports +# Front Ports -Front ports are pass-through ports used to represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific rear port on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each. +Front ports are pass-through ports which represent physical cable connections that comprise part of a longer path. For example, the ports on the front face of a UTP patch panel would be modeled in NetBox as front ports. Each port is assigned a physical type, and must be mapped to a specific [rear port](./rearport.md) on the same device. A single rear port may be mapped to multiple front ports, using numeric positions to annotate the specific alignment of each. + +!!! tip + Like most device components, front ports are instantiated automatically from [front port templates](./frontporttemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this port belongs. + +### Module + +The installed module within the assigned device to which this port belongs (optional). + +### Name + +The port's name. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the port. + +### Type + +The port's termination type. + +### Rear Ports + +The rear port and position to which this front port maps. + +!!! tip + When creating multiple front ports using a patterned name (e.g. `Port [1-12]`), you may select the equivalent number of rear port-position mappings from the list. + +### Color + +The port's color (optional). + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/frontporttemplate.md b/docs/models/dcim/frontporttemplate.md index 03de0eae4..09535e3b6 100644 --- a/docs/models/dcim/frontporttemplate.md +++ b/docs/models/dcim/frontporttemplate.md @@ -1,3 +1,3 @@ -## Front Port Templates +# Front Port Templates -A template for a front-facing pass-through port that will be created on all instantiations of the parent device type. Front ports may have a physical type assigned, and must be associated with a corresponding rear port and position. This association will be automatically replicated when the device type is instantiated. +A template for a front-facing pass-through port that will be created on all instantiations of the parent device type. See the [front port](./frontport.md) documentation for more detail. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 7fa52fa9f..42b570964 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -1,27 +1,141 @@ -## Interfaces +# Interfaces -Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Additionally, each interface may optionally be assigned to a VRF. +Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. IP addresses and VLANs can be assigned to interfaces. + +!!! tip + Like most device components, interfaces are instantiated automatically from [interface templates](./interfacetemplate.md) assigned to the selected device type when a device is created. !!! note - Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. + Although both devices and virtual machines both can have interfaces assigned, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. -### Interface Types +## Fields -Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. Cables can connect interfaces to pass-through ports, circuit terminations, or other interfaces. Virtual interfaces, such as 802.1Q-tagged subinterfaces, may be assigned to physical parent interfaces. +### Device -Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. +The device to which this interface belongs. -### Wireless Interfaces +### Module -Wireless interfaces may additionally track the following attributes: +The installed module within the assigned device to which this interface belongs (optional). -* **Role** - AP or station -* **Channel** - One of several standard wireless channels -* **Channel Frequency** - The transmit frequency -* **Channel Width** - Channel bandwidth +### Name -If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually. +The name of the interface, as reported by the device's operating system. Must be unique to the parent device. -### IP Address Assignment +### Label -IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.) +An alternative physical label identifying the interface. + +### Type + +The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. + +!!! note + The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver. + +### Speed + +The operating speed, in kilobits per second (kbps). + +### Duplex + +The operation duplex (full, half, or auto). + +### VRF + +The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. + +### MAC Address + +The 48-bit MAC address (for Ethernet interfaces). + +### WWN + +The 64-bit world-wide name (for Fibre Channel interfaces). + +### MTU + +The interface's configured maximum transmissible unit (MTU). + +### Transmit Power + +The interface's configured output power, in dBm (for optical interfaces). + +### Enabled + +If not selected, this interface will be treated as disabled/inoperative. + +### Management Only + +Designates the interface as handling management traffic only (e.g. for out-of-band management connections). + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. + +### Parent Interface + +Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. + +### Bridged Interface + +Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped. + +* **Symmetric:** For example, eth0 is bridged to eth1, and eth1 is bridged to eth0. This effects a point-to-point bridge between the two interfaces, which NetBox will follow when tracing cable paths. +* **Grouped:** Multiple interfaces are each bridged to a common virtual bridge interface, effecting a multiaccess bridged segment. NetBox cannot follow these relationships when tracing cable paths, because no forwarding information is available. + +### LAG Interface + +Physical interfaces may be arranged into link aggregation groups (LAGs, also known as "trunks") and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically. + +### PoE Mode + +The power over Ethernet (PoE) mode for this interface. (This field must be left empty for interfaces which do not support PoE.) Choices include: + +* Powered device (PD) +* Power-supplying equipment (PSE) + +### PoE Type + +The classification of PoE transmission supported, for PoE-enabled interfaces. This can be one of the listed IEEE 802.3 standards, or a passive setting (24 or 48 volts across two or four pairs). + +### 802.1Q Mode + +For switched Ethernet interfaces, this identifies the 802.1Q encapsulation strategy in effect. Options include: + +* **Access:** All traffic is assigned to a single VLAN, with no tagging. +* **Tagged:** One untagged "native" VLAN is allowed, as well as any number of tagged VLANs. +* **Tagged (all):** Implies that all VLANs are carried by the interface. One untagged VLAN may be designated. + +This field must be left blank for routed interfaces which do employ 802.1Q encapsulation. + +### Untagged VLAN + +The "native" (untagged) VLAN for the interface. Valid only when one of the above 802.1Q mode is selected. + +### Tagged VLANs + +The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. + +### Wireless Role + +Indicates the configured role for wireless interfaces (access point or station). + +### Wireless Channel + +The configured channel for wireless interfaces. + +!!! tip + Selecting one of the pre-defined wireless channels will automatically populate the channel frequency and width upon saving the interface. + +### Channel Frequency + +The configured operation frequency of a wireless interface, in MHz. This is typically inferred by the configured channel above, but may be set manually e.g. to identify a licensed channel not available for general use. + +### Channel Width + +The configured channel width of a wireless interface, in MHz. This is typically inferred by the configured channel above, but may be set manually e.g. to identify a licensed channel not available for general use. + +### Wireless LANs + +The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries traffic. (Valid for wireless interfaces only.) diff --git a/docs/models/dcim/interfacetemplate.md b/docs/models/dcim/interfacetemplate.md index d9b30dd87..898c4843f 100644 --- a/docs/models/dcim/interfacetemplate.md +++ b/docs/models/dcim/interfacetemplate.md @@ -1,3 +1,3 @@ -## Interface Templates +# Interface Templates -A template for a network interface that will be created on all instantiations of the parent device type. Each interface may be assigned a physical or virtual type, and may be designated as "management-only." +A template for a network interface that will be created on all instantiations of the parent device type. See the [interface](./interface.md) documentation for more detail. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index fbd3172bb..f61586eda 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -2,6 +2,45 @@ Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. -Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside NetBox). - Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. + +!!! tip + Like most device components, inventory items can be instantiated automatically from [templates](./inventoryitemtemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device in which the inventory item is installed. + +### Parent + +The parent inventory item to which this item is assigned (optional). + +### Name + +The inventory item's name. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the inventory item. + +### Role + +The functional [role](./inventoryitemrole.md) assigned to this inventory item. + +### Manufacturer + +The [manufacturer](./manufacturer.md) that produced the item. + +### Part ID + +The part identification or model number assigned by the manufacturer. + +### Serial Number + +The serial number assigned by the manufacturer. + +### Asset Tag + +A unique, locally-administered label used to identify hardware resources. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md index 8ed31481a..50eb61abd 100644 --- a/docs/models/dcim/inventoryitemrole.md +++ b/docs/models/dcim/inventoryitemrole.md @@ -1,3 +1,17 @@ # Inventory Item Roles Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Color + +The color used when displaying the role in the NetBox UI. diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md index 3167ed4ab..02fde5995 100644 --- a/docs/models/dcim/inventoryitemtemplate.md +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -1,3 +1,3 @@ # Inventory Item Templates -A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. +A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. See the [inventory item](./inventoryitem.md) documentation for more detail. diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md index 901a68acf..96ab13039 100644 --- a/docs/models/dcim/location.md +++ b/docs/models/dcim/location.md @@ -1,6 +1,28 @@ # Locations -Racks and devices can be grouped by location within a site. A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. +[Racks](./rack.md) and [devices](./device.md) can be grouped by location within a [site](./site.md). A location may represent a floor, room, cage, or similar organizational unit. Locations can be nested to form a hierarchy. For example, you may have floors within a site, and rooms within a floor. -Each location must have a name that is unique within its parent site and location, if any. +## Fields +### Site + +The parent [site](./site.md) to which this location belongs. + +### Parent + +The parent location of which this location is a child (optional). + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Status + +The location's operational status. + +!!! tip + Additional statuses may be defined by setting `Location.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. diff --git a/docs/models/dcim/manufacturer.md b/docs/models/dcim/manufacturer.md index df227ee17..6beee5a90 100644 --- a/docs/models/dcim/manufacturer.md +++ b/docs/models/dcim/manufacturer.md @@ -1,3 +1,13 @@ # Manufacturers -A manufacturer represents the "make" of a device; e.g. Cisco or Dell. Each device type must be assigned to a manufacturer. (Inventory items and platforms may also be associated with manufacturers.) Each manufacturer must have a unique name and may have a description assigned to it. +A manufacturer represents the "make" of a device; e.g. Cisco or Dell. Each [device type](./devicetype.md) must be assigned to a manufacturer. ([Inventory items](./inventoryitem.md) and [platforms](./platform.md) may also be associated with manufacturers.) + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md index bc9753ecc..c90430faa 100644 --- a/docs/models/dcim/module.md +++ b/docs/models/dcim/module.md @@ -2,4 +2,34 @@ A module is a field-replaceable hardware component installed within a device which houses its own child components. The most common example is a chassis-based router or switch. -Similar to devices, modules are instantiated from module types, and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a module bay on a device, and each module bay may have only one module installed in it. A module may optionally be assigned a serial number and asset tag. +Similar to devices, modules are instantiated from [module types](./moduletype.md), and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a [module bay](./modulebay.md) on a [device](./device.md), and each module bay may have only one module installed in it. + +## Fields + +### Device + +The parent [device](./device.md) into which the module is installed. + +### Module Bay + +The [module bay](./modulebay.md) into which the module is installed. + +### Module Type + +The [module type](./moduletype.md) which represents the physical make & model of hardware. By default, module components will be instantiated automatically from the module type when creating a new module. + +### Serial Number + +The unique physical serial number assigned to this module by its manufacturer. + +### Asset Tag + +A unique, locally-administered label used to identify hardware resources. + +### Replicate Components + +Controls whether templates module type components are automatically added when creating a new module. + +### Adopt Components + +Controls whether pre-existing components assigned to the device with the same names as components that would be created automatically will be assigned to the new module. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md index 6c6f94598..c77909511 100644 --- a/docs/models/dcim/modulebay.md +++ b/docs/models/dcim/modulebay.md @@ -1,3 +1,27 @@ -## Module Bays +# Module Bays -Module bays represent a space or slot within a device in which a field-replaceable module may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. +Module bays represent a space or slot within a device in which a field-replaceable [module](./module.md) may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. + +!!! note + If you need to model child devices rather than modules, use a [device bay](./devicebay.md) instead. + +!!! tip + Like most device components, module bays are instantiated automatically from [module bay templates](./modulebaytemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this module bay belongs. + +### Name + +The module bay's name. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the module bay. + +### Position + +The numeric position in which this module bay is situated. For example, this would be the number assigned to a slot within a chassis-based switch. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md index 463789305..3d5845d2e 100644 --- a/docs/models/dcim/modulebaytemplate.md +++ b/docs/models/dcim/modulebaytemplate.md @@ -1,3 +1,3 @@ -## Module Bay Templates +# Module Bay Templates -A template for a module bay that will be created on all instantiations of the parent device type. Module bays hold installed modules that do not have an independent management plane, such as line cards. +A template for a module bay that will be created on all instantiations of the parent device type. See the [module bay](./modulebay.md) documentation for more detail. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index c1c8c5079..b8ec0ac6e 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -1,8 +1,8 @@ # Module Types -A module type represent a specific make and model of hardware component which is installable within a device and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. +A module type represents a specific make and model of hardware component which is installable within a device's [module bay](./modulebay.md) and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. -Similar to device types, each module type can have any of the following component templates associated with it: +Similar to [device types](./devicetype.md), each module type can have any of the following component templates associated with it: * Interfaces * Console ports @@ -21,3 +21,17 @@ When adding component templates to a module type, the string `{module}` can be u For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. Automatic renaming is supported for all modular component types (those listed above). + +## Fields + +### Manufacturer + +The [manufacturer](./manufacturer.md) which produces this type of module. + +### Model + +The model number assigned to this module type by its manufacturer. Must be unique to the manufacturer. + +### Part Number + +An alternative part number to uniquely identify the module type. diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 347abc5b8..d080f74a4 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -1,9 +1,31 @@ # Platforms -A platform defines the type of software running on a device or virtual machine. This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15. +A platform defines the type of software running on a [device](./device.md) or [virtual machine](../virtualization/virtualmachine.md). This can be helpful to model when it is necessary to distinguish between different versions or feature sets. Note that two devices of the same type may be assigned different platforms: For example, one Juniper MX240 might run Junos 14 while another runs Junos 15. -Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer. +Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer. -The platform model is also used to indicate which NAPALM driver (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. +The platform model is also used to indicate which [NAPALM driver](../../integrations/napalm.md) (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Manufacturer + +If designated, this platform will be available for use only to devices assigned to this [manufacturer](./manufacturer.md). This can be handy e.g. for limiting network operating systems to use on hardware produced by the relevant vendor. However, it should not be used when defining general-purpose software platforms. + +### NAPALM Driver + +The [NAPALM driver](https://napalm.readthedocs.io/en/latest/support/index.html) associated with this platform. + +### NAPALM Arguments + +Any additional arguments to send when invoking the NAPALM driver assigned to this platform. diff --git a/docs/models/dcim/powerfeed.md b/docs/models/dcim/powerfeed.md index bac7214f1..f98c758ff 100644 --- a/docs/models/dcim/powerfeed.md +++ b/docs/models/dcim/powerfeed.md @@ -1,21 +1,55 @@ # Power Feed -A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power port (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks. +A power feed represents the distribution of power from a [power panel](./powerpanel.md) to a particular [device](./device.md), typically a power distribution unit (PDU). The [power port](./powerport.md) (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks. -Each power feed is assigned an operational type (primary or redundant) and one of the following statuses: +## Fields -* Offline -* Active -* Planned -* Failed +### Power Panel -Each power feed also defines the electrical characteristics of the circuit which it represents. These include the following: +The [power panel](./powerpanel.md) which supplies upstream power to this feed. -* Supply type (AC or DC) -* Phase (single or three-phase) -* Voltage -* Amperage -* Maximum utilization (percentage) +### Rack + +The [rack](./rack.md) within which this feed delivers power (optional). + +### Name + +The feed's name or other identifier. Must be unique to the assigned power panel. + +### Status + +The feed's operational status. + +!!! tip + Additional statuses may be defined by setting `PowerFeed.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Type + +In redundant environments, each power feed can be designated as providing either primary or redundant power. (In environment with only one power source, all power feeds should be designated as primary.) + +### Mark Connected + +If selected, the power feed will be treated as if a cable has been connected. + +### Supply + +Electrical current type (AC or DC). + +### Voltage + +Operating circuit voltage, in volts. + +### Amperage + +Operation circuit amperage, in amperes. + +### Phase + +Indicates whether the circuit provides single- or three-phase power. + +### Max Utilization + +The maximum safe utilization of the feed, expressed as a percentage of the total available power. (Typically this will be set to around 80%, to avoid tripping a breaker during heaving spikes in current draw.) !!! info The power utilization of a rack is calculated when one or more power feeds are assigned to the rack and connected to devices that draw power. diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index e9ef307bd..5c8bd6ff0 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -1,7 +1,42 @@ -## Power Outlets +# Power Outlets -Power outlets represent the outlets on a power distribution unit (PDU) or other device that supply power to dependent devices. Each power port may be assigned a physical type, and may be associated with a specific feed leg (where three-phase power is used) and/or a specific upstream power port. This association can be used to model the distribution of power within a device. +Power outlets represent the outlets on a power distribution unit (PDU) or other device that supplies power to dependent devices. Each power port may be assigned a physical type, and may be associated with a specific feed leg (where three-phase power is used) and/or a specific upstream power port. This association can be used to model the distribution of power within a device. For example, imagine a PDU with one power port which draws from a three-phase feed and 48 power outlets arranged into three banks of 16 outlets each. Outlets 1-16 would be associated with leg A on the port, and outlets 17-32 and 33-48 would be associated with legs B and C, respectively. -Cables can connect power outlets only to downstream power ports. (Pass-through ports cannot be used to model power distribution.) +!!! tip + Like most device components, power outlets are instantiated automatically from [power outlet templates](./poweroutlettemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this power outlet belongs. + +### Module + +The installed module within the assigned device to which this power outlet belongs (optional). + +### Name + +The name of the power outlet. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the power outlet. + +### Type + +The type of power outlet. + +### Power Port + +When modeling a device which redistributes power from an upstream supply, such as a power distribution unit (PDU), each power outlet should be mapped to the respective [power port](./powerport.md) on the device which supplies power. For example, a 24-outlet PDU may two power ports, each distributing power to 12 of its outlets. + +### Feed Leg + +This field is used to indicate to which leg of three-phase power circuit the outlet is bound. (This should be left blank for single-phase applications.) + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/poweroutlettemplate.md b/docs/models/dcim/poweroutlettemplate.md index 6f81891f1..0803ede97 100644 --- a/docs/models/dcim/poweroutlettemplate.md +++ b/docs/models/dcim/poweroutlettemplate.md @@ -1,3 +1,3 @@ -## Power Outlet Templates +# Power Outlet Templates -A template for a power outlet that will be created on all instantiations of the parent device type. Each power outlet can be assigned a physical type, and its power source may be mapped to a specific feed leg and power port template. This association will be automatically replicated when the device type is instantiated. +A template for a power outlet that will be created on all instantiations of the parent device type. See the [power outlet](./poweroutlet.md) documentation for more detail. diff --git a/docs/models/dcim/powerpanel.md b/docs/models/dcim/powerpanel.md index 813321179..dd02a57f0 100644 --- a/docs/models/dcim/powerpanel.md +++ b/docs/models/dcim/powerpanel.md @@ -1,8 +1,20 @@ # Power Panel -A power panel represents the origin point in NetBox for electrical power being disseminated by one or more power feeds. In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack. - -Each power panel must be assigned to a site, and may optionally be assigned to a particular location within that site. +A power panel represents the origin point in NetBox for electrical power being disseminated by one or more [power feeds](./powerfeed.md). In a data center environment, one power panel often serves a group of racks, with an individual power feed extending to each rack, though this is not always the case. It is common to have two sets of panels and feeds arranged in parallel to provide redundant power to each rack. !!! note NetBox does not model the mechanism by which power is delivered to a power panel. Power panels define the root level of the power distribution hierarchy in NetBox. + +## Fields + +### Site + +The [site](./site.md) in which the power panel resides. + +### Location + +A specific [location](./location.md) within the assigned site where the power panel is installed. + +### Name + +The power panel's name. Must be unique to the assigned site. diff --git a/docs/models/dcim/powerport.md b/docs/models/dcim/powerport.md index 1948920d0..7cc1dd60a 100644 --- a/docs/models/dcim/powerport.md +++ b/docs/models/dcim/powerport.md @@ -1,8 +1,43 @@ -## Power Ports +# Power Ports -A power port represents the inlet of a device where it draws its power, i.e. the connection port(s) on a device's power supply. Each power port may be assigned a physical type, as well as allocated and maximum draw values (in watts). These values can be used to calculate the overall utilization of an upstream power feed. +A power port is a device component which draws power from some external source (e.g. an upstream [power outlet](./poweroutlet.md)), and generally represents a power supply internal to a device. + +!!! tip + Like most device components, power ports are instantiated automatically from [power port templates](./powerporttemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this power port belongs. + +### Module + +The installed module within the assigned device to which this power port belongs (optional). + +### Name + +The name of the power port. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the power port. + +### Type + +The type of power port. + +### Maximum Draw + +The maximum amount of power this port consumes (in watts). !!! info - When creating a power port on a device which supplies power to downstream devices, the allocated and maximum draw numbers should be left blank. Utilization will be calculated by taking the sum of all power ports of devices connected downstream. + When creating a power port on a device which is mapped to outlets and supplies power to downstream devices, the maximum and allocated draw numbers should be left blank. Utilization will be calculated by taking the sum of all power ports of devices connected downstream. -Cables can connect power ports only to power outlets or power feeds. (Pass-through ports cannot be used to model power distribution.) +### Allocated Draw + +The budgeted amount of power this port consumes (in watts). + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/powerporttemplate.md b/docs/models/dcim/powerporttemplate.md index 947f146ae..32579f4d5 100644 --- a/docs/models/dcim/powerporttemplate.md +++ b/docs/models/dcim/powerporttemplate.md @@ -1,3 +1,3 @@ -## Power Port Templates +# Power Port Templates -A template for a power port that will be created on all instantiations of the parent device type. Each power port can be assigned a physical type, as well as a maximum and allocated draw in watts. +A template for a power port that will be created on all instantiations of the parent device type. See the [power port](./powerport.md) documentation for more detail. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 9465a828c..57e7bec98 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,12 +1,51 @@ # Racks -The rack model represents a physical two- or four-post equipment rack in which devices can be installed. Each rack must be assigned to a site, and may optionally be assigned to a location and/or tenant. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique. +The rack model represents a physical two- or four-post equipment rack in which [devices](./device.md) can be installed. Each rack must be assigned to a [site](./site.md), and may optionally be assigned to a [location](./location.md) within that site. Racks can also be organized by user-defined functional roles. The name and facility ID of each rack within a location must be unique. Rack height is measured in *rack units* (U); racks are commonly between 42U and 48U tall, but NetBox allows you to define racks of arbitrary height. A toggle is provided to indicate whether rack units are in ascending (from the ground up) or descending order. Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, "M204.313") whereas internally you refer to is simply as "R113." A unique serial number and asset tag may also be associated with each rack. -A rack must be designated as one of the following types: +## Fields + +### Site + +The [site](./site.md) to which the rack is assigned. + +### Location + +The [location](./location.md) within a site where the rack has been installed (optional). + +### Name + +The rack's name or identifier. Must be unique to the rack's location, if assigned. + +### Status + +Operational status. + +!!! tip + Additional statuses may be defined by setting `Rack.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Role + +The functional [role](./rackrole.md) fulfilled by the rack. + +### Facility ID + +An alternative identifier assigned to the rack e.g. by the facility operator. This is helpful for tracking datacenter rack designations in a colocation facility. + +### Serial Number + +The unique physical serial number assigned to this rack. + +### Asset Tag + +A unique, locally-administered label used to identify hardware resources. + +### Type + +A rack can be designated as one of the following types: * 2-post frame * 4-post frame @@ -14,12 +53,18 @@ A rack must be designated as one of the following types: * Wall-mounted frame * Wall-mounted cabinet -Similarly, each rack must be assigned an operational status, which is one of the following: +### Width -* Reserved -* Available -* Planned -* Active -* Deprecated +The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.) -Each rack has two faces (front and rear) on which devices can be mounted. Rail-to-rail width may be 10, 19, 21, or 23 inches. The outer width and depth of a rack or cabinet can also be annotated in millimeters or inches. +### Height + +The height of the rack, measured in units. + +### Outer Dimensions + +The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. + +### Descending Units + +If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use asceneding numbering, with unit 1 assigned to the bottommost position.) diff --git a/docs/models/dcim/rackreservation.md b/docs/models/dcim/rackreservation.md index 0ed9651a0..32d52c9d7 100644 --- a/docs/models/dcim/rackreservation.md +++ b/docs/models/dcim/rackreservation.md @@ -1,3 +1,21 @@ # Rack Reservations -Users can reserve specific units within a rack for future use. An arbitrary set of units within a rack can be associated with a single reservation, but reservations cannot span multiple racks. A description is required for each reservation, reservations may optionally be associated with a specific tenant. +Users can reserve specific units within a [rack](./rackreservation.md) for future use. An arbitrary set of units within a rack can be associated with a single reservation, but reservations cannot span multiple racks. A description is required for each reservation, reservations may optionally be associated with a specific tenant. + +## Fields + +### Rack + +The [rack](./rack.md) being reserved. + +### Units + +The rack unit or units being reserved. Multiple units can be expressed using commas and/or hyphens. For example, `1,3,5-7` specifies units 1, 3, 5, 6, and 7. + +### User + +The NetBox user account associated with the reservation. Note that users with sufficient permission can make rack reservations for other users. + +### Description + +Every rack reservation must include a description of its purpose. diff --git a/docs/models/dcim/rackrole.md b/docs/models/dcim/rackrole.md index 1375ce692..88f171af8 100644 --- a/docs/models/dcim/rackrole.md +++ b/docs/models/dcim/rackrole.md @@ -1,3 +1,17 @@ # Rack Roles -Each rack can optionally be assigned a user-defined functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. Rack roles are fully customizable and may be color-coded. +Each rack can optionally be assigned a user-defined functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Color + +The color used when displaying the role in the NetBox UI. diff --git a/docs/models/dcim/rearport.md b/docs/models/dcim/rearport.md index 41c5b3037..b23d4a29b 100644 --- a/docs/models/dcim/rearport.md +++ b/docs/models/dcim/rearport.md @@ -1,6 +1,43 @@ -## Rear Ports +# Rear Ports -Like front ports, rear ports are pass-through ports which represent the continuation of a path from one cable to the next. Each rear port is defined with its physical type and a number of positions: Rear ports with more than one position can be mapped to multiple front ports. This can be useful for modeling instances where multiple paths share a common cable (for example, six discrete two-strand fiber connections sharing a 12-strand MPO cable). +Like [front ports](./frontport.md), rear ports are pass-through ports which represent the continuation of a path from one cable to the next. Each rear port is defined with its physical type and a number of positions: Rear ports with more than one position can be mapped to multiple front ports. This can be useful for modeling instances where multiple paths share a common cable (for example, six discrete two-strand fiber connections sharing a 12-strand MPO cable). !!! note Front and rear ports need not necessarily reside on the actual front or rear device face. This terminology is used primarily to distinguish between the two components in a pass-through port pairing. + +!!! tip + Like most device components, rear ports are instantiated automatically from [rear port templates](./rearporttemplate.md) assigned to the selected device type when a device is created. + +## Fields + +### Device + +The device to which this port belongs. + +### Module + +The installed module within the assigned device to which this port belongs (optional). + +### Name + +The port's name. Must be unique to the parent device. + +### Label + +An alternative physical label identifying the port. + +### Type + +The port's termination type. + +### Color + +The port's color (optional). + +### Positions + +The number of [front ports](./frontport.md) to which this rear port can be mapped. For example, an MPO fiber termination cassette might have a single 12-strand rear port mapped to 12 discrete front ports, each terminating a single fiber strand. (For rear ports which map directly to a single front port, set this to `1`.) + +### Mark Connected + +If selected, this component will be treated as if a cable has been connected. diff --git a/docs/models/dcim/rearporttemplate.md b/docs/models/dcim/rearporttemplate.md index 01ba02ac0..00cdfa5d2 100644 --- a/docs/models/dcim/rearporttemplate.md +++ b/docs/models/dcim/rearporttemplate.md @@ -1,3 +1,3 @@ -## Rear Port Templates +# Rear Port Templates -A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024). +A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. See the [rear port](./rearport.md) documentation for more detail. diff --git a/docs/models/dcim/region.md b/docs/models/dcim/region.md index bac186264..27673b2df 100644 --- a/docs/models/dcim/region.md +++ b/docs/models/dcim/region.md @@ -1,5 +1,17 @@ # Regions -Sites can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. +[Sites](./site.md) can be arranged geographically using regions. A region might represent a continent, country, city, campus, or other area depending on your use case. Regions can be nested recursively to construct a hierarchy. For example, you might define several country regions, and within each of those several state or city regions to which sites are assigned. -Each region must have a name that is unique within its parent region, if any. +## Fields + +### Parent + +The parent region, if any. + +### Name + +The region's name. Must be unique to the parent region, if one is assigned. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/dcim/site.md b/docs/models/dcim/site.md index 6617b950c..c74c209e1 100644 --- a/docs/models/dcim/site.md +++ b/docs/models/dcim/site.md @@ -2,14 +2,50 @@ How you choose to employ sites when modeling your network may vary depending on the nature of your organization, but generally a site will equate to a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. -Each site must be assigned a unique name and may optionally be assigned to a region and/or tenant. The following operational statuses are available: +## Fields -* Planned -* Staging -* Active -* Decommissioning -* Retired +### Name -The site model also provides a facility ID field which can be used to annotate a facility ID (such as a datacenter name) associated with the site. Each site may also have an autonomous system (AS) number and time zone associated with it. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) +The site's unique name. -The site model also includes several fields for storing contact and address information as well as geolocation data (GPS coordinates). +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Status + +The site's operational status. + +!!! tip + Additional statuses may be defined by setting `Site.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Region + +The parent [region](./region.md) to which the site belongs, if any. + +### Facility + +Data center or facility designation for identifying the site. + +### ASNs + +Each site can have multiple [AS numbers](../ipam/asn.md) assigned to it. + +### Time Zone + +The site's local time zone. (Time zones are provided by the [pytz](https://pypi.org/project/pytz/) package.) + +### Physical Address + +The site's physical address, used for mapping. + +### Shipping Address + +The address to use for deliveries destined for the site. + +!!! tip + You can also designate [points of contact](../../features/contacts.md) for each site to provide additional contact details. + +### Latitude & Longitude + +GPS coordinates of the site for geolocation. diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md index 04ebcc1a5..e1c5215d9 100644 --- a/docs/models/dcim/sitegroup.md +++ b/docs/models/dcim/sitegroup.md @@ -1,5 +1,17 @@ # Site Groups -Like regions, site groups can be used to organize sites. Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of any of its parent groups. +Like [regions](./region.md), site groups can be used to organize [sites](./site.md). Whereas regions are intended to provide geographic organization, site groups can be used to classify sites by role or function. Also like regions, site groups can be nested to form a hierarchy. Sites which belong to a child group are also considered to be members of all its parent groups. -Each site group must have a name that is unique within its parent group, if any. +## Fields + +### Parent + +The parent site group, if any. + +### Name + +The site group's name. Must be unique to the parent group, if one is assigned. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/dcim/virtualchassis.md b/docs/models/dcim/virtualchassis.md index 2466b065d..e68b2fda6 100644 --- a/docs/models/dcim/virtualchassis.md +++ b/docs/models/dcim/virtualchassis.md @@ -1,9 +1,22 @@ # Virtual Chassis -A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single device. A virtual chassis must be assigned a name and may be assigned a domain. +A virtual chassis represents a set of devices which share a common control plane. A common example of this is a stack of switches which are connected and configured to operate as a single managed device. Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. -Each device in the virtual chassis is referred to as a VC member, and assigned a position and (optionally) a priority. VC member devices commonly reside within the same rack, though this is not a requirement. One of the devices may be designated as the VC master: This device will typically be assigned a name, services, virtual interfaces, and other attributes related to managing the VC. -If a VC master is defined, interfaces from all VC members are displayed when navigating to its device interfaces view. This does not include other members interfaces declared as management-only. +One of the member devices may be designated as the VC master: This device will typically be assigned a name, services, virtual interfaces, and other attributes related to managing the VC. If a VC master is defined, interfaces from all VC members are displayed when navigating to its device interfaces view. This does not include management-only interfaces belonging to other members. !!! note - It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices. + It's important to recognize the distinction between a virtual chassis and a chassis-based device. A virtual chassis is **not** suitable for modeling a chassis-based switch with removable line cards (such as the Juniper EX9208), as its line cards are _not_ physically autonomous devices. Instead, use [modules](./module.md) for these. + +## Fields + +### Name + +The virtual chassis' name. + +### Domain + +The domain assigned for VC member devices. + +### Master + +The member device which has been designated as the chassis master (optional). diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index bb4a22e0d..156b2d784 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -1,67 +1,27 @@ # Configuration Contexts -Sometimes it is desirable to associate additional data with a group of devices or virtual machines to aid in automated configuration. For example, you might want to associate a set of syslog servers for all devices within a particular region. Context data enables the association of extra user-defined data with devices and virtual machines grouped by one or more of the following assignments: +Context data is made available to [devices](../dcim/device.md) and/or [virtual machines](../virtualization/virtualmachine.md) based on their relationships to other objects in NetBox. For example, context data can be associated only with devices assigned to a particular site, or only to virtual machines in a certain cluster. -* Region -* Site group -* Site -* Device type (devices only) -* Role -* Platform -* Cluster group (VMs only) -* Cluster (VMs only) -* Tenant group -* Tenant -* Tag +See the [context data documentation](../../features/context-data.md) for more information. -## Hierarchical Rendering +## Fields -Context data is arranged hierarchically, so that data with a higher weight can be entered to override lower-weight data. Multiple instances of data are automatically merged by NetBox to present a single dictionary for each object. +### Name -For example, suppose we want to specify a set of syslog and NTP servers for all devices within a region. We could create a config context instance with a weight of 1000 assigned to the region, with the following JSON data: +A unique human-friendly name. -```json -{ - "ntp-servers": [ - "172.16.10.22", - "172.16.10.33" - ], - "syslog-servers": [ - "172.16.9.100", - "172.16.9.101" - ] -} -``` +### Weight -But suppose there's a problem at one particular site within this region preventing traffic from reaching the regional syslog server. Devices there need to use a local syslog server instead of the two defined above. We'll create a second config context assigned only to that site with a weight of 2000 and the following data: +A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight. -```json -{ - "syslog-servers": [ - "192.168.43.107" - ] -} -``` +### Data -When the context data for a device at this site is rendered, the second, higher-weight data overwrite the first, resulting in the following: +The context data expressed in JSON format. -```json -{ - "ntp-servers": [ - "172.16.10.22", - "172.16.10.33" - ], - "syslog-servers": [ - "192.168.43.107" - ] -} -``` +### Is Active -Data from the higher-weight context overwrites conflicting data from the lower-weight context, while the non-conflicting portion of the lower-weight context (the list of NTP servers) is preserved. +If not selected, this config context will be excluded from rendering. This can be convenient to temporarily disable a config context. -## Local Context Data +### Object Assignment -Devices and virtual machines may also have a local config context defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. This is useful in situations where we need to call out a specific deviation in the data for a particular object. - -!!! warning - If you find that you're routinely defining local context data for many individual devices or virtual machines, custom fields may offer a more effective solution. +Each configuration context may be assigned with any number of objects of the supported types. If no related objects are selected, it will be considered a "global" config context and apply to all devices and virtual machines. diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e0c01688d..ee1fde2b7 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -1,49 +1,95 @@ # Custom Fields -Each model in NetBox is represented in the database as a discrete table, and each attribute of a model exists as a column within its table. For example, sites are stored in the `dcim_site` table, which has columns named `name`, `facility`, `physical_address`, and so on. As new attributes are added to objects throughout the development of NetBox, tables are expanded to include new rows. +NetBox administrators can extend NetBox's built-in data model by adding custom fields to most object types. See the [custom fields documentation](../../customization/custom-fields.md) for more information. -However, some users might want to store additional object attributes that are somewhat esoteric in nature, and that would not make sense to include in the core NetBox database schema. For instance, suppose your organization needs to associate each device with a ticket number correlating it with an internal support system record. This is certainly a legitimate use for NetBox, but it's not a common enough need to warrant including a field for _every_ NetBox installation. Instead, you can create a custom field to hold this data. +## Fields -Within the database, custom fields are stored as JSON data directly alongside each object. This alleviates the need for complex queries when retrieving objects. +### Model(s) -## Creating Custom Fields +Select the NetBox object type or types to which this custom field applies. -Custom fields may be created by navigating to Customization > Custom Fields. NetBox supports six types of custom field: +### Name -* Text: Free-form text (intended for single-line use) -* Long text: Free-form of any length; supports Markdown rendering -* Integer: A whole number (positive or negative) -* Boolean: True or false -* Date: A date in ISO 8601 format (YYYY-MM-DD) -* URL: This will be presented as a link in the web UI -* JSON: Arbitrary data stored in JSON format -* Selection: A selection of one of several pre-defined custom choices -* Multiple selection: A selection field which supports the assignment of multiple values -* Object: A single NetBox object of the type defined by `object_type` -* Multiple object: One or more NetBox objects of the type defined by `object_type` +The raw field name. This will be used in the database and API, and should consist only of alphanumeric characters and underscores. (Use the `label` field to designate a human-friendly name for the custom field.) -Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. +### Label -Marking a field as required will force the user to provide a value for the field when creating a new object or when saving an existing object. A default value for the field may also be provided. Use "true" or "false" for boolean fields, or the exact value of a choice for selection fields. +An optional human-friendly name for the custom field. If not defined, the field's `name` attribute will be used. -The filter logic controls how values are matched when filtering objects by the custom field. Loose filtering (the default) matches on a partial value, whereas exact matching requires a complete match of the given string to a field's value. For example, exact filtering with the string "red" will only match the exact value "red", whereas loose filtering will match on the values "red", "red-orange", or "bored". Setting the filter logic to "disabled" disables filtering by the field entirely. +### Group Name -A custom field must be assigned to one or more object types, or models, in NetBox. Once created, custom fields will automatically appear as part of these models in the web UI and REST API. Note that not all models support custom fields. +If this custom field should be grouped with others, specify the name of the group here. Custom fields with no group defined will be ordered only by weight and name. -### Custom Field Validation +### Type -NetBox supports limited custom validation for custom field values. Following are the types of validation enforced for each field type: +The type of data this field holds. This must be one of the following: -* Text: Regular expression (optional) -* Integer: Minimum and/or maximum value (optional) -* Selection: Must exactly match one of the prescribed choices +| Type | Description | +|--------------------|--------------------------------------------------------------------| +| Text | Free-form text (intended for single-line use) | +| Long text | Free-form of any length; supports Markdown rendering | +| Integer | A whole number (positive or negative) | +| Boolean | True or false | +| Date | A date in ISO 8601 format (YYYY-MM-DD) | +| URL | This will be presented as a link in the web UI | +| JSON | Arbitrary data stored in JSON format | +| Selection | A selection of one of several pre-defined custom choices | +| Multiple selection | A selection field which supports the assignment of multiple values | +| Object | A single NetBox object of the type defined by `object_type` | +| Multiple object | One or more NetBox objects of the type defined by `object_type` | -### Custom Selection Fields +### Object Type -Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. +For object and multiple-object fields only. Designates the type of NetBox object being referenced. -If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. +### Weight -### Custom Object Fields +A numeric weight used to override alphabetic ordering of fields by name. Custom fields with a lower weight will be listed before those with a higher weight. (Note that weight applies within the context of a custom field group, if defined.) -An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. +### Required + +If checked, this custom field must be populated with a valid value for the object to pass validation. + +### Description + +A brief description of the field's purpose (optional). + +### Filter Logic + +Defines how filters are evaluated against custom field values. + +| Option | Description | +|----------|-------------------------------------| +| Disabled | Filtering disabled | +| Loose | Match any occurrence of the value | +| Exact | Match only the complete field value | + +### UI Visibility + +Controls how and whether the custom field is displayed within the NetBox user interface. + +| Option | Description | +|------------|--------------------------------------| +| Read/write | Display and permit editing (default) | +| Read-only | Display field but disallow editing | +| Hidden | Do not display field in the UI | + +### Default + +The default value to populate for the custom field when creating new objects (optional). This value must be expressed as JSON. If this is a choice or multi-choice field, this must be one of the available choices. + +### Choices + +For choice and multi-choice custom fields only. A comma-delimited list of the available choices. + +### Minimum Value + +For numeric custom fields only. The minimum valid value (optional). + +### Maximum Value + +For numeric custom fields only. The maximum valid value (optional). + +### Validation Regex + +For string-based custom fields only. A regular expression used to validate the field's value (optional). diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index 16ba9d2af..dae9978aa 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -1,66 +1,56 @@ # Custom Links -Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). +Users can add custom links to object views in NetBox to reference external resources. For example, you might create a custom link for devices pointing to a monitoring system. See the [custom links documentation](../../customization/custom-links.md) for more information. -Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. +## Fields -For example, you might define a link like this: +### Name -* Text: `View NMS` -* URL: `https://nms.example.com/nodes/?name={{ obj.name }}` +The name of the custom link. This is used primarily for administrative purposes only, although custom links of the same weight are ordered alphabetically by name when being rendered in the UI. -When viewing a device named Router4, this link would render as: +### Content Type -```no-highlight -View NMS -``` +The type of NetBox object to which this custom link applies. -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. +### Weight -!!! warning - Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. +A numeric weight used to override alphabetic ordering of links by name. Custom fields with a lower weight will be listed before those with a higher weight. (Note that weight applies within the context of a custom link group, if defined.) + +### Group Name + +If this custom link should be grouped with others, specify the name of the group here. Grouped custom links will be listed in a dropdown menu attached to a single button bearing the group name. + +### Button Class + +The color of the UI button. + +### Enabled + +If not selected, the custom link will not be rendered. This can be useful for temporarily disabling a custom link. + +### New Window + +If selected, this will force the link to open in a new browser tab or window. + +### Link Text + +Jinja2 template code for rendering the button text. (Note that this does not _need_ to contain any template variables.) See below for available context data. + +!!! note + Custom links which render an empty text value will not be displayed in the UI. This can be used to toggle the inclusion of a link based on object attributes. + +### Link URL + +Jinja2 template code for rendering the hyperlink. See below for available context data. ## Context Data -The following context data is available within the template when rendering a custom link's text or URL. +The following context variables are available in to the text and link templates. -| Variable | Description | -|-----------|-------------------------------------------------------------------------------------------------------------------| -| `object` | The NetBox object being displayed | -| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 | -| `debug` | A boolean indicating whether debugging is enabled | -| `request` | The current WSGI request | -| `user` | The current user (if authenticated) | -| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | - -While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. - -Checking the REST API representation of an object is generally a convenient way to determine what attributes are available. You can also reference the NetBox source code directly for a comprehensive list. - -## Conditional Rendering - -Only links which render with non-empty text are included on the page. You can employ conditional Jinja2 logic to control the conditions under which a link gets rendered. - -For example, if you only want to display a link for active devices, you could set the link text to - -```jinja2 -{% if obj.status == 'active' %}View NMS{% endif %} -``` - -The link will not appear when viewing a device with any status other than "active." - -As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this: - -```jinja2 -{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} -``` - -The link will only appear when viewing a device with a manufacturer name of "Cisco." - -## Link Groups - -Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group. - -## Table Columns - -Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL. +| Variable | Description | +|-----------|-----------------------------------------------------------------------------| +| `object` | The NetBox object being displayed | +| `debug` | A boolean indicating whether debugging is enabled | +| `request` | The current WSGI request | +| `user` | The current user (if authenticated) | +| `perms` | The [permissions](../../administration/permissions.md) assigned to the user | diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index e76a3ad47..3215201b3 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -1,37 +1,29 @@ # Export Templates -NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Customization > Export Templates. +Export templates are used to render arbitrary data from a set of NetBox objects. For example, you might want to automatically generate a network monitoring service configuration from a list of device objects. See the [export templates documentation](../../customization/export-templates.md) for more information. -Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension. +## Fields -Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). +### Name -!!! note - The name `table` is reserved for internal use. +The name of the export template. This will appear in the "export" dropdown list in the NetBox UI. -!!! warning - Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users. +### Content Type -The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example: +The type of NetBox object to which the export template applies. -```jinja2 -{% for rack in queryset %} -Rack: {{ rack.name }} -Site: {{ rack.site.name }} -Height: {{ rack.u_height }}U -{% endfor %} -``` +### Template Code -To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`. +Jinja2 template code for rendering the exported data. -If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example: -``` -{% for server in queryset %} -{% set data = server.get_config_context() %} -{{ data.syslog }} -{% endfor %} -``` +### MIME Type -The `as_attachment` attribute of an export template controls its behavior when rendered. If true, the rendered content will be returned to the user as a downloadable file. If false, it will be displayed within the browser. (This may be handy e.g. for generating HTML content.) +The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. -A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`. +### File Extension + +The file extension to append to the file name in the response (optional). + +### As Attachment + +If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). diff --git a/docs/models/extras/imageattachment.md b/docs/models/extras/imageattachment.md index da15462ab..b8c48f055 100644 --- a/docs/models/extras/imageattachment.md +++ b/docs/models/extras/imageattachment.md @@ -1,3 +1,13 @@ # Image Attachments Certain objects in NetBox support the attachment of uploaded images. These will be saved to the NetBox server and made available whenever the object is viewed. + +## Fields + +### Name + +The name of the image being attached. If not defined, this will be inferred from the name of the uploaded file. + +### Image + +The image file to upload. Note that the uploaded file **must** be a supported image type, or validation will fail. diff --git a/docs/models/extras/journalentry.md b/docs/models/extras/journalentry.md new file mode 100644 index 000000000..7e9bc986d --- /dev/null +++ b/docs/models/extras/journalentry.md @@ -0,0 +1,16 @@ +# Journal Entries + +Most objects in NetBox support journaling. This is the ability of users to record chronological notes indicating changes to or work performed on resources in NetBox. For example, a data center technician might add a journal entry for a device when swapping out a failed power supply. + +## Fields + +### Kind + +A general classification for the entry type (info, success, warning, or danger.) + +!!! tip + Additional kinds may be defined by setting `JournalEntry.kind` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Comments + +The body of the journal entry. [Markdown](../../reference/markdown.md) rendering is supported. diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index fe6a1ef36..97ebd9d72 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -2,16 +2,16 @@ Tags are user-defined labels which can be applied to a variety of objects within NetBox. They can be used to establish dimensions of organization beyond the relationships built into NetBox. For example, you might create a tag to identify a particular ownership or condition across several types of objects. -Each tag has a label, color, and a URL-friendly slug. For example, the slug for a tag named "Dunder Mifflin, Inc." would be `dunder-mifflin-inc`. The slug is generated automatically and makes tags easier to work with as URL parameters. Each tag can also be assigned a description indicating its purpose. +## Fields -Objects can be filtered by the tags they have applied. For example, the following API request will retrieve all devices tagged as "monitored": +### Name -```no-highlight -GET /api/dcim/devices/?tag=monitored -``` +A unique human-friendly label for the tag. -The `tag` filter can be specified multiple times to match only objects which have _all_ of the specified tags assigned: +### Slug -```no-highlight -GET /api/dcim/devices/?tag=monitored&tag=deprecated -``` +A unique URL-friendly identifier. (This value will be used for filtering.) This is automatically generated from the tag's name, but can be altered as needed. + +### Color + +The color to use when displaying the tag in the NetBox UI. diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index 9f64401ae..1ca6ec191 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -1,83 +1,84 @@ # Webhooks -A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. +A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. + +See the [webhooks documentation](../../integrations/webhooks.md) for more information. + +## Fields + +### Name + +A unique human-friendly name. + +### Content Types + +The type(s) of object in NetBox that will trigger the webhook. + +### Enabled + +If not selected, the webhook will be inactive. + +### Events + +The events which will trigger the webhook. At least one event type must be selected. + +| Name | Description | +|-----------|--------------------------------------| +| Creations | A new object has been created | +| Updates | An existing object has been modified | +| Deletions | An object has been deleted | + +### URL + +The URL to which the webhook HTTP request will be made. + +### HTTP Method + +The type of HTTP request to send. Options are: + +* `GET` +* `POST` +* `PUT` +* `PATCH` +* `DELETE` + +### HTTP Content Type + +The content type to indicate in the outgoing HTTP request header. See [this list](https://www.iana.org/assignments/media-types/media-types.xhtml) of known types for reference. + +### Additional Headers + +Any additional header to include with the outgoing HTTP request. These should be defined in the format `Name: Value`, with each header on a separate line. Jinja2 templating is supported for this field. + +### Body Template + +Jinja2 template for a custom request body, if desired. If not defined, NetBox will populate the request body with a raw dump of the webhook context. + +### Secret + +A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. + +### SSL Verification + +Controls whether validation of the receiver's SSL certificate is enforced when HTTPS is used. !!! warning - Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + Disabling this can expose your webhooks to man-in-the-middle attacks. -## Configuration +### CA File Path -* **Name** - A unique name for the webhook. The name is not included with outbound messages. -* **Object type(s)** - The type or types of NetBox object that will trigger the webhook. -* **Enabled** - If unchecked, the webhook will be inactive. -* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. -* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. -* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field. -* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) -* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). -* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) -* **Secret** - A secret string used to prove authenticity of the request (optional). This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. -* **Conditions** - An optional set of conditions evaluated to determine whether the webhook fires for a given object. -* **SSL verification** - Uncheck this option to disable validation of the receiver's SSL certificate. (Disable with caution!) -* **CA file path** - The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (optional). +The file path to a particular certificate authority (CA) file to use when validating the receiver's SSL certificate (if not using the system defaults). -## Jinja2 Template Support +## Context Data -[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. +The following context variables are available in to the text and link templates. -For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: - -* Object type: IPAM > IP address -* HTTP method: `POST` -* URL: Slack incoming webhook URL -* HTTP content type: `application/json` -* Body template: `{"text": "IP address {{ data['address'] }} was created by {{ username }}!"}` - -### Available Context - -The following data is available as context for Jinja2 templates: - -* `event` - The type of event which triggered the webhook: created, updated, or deleted. -* `model` - The NetBox model which triggered the change. -* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). -* `username` - The name of the user account associated with the change. -* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API. -* `snapshots` - Minimal "snapshots" of the object state both before and after the change was made; provided as a dictionary with keys named `prechange` and `postchange`. These are not as extensive as the fully serialized representation, but contain enough information to convey what has changed. - -### Default Request Body - -If no body template is specified, the request body will be populated with a JSON object containing the context data. For example, a newly created site might appear as follows: - -```json -{ - "event": "created", - "timestamp": "2021-03-09 17:55:33.968016+00:00", - "model": "site", - "username": "jstretch", - "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", - "data": { - "id": 19, - "name": "Site 1", - "slug": "site-1", - "status": - "value": "active", - "label": "Active", - "id": 1 - }, - "region": null, - ... - }, - "snapshots": { - "prechange": null, - "postchange": { - "created": "2021-03-09", - "last_updated": "2021-03-09T17:55:33.851Z", - "name": "Site 1", - "slug": "site-1", - "status": "active", - ... - } - } -} -``` +| Variable | Description | +|--------------|----------------------------------------------------| +| `event` | The event type (`create`, `update`, or `delete`) | +| `timestamp` | The time at which the event occured | +| `model` | The type of object impacted | +| `username` | The name of the user associated with the change | +| `request_id` | The unique request ID | +| `data` | A complete serialized representation of the object | +| `snapshots` | Pre- and post-change snapshots of the object | diff --git a/docs/models/ipam/aggregate.md b/docs/models/ipam/aggregate.md index ff5a50a39..eadeb813c 100644 --- a/docs/models/ipam/aggregate.md +++ b/docs/models/ipam/aggregate.md @@ -20,9 +20,23 @@ NetBox allows us to specify the portions of IP space that are interesting to us * 192.168.0.0/16 (RFC 1918) * One or more /48s within fd00::/8 (IPv6 unique local addressing) -Each aggregate is assigned to a RIR. For "public" aggregates, this will be the real-world authority which has granted your organization permission to use the specified IP space on the public Internet. For "private" aggregates, this will be a statutory authority, such as RFC 1918. Each aggregate can also annotate that date on which it was allocated, where applicable. +Each aggregate is assigned to a [RIR](./rir.md). For "public" aggregates, this will be the real-world authority which has granted your organization permission to use the specified IP space on the public Internet. For "private" aggregates, this will be a statutory authority, such as RFC 1918. Each aggregate can also annotate that date on which it was allocated, where applicable. -Prefixes are automatically arranged beneath their parent aggregates in NetBox. Typically you'll want to create aggregates only for the prefixes and IP addresses that your organization actually manages: There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. +Prefixes are automatically arranged beneath their parent aggregates in NetBox. Typically, you'll want to create aggregates only for the prefixes and IP addresses that your organization actually manages: There is no need to define aggregates for provider-assigned space which is only used on Internet circuits, for example. !!! note - Because aggregates represent swaths of the global IP space, they cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a container prefix and automatically grouped under the 10.0.0.0/8 aggregate. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. + Because aggregates represent segments of the global IP space, they cannot overlap with one another: They can only exist side-by-side. For instance, you cannot define both 10.0.0.0/8 and 10.16.0.0/16 as aggregates, because they overlap. 10.16.0.0/16 in this example would be created as a container prefix and automatically grouped under the 10.0.0.0/8 aggregate. Remember, the purpose of aggregates is to establish the root of your IP addressing hierarchy. + +## Fields + +### Prefix + +The IPv4 or IPv6 network this aggregate represents. + +### RIR + +The [Regional Internet Registry](./rir.md) or similar authority which governs allocations of this address space from the global pool. + +### Date Added + +The date on which the address space was allocated or deployed. diff --git a/docs/models/ipam/asn.md b/docs/models/ipam/asn.md index cfef1da29..e34790406 100644 --- a/docs/models/ipam/asn.md +++ b/docs/models/ipam/asn.md @@ -1,15 +1,19 @@ # ASN -ASN is short for Autonomous System Number. This identifier is used in the BGP protocol to identify which "autonomous system" a particular prefix is originating and transiting through. +An Autonomous System Number (ASN) is a numeric identifier used in the BGP protocol to identify which [autonomous system](https://en.wikipedia.org/wiki/Autonomous_system_%28Internet%29) a particular prefix is originating and transiting through. NetBox support both 32- and 64- ASNs. -The AS number model within NetBox allows you to model some of this real-world relationship. +ASNs must be globally unique within NetBox, must each may be assigned to multiple [sites](../dcim/site.md). -Within NetBox: +## Fields -* AS numbers are globally unique -* Each AS number must be associated with a RIR (ARIN, RFC 6996, etc) -* Each AS number can be associated with many different sites -* Each site can have many different AS numbers -* Each AS number can be assigned to a single tenant +### AS Number +The 32- or 64-bit AS number. +### RIR + +The [Regional Internet Registry](./rir.md) or similar authority responsible for the allocation of this particular ASN. + +### Sites + +The [site(s)](../dcim/site.md) to which this ASN is assigned. diff --git a/docs/models/ipam/fhrpgroup.md b/docs/models/ipam/fhrpgroup.md index c5baccd7b..4da390310 100644 --- a/docs/models/ipam/fhrpgroup.md +++ b/docs/models/ipam/fhrpgroup.md @@ -1,10 +1,31 @@ # FHRP Group -A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to present a virtual IP address in a redundant manner. Example of such protocols include: +A first-hop redundancy protocol (FHRP) enables multiple physical interfaces to present a virtual [IP address](./ipaddress.md) (VIP) in a redundant manner. Examples of such protocols include: -* Hot Standby Router Protocol (HSRP) -* Virtual Router Redundancy Protocol (VRRP) -* Common Address Redundancy Protocol (CARP) -* Gateway Load Balancing Protocol (GLBP) +* [Hot Standby Router Protocol](https://en.wikipedia.org/wiki/Hot_Standby_Router_Protocol) (HSRP) +* [Virtual Router Redundancy Protocol](https://en.wikipedia.org/wiki/Virtual_Router_Redundancy_Protocol) (VRRP) +* [Common Address Redundancy Protocol](https://en.wikipedia.org/wiki/Common_Address_Redundancy_Protocol) (CARP) +* [Gateway Load Balancing Protocol](https://en.wikipedia.org/wiki/Gateway_Load_Balancing_Protocol) (GLBP) -NetBox models these redundancy groups by protocol and group ID. Each group may optionally be assigned an authentication type and key. (Note that the authentication key is stored as a plaintext value in NetBox.) Each group may be assigned or more virtual IPv4 and/or IPv6 addresses. +When creating a new FHRP group, the user may optionally create a VIP as well. This IP address will be automatically assigned to the new group. (Virtual IP addresses can also be assigned after the group has been created.) + +## Fields + +### Protocol + +The wire protocol employed by cooperating servers to maintain the virtual [IP address(es)](./ipaddress.md) for the group. + +### Group ID + +The group's numeric identifier. + +### Authentication Type + +The type of authentication employed by group nodes, if any. + +### Authentication Key + +The shared key used for group authentication, if any. + +!!! warning + The authentication key value is stored in plaintext in NetBox's database. Do not utilize this field if you require encryption at rest for shared keys. diff --git a/docs/models/ipam/fhrpgroupassignment.md b/docs/models/ipam/fhrpgroupassignment.md index c3e0bf200..c65217c62 100644 --- a/docs/models/ipam/fhrpgroupassignment.md +++ b/docs/models/ipam/fhrpgroupassignment.md @@ -1,5 +1,19 @@ # FHRP Group Assignments -Member device and VM interfaces can be assigned to FHRP groups, along with a numeric priority value. For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a common virtual IP address. Each of these assignments would typically receive a different priority. +Member device and VM interfaces can be assigned to [FHRP groups](./fhrpgroup.md) to indicate their participation in maintaining a common virtual IP address (VIP). For instance, three interfaces, each belonging to a different router, may each be assigned to the same FHRP group to serve a shared VIP. Each of these assignments would typically receive a different priority. Interfaces are assigned to FHRP groups under the interface detail view. + +## Fields + +### Group + +The [FHRP group](./fhrpgroup.md) being assigned. + +### Interface + +The device or VM interface to which the group is being assigned. + +### Priority + +A value between 0 and 255 indicating the interface's priority for being elected as the master/primary node in the group. diff --git a/docs/models/ipam/ipaddress.md b/docs/models/ipam/ipaddress.md index 1ea613997..ecfdc4b96 100644 --- a/docs/models/ipam/ipaddress.md +++ b/docs/models/ipam/ipaddress.md @@ -1,30 +1,8 @@ # IP Addresses -An IP address comprises a single host address (either IPv4 or IPv6) and its subnet mask. Its mask should match exactly how the IP address is configured on an interface in the real world. +An IP address object in NetBox comprises a single host address (either IPv4 or IPv6) and its subnet mask, and represents an IP address as configured on a network interface. IP addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces, as well as to [FHRP groups](./fhrpgroup.md). Further, each device and virtual machine may have one of its interface IPs designated as its primary IP per address family (one for IPv4 and one for IPv6). -Like a prefix, an IP address can optionally be assigned to a VRF (otherwise, it will appear in the "global" table). IP addresses are automatically arranged under parent prefixes within their respective VRFs according to the IP hierarchy. - -Each IP address can also be assigned an operational status and a functional role. Statuses are hard-coded in NetBox and include the following: - -* Active -* Reserved -* Deprecated -* DHCP -* SLAAC (IPv6 Stateless Address Autoconfiguration) - -Roles are used to indicate some special attribute of an IP address; for example, use as a loopback or as the the virtual IP for a VRRP group. (Note that functional roles are conceptual in nature, and thus cannot be customized by the user.) Available roles include: - -* Loopback -* Secondary -* Anycast -* VIP -* VRRP -* HSRP -* GLBP - -An IP address can be assigned to any device or virtual machine interface, and an interface may have multiple IP addresses assigned to it. Further, each device and virtual machine may have one of its interface IPs designated as its primary IP per address family (one for IPv4 and one for IPv6). - -!!! note +!!! tip When primary IPs are set for both IPv4 and IPv6, NetBox will prefer IPv6. This can be changed by setting the `PREFER_IPV4` configuration parameter. ## Network Address Translation (NAT) @@ -32,4 +10,44 @@ An IP address can be assigned to any device or virtual machine interface, and an An IP address can be designated as the network address translation (NAT) inside IP address for exactly one other IP address. This is useful primarily to denote a translation between public and private IP addresses. This relationship is followed in both directions: For example, if 10.0.0.1 is assigned as the inside IP for 192.0.2.1, 192.0.2.1 will be displayed as the outside IP for 10.0.0.1. !!! note - NetBox does not support tracking one-to-many NAT relationships (also called port address translation). This type of policy requires additional logic to model and cannot be fully represented by IP address alone. + NetBox does not currently support tracking application-level NAT relationships (also called _port address translation_ or PAT). This type of policy requires additional logic to model and cannot be fully represented by IP address alone. + +## Fields + +### Address + +The IPv4 or IPv6 address and mask, in CIDR notation (e.g. `192.0.2.0/24`). + +### Status + +The operational status of the IP address. + +!!! tip + Additional statuses may be defined by setting `IPAddress.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Role + +The functional role fulfilled by this IP address. Options include: + +* **Loopback:** Configured on a loopback interface +* **Secondary:** One of multiple IP addresses configured on an interface +* **Anycast:** Employed for anycast services +* **VIP:** A general-purpose virtual IP address +* **VRRP:** A virtual IP address managed with the VRRP protocol +* **HSRP:** A virtual IP address managed with the HSRP protocol +* **GLBP:** A virtual IP address managed with the GLBP protocol +* **CARP:** A virtual IP address managed with the CARP protocol + +!!! tip + Virtual IP addresses should be assigned to [FHRP groups](./fhrpgroup.md) rather than to actual interfaces to accurately model their shared nature. + +### VRF + +The [Virtual Routing and Forwarding](./vrf.md) instance in which this IP address exists. + +!!! note + VRF assignment is optional. IP addresses with no VRF assigned are considered to exist in the "global" table. + +### DNS Name + +A DNS A/AAAA record value associated with this IP address. diff --git a/docs/models/ipam/iprange.md b/docs/models/ipam/iprange.md index 7b0457f27..53abf10b8 100644 --- a/docs/models/ipam/iprange.md +++ b/docs/models/ipam/iprange.md @@ -1,14 +1,30 @@ # IP Ranges -This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like prefixes and IP addresses, each IP range may optionally be assigned to a VRF and/or tenant. +This model represents an arbitrary range of individual IPv4 or IPv6 addresses, inclusive of its starting and ending addresses. For instance, the range 192.0.2.10 to 192.0.2.20 has eleven members. (The total member count is available as the `size` property on an IPRange instance.) Like [prefixes](./prefix.md) and [IP addresses](./ipaddress.md), each IP range may optionally be assigned to a [VRF](./vrf.md). -IP also ranges share the same functional roles as prefixes and VLANs, although the assignment of a role is optional. Each IP range must be assigned an operational status, which is one of the following: +## Fields -* Active - Provisioned and in use -* Reserved - Designated for future use -* Deprecated - No longer in use +### VRF -The status of a range does _not_ have any impact on its member IP addresses, which may have their statuses modified separately. +The [Virtual Routing and Forwarding](./vrf.md) instance in which this IP range exists. + +!!! note + VRF assignment is optional. IP ranges with no VRF assigned are considered to exist in the "global" table. + +### Start & End Address + +The beginning and ending IP addresses (inclusive) which define the boundaries of the range. Both IP addresses must specify the correct mask. !!! note The maximum supported size of an IP range is 2^32 - 1. + +### Role + +The user-defined functional [role](./role.md) assigned to the IP range. + +### Status + +The IP range's operational status. Note that the status of a range does _not_ have any impact on its member IP addresses, which may have their statuses defined independently. + +!!! tip + Additional statuses may be defined by setting `IPRange.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. diff --git a/docs/models/ipam/l2vpn.md b/docs/models/ipam/l2vpn.md new file mode 100644 index 000000000..e7ee1e187 --- /dev/null +++ b/docs/models/ipam/l2vpn.md @@ -0,0 +1,41 @@ +# L2VPN + +A L2VPN object is NetBox is a representation of a layer 2 bridge technology such as VXLAN, VPLS, or EPL. Each L2VPN can be identified by name as well as by an optional unique identifier (VNI would be an example). Once created, L2VPNs can be terminated to [interfaces](../dcim/interface.md) and [VLANs](./vlan.md). + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Type + +The technology employed in forming and operating the L2VPN. Choices include: + +* VPLS +* VPWS +* EPL +* EVPL +* EP-LAN +* EVP-LAN +* EP-TREE +* EVP-TREE +* VXLAN +* VXLAN-EVPN +* MPLS-EVPN +* PBB-EVPN + +!!! note + Designating the type as VPWS, EPL, EP-LAN, EP-TREE will limit the L2VPN instance to two terminations. + +### Identifier + +An optional numeric identifier. This can be used to track a pseudowire ID, for example. + +### Import & Export Targets + +The [route targets](./routetarget.md) associated with this L2VPN to control the import and export of forwarding information. diff --git a/docs/models/ipam/l2vpntermination.md b/docs/models/ipam/l2vpntermination.md new file mode 100644 index 000000000..c3c27b8d2 --- /dev/null +++ b/docs/models/ipam/l2vpntermination.md @@ -0,0 +1,18 @@ +# L2VPN Termination + +A L2VPN termination is the attachment of an [L2VPN](./l2vpn.md) to an [interface](../dcim/interface.md) or [VLAN](./vlan.md). Note that the L2VPNs of the following types may have only two terminations assigned to them: + +* VPWS +* EPL +* EP-LAN +* EP-TREE + +## Fields + +### L2VPN + +The [L2VPN](./l2vpn.md) instance. + +### VLAN or Interface + +The [VLAN](./vlan.md), [device interface](../dcim/interface.md), or [virtual machine interface](../virtualization/virtualmachine.md) attached to the L2VPN. diff --git a/docs/models/ipam/prefix.md b/docs/models/ipam/prefix.md index bd5e9695f..2fb01daf0 100644 --- a/docs/models/ipam/prefix.md +++ b/docs/models/ipam/prefix.md @@ -1,18 +1,43 @@ # Prefixes -A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.) +A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address: All bits in the address not covered by the mask must be zero. (In other words, a prefix cannot be a specific IP address.) Prefixes are automatically organized by their parent [aggregate](./aggregate.md) and assigned [VRF](./vrf.md). -Prefixes are automatically organized by their parent aggregates. Additionally, each prefix can be assigned to a particular site and virtual routing and forwarding instance (VRF). Each VRF represents a separate IP space or routing table. All prefixes not assigned to a VRF are considered to be in the "global" table. +## Fields -Each prefix can be assigned a status and a role. These terms are often used interchangeably so it's important to recognize the difference between them. The **status** defines a prefix's operational state. Statuses are hard-coded in NetBox and can be one of the following: +### Prefix -* Container - A summary of child prefixes -* Active - Provisioned and in use -* Reserved - Designated for future use -* Deprecated - No longer in use +The IPv4 or IPv6 network this prefix represents. -On the other hand, a prefix's **role** defines its function. Role assignment is optional and roles are fully customizable. For example, you might create roles to differentiate between production and development infrastructure. +### Status -A prefix may also be assigned to a VLAN. This association is helpful for associating address space with layer two domains. A VLAN may have multiple prefixes assigned to it. +The prefix's operational status. Note that the status of a prefix does _not_ have any impact on its member [IP addresses](./ipaddress.md), which may have their statuses defined independently. The "container" status indicates that the prefix exists merely as a container for organizing child prefixes. -The prefix model include an "is pool" flag. If enabled, NetBox will treat this prefix as a range (such as a NAT pool) wherein every IP address is valid and assignable. This logic is used when identifying available IP addresses within a prefix. If this flag is disabled, NetBox will assume that the first and last (broadcast) address within an IPv4 prefix are unusable. +!!! tip + Additional statuses may be defined by setting `Prefix.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### VRF + +The [Virtual Routing and Forwarding](./vrf.md) instance in which this prefix exists. + +!!! note + VRF assignment is optional. Prefixes with no VRF assigned are considered to exist in the "global" table. + +### Role + +The user-defined functional [role](./role.md) assigned to the prefix. + +### Is a Pool + +Designates whether the prefix should be treated as a pool. If selected, the first and last IP addresses within the prefix (normally reserved as the network and broadcast addresses, respectively) will be considered usable. This option is ideal for documenting NAT pools. + +### Mark Utilized + +If selected, this prefix will report 100% utilization regardless of how many child objects have been defined within it. + +### Site + +The [site](../dcim/site.md) to which this prefix is assigned (optional). + +### VLAN + +The [VLAN](./vlan.md) to which this prefix is assigned (optional). This mapping is helpful for associating IP space with layer two domains. A VLAN may have multiple prefixes assigned to it. diff --git a/docs/models/ipam/rir.md b/docs/models/ipam/rir.md index 6904381ac..efa5cf0f5 100644 --- a/docs/models/ipam/rir.md +++ b/docs/models/ipam/rir.md @@ -2,6 +2,18 @@ [Regional Internet registries](https://en.wikipedia.org/wiki/Regional_Internet_registry) are responsible for the allocation of globally-routable address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for internal use, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. There also exist lower-tier registries which serve particular geographic areas. -Users can create whatever RIRs they like, but each aggregate must be assigned to one RIR. The RIR model includes a boolean flag which indicates whether the RIR allocates only private IP space. +Users can create whatever RIRs they like, but each [aggregate](./aggregate.md) must be assigned to one RIR. For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named "ARIN" and "RFC 1918," then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. -For example, suppose your organization has been allocated 104.131.0.0/16 by ARIN. It also makes use of RFC 1918 addressing internally. You would first create RIRs named "ARIN" and "RFC 1918," then create an aggregate for each of these top-level prefixes, assigning it to its respective RIR. +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Private + +Designates this RIR as an authority for private/local IP space only (e.g. an RFC). diff --git a/docs/models/ipam/role.md b/docs/models/ipam/role.md index 8623e5282..7685f7d85 100644 --- a/docs/models/ipam/role.md +++ b/docs/models/ipam/role.md @@ -1,3 +1,17 @@ # Prefix/VLAN Roles A role indicates the function of a prefix or VLAN. For example, you might define Data, Voice, and Security roles. Generally, a prefix will be assigned the same functional role as the VLAN to which it is assigned (if any). + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Weight + +A numeric weight employed to influence the ordering of roles. Roles with a lower weight will be listed before those with higher weights. diff --git a/docs/models/ipam/routetarget.md b/docs/models/ipam/routetarget.md index b71e96904..298900f88 100644 --- a/docs/models/ipam/routetarget.md +++ b/docs/models/ipam/routetarget.md @@ -2,4 +2,8 @@ A route target is a particular type of [extended BGP community](https://tools.ietf.org/html/rfc4360#section-4) used to control the redistribution of routes among VRF tables in a network. Route targets can be assigned to individual VRFs in NetBox as import or export targets (or both) to model this exchange in an L3VPN. Each route target must be given a unique name, which should be in a format prescribed by [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), similar to a VR route distinguisher. -Each route target can optionally be assigned to a tenant, and may have tags assigned to it. +## Fields + +### Name + +The route target identifier formatted in accordance with [RFC 4360](https://tools.ietf.org/html/rfc4360). diff --git a/docs/models/ipam/service.md b/docs/models/ipam/service.md index 057544a91..316828b61 100644 --- a/docs/models/ipam/service.md +++ b/docs/models/ipam/service.md @@ -1,5 +1,23 @@ # Services -A service represents a layer four TCP or UDP service available on a device or virtual machine. For example, you might want to document that an HTTP service is running on a device. Each service includes a name, protocol, and port number; for example, "SSH (TCP/22)" or "DNS (UDP/53)." +A service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine. -A service may optionally be bound to one or more specific IP addresses belonging to its parent device or VM. (If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address.) +To aid in the efficient creation of services, users may opt to first create a [service template](./servicetemplate.md) from which service definitions can be quickly replicated. + +## Fields + +### Name + +A service or protocol name. + +### Protocol + +The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP. + +### Ports + +One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003. + +### IP Addresses + +The [IP address(es)](./ipaddress.md) to which this service is bound. If no IP addresses are bound, the service is assumed to be reachable via any assigned IP address. diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md index 7fed40211..28c66b648 100644 --- a/docs/models/ipam/servicetemplate.md +++ b/docs/models/ipam/servicetemplate.md @@ -1,3 +1,17 @@ # Service Templates -Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses. +Service templates can be used to instantiate [services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md). + +## Fields + +### Name + +A service or protocol name. + +### Protocol + +The wire protocol on which the service runs. Choices include UDP, TCP, and SCTP. + +### Ports + +One or more numeric ports to which the service is bound. Multiple ports can be expressed using commas and/or hyphens. For example, `80,8001-8003` specifies ports 80, 8001, 8002, and 8003. diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index c7aa0d05f..2dd5ec2d3 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -1,11 +1,28 @@ # VLANs -A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into VLAN groups to define scope and to enforce uniqueness. +A Virtual LAN (VLAN) represents an isolated layer two domain, identified by a name and a numeric ID (1-4094) as defined in [IEEE 802.1Q](https://en.wikipedia.org/wiki/IEEE_802.1Q). VLANs are arranged into [VLAN groups](./vlangroup.md) to define scope and to enforce uniqueness. -Each VLAN must be assigned one of the following operational statuses: +## Fields -* Active -* Reserved -* Deprecated +### ID -As with prefixes, each VLAN may also be assigned a functional role. Prefixes and VLANs share the same set of customizable roles. +A 12-bit numeric ID for the VLAN, 1-4094 (inclusive). + +### Name + +The configured VLAN name. + +### Status + +The VLAN's operational status. + +!!! tip + Additional statuses may be defined by setting `VLAN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Role + +The user-defined functional [role](./role.md) assigned to the VLAN. + +### VLAN Group or Site + +The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned. diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 2840fafed..a2920fb70 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -1,7 +1,23 @@ # VLAN Groups -VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. +VLAN groups can be used to organize [VLANs](./vlan.md) within NetBox. Each VLAN group can be scoped to a particular [region](../dcim/region.md), [site group](../dcim/sitegroup.md), [site](../dcim/sitegroup.md), [location](../dcim/location.md), [rack](../dcim/rack.md), [cluster group](../virtualization/clustergroup.md), or [cluster](../virtualization/cluster.md). Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. + +Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, two VLANs with ID 123 may be created, but they cannot both be assigned to the same group. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Minimum & Maximum VLAN IDs A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive). -Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. +### Scope + +The domain covered by a VLAN group, defined as one of the supported object types. This conveys the context in which a VLAN group applies. diff --git a/docs/models/ipam/vrf.md b/docs/models/ipam/vrf.md index 392141fdd..e45a81daf 100644 --- a/docs/models/ipam/vrf.md +++ b/docs/models/ipam/vrf.md @@ -1,14 +1,26 @@ # Virtual Routing and Forwarding (VRF) -A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain. Each VRF is essentially a separate routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). Each VRF may be assigned to a specific tenant to aid in organizing the available IP space by customer or internal user. +A VRF object in NetBox represents a Virtual Routing and Forwarding (VRF) domain. Each VRF is essentially an independent routing table. VRFs are commonly used to isolate customers or organizations from one another within a network, or to route overlapping address space (e.g. multiple instances of the 10.0.0.0/8 space). Each VRF may be assigned to a specific tenant to aid in organizing the available IP space by customer or internal user. -Each VRF is assigned a unique name and an optional route distinguisher (RD). The RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. +Each [prefix](./prefix.md), [IP range](./iprange.md), and [IP address](./ipaddress.md) may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any such object not assigned to a VRF is said to belong to the "global" table. -Each prefix and IP address may be assigned to one (and only one) VRF. If you have a prefix or IP address which exists in multiple VRFs, you will need to create a separate instance of it in NetBox for each VRF. Any prefix or IP address not assigned to a VRF is said to belong to the "global" table. +## Fields -By default, NetBox will allow duplicate prefixes to be assigned to a VRF. This behavior can be toggled by setting the "enforce unique" flag on the VRF model. +### Name + +The configured or administrative name for the VRF instance. + +### Route Distinguisher + +A route distinguisher is used to map routes to VRFs within a device's routing table e.g. for MPLS/VPN. The assignment of a route distinguisher is optional. If defined, the RD is expected to take one of the forms prescribed in [RFC 4364](https://tools.ietf.org/html/rfc4364#section-4.2), however its formatting is not strictly enforced. + +### Enforce Unique Space + +By default, NetBox will permit duplicate prefixes to be assigned to a VRF. This behavior can be toggled by setting the "enforce unique" flag on the VRF model. !!! note Enforcement of unique IP space can be toggled for global table (non-VRF prefixes) using the `ENFORCE_GLOBAL_UNIQUE` configuration setting. -Each VRF may have one or more import and/or export route targets applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs. +### Import & Export Targets + +Each VRF may have one or more import and/or export [route targets](./routetarget.md) applied to it. Route targets are used to control the exchange of routes (prefixes) among VRFs in L3VPNs. diff --git a/docs/models/tenancy/contact.md b/docs/models/tenancy/contact.md index 9d81e2d85..eac630180 100644 --- a/docs/models/tenancy/contact.md +++ b/docs/models/tenancy/contact.md @@ -1,31 +1,33 @@ # Contacts -A contact represent an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. Contacts can be arranged within nested contact groups. +A contact represents an individual or group that has been associated with an object in NetBox for administrative reasons. For example, you might assign one or more operational contacts to each site. -Each contact must include a name, which is unique to its parent group (if any). The following optional descriptors are also available: +## Fields -* Title -* Phone -* Email -* Address +### Group -## Contact Assignment +The [contact group](./contactgroup.md) to which this contact is assigned (if any). -Each contact can be assigned to one or more objects, allowing for the efficient reuse of contact information. When assigning a contact to an object, the user may optionally specify a role and/or priority (primary, secondary, tertiary, or inactive) to better convey the nature of the contact's relationship to the assigned object. +### Name -The following models support the assignment of contacts: +The name of the contact. This may be an individual or a team/department. (This is the only required contact detail; all others are optional.) -* circuits.Circuit -* circuits.Provider -* dcim.Device -* dcim.Location -* dcim.Manufacturer -* dcim.PowerPanel -* dcim.Rack -* dcim.Region -* dcim.Site -* dcim.SiteGroup -* tenancy.Tenant -* virtualization.Cluster -* virtualization.ClusterGroup -* virtualization.VirtualMachine +### Title + +The contact's title or role. + +### Phone + +The contact's phone number. (Note that NetBox does _not_ enforce a particular numbering format.) + +### Email + +The contact's email address. + +### Address + +The contact's physical or mailing address. + +### Link + +A URL to reach the contact via some other means. diff --git a/docs/models/tenancy/contactgroup.md b/docs/models/tenancy/contactgroup.md index ea566c58a..49efb7c18 100644 --- a/docs/models/tenancy/contactgroup.md +++ b/docs/models/tenancy/contactgroup.md @@ -1,3 +1,17 @@ # Contact Groups -Contacts can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated. +[Contacts](./contact.md) can be organized into arbitrary groups. These groups can be recursively nested for convenience. Each contact within a group must have a unique name, but other attributes can be repeated. + +## Fields + +### Parent + +The parent contact group (if any). + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/tenancy/contactrole.md b/docs/models/tenancy/contactrole.md index 23642ca03..bc8e1c802 100644 --- a/docs/models/tenancy/contactrole.md +++ b/docs/models/tenancy/contactrole.md @@ -1,3 +1,13 @@ # Contact Roles -Contacts can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts. +[Contacts](./contact.md) can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for administrative, operational, or emergency contacts. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/tenancy/tenant.md b/docs/models/tenancy/tenant.md index 60a160b9e..7df6992d1 100644 --- a/docs/models/tenancy/tenant.md +++ b/docs/models/tenancy/tenant.md @@ -1,17 +1,17 @@ # Tenants -A tenant represents a discrete grouping of resources used for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. The following objects can be assigned to tenants: +A tenant represents a discrete grouping of resources used for administrative purposes. Typically, tenants are used to represent individual customers or internal departments within an organization. -* Sites -* Racks -* Rack reservations -* Devices -* VRFs -* Prefixes -* IP addresses -* VLANs -* Circuits -* Clusters -* Virtual machines +## Fields -Tenant assignment is used to signify the ownership of an object in NetBox. As such, each object may only be owned by a single tenant. For example, if you have a firewall dedicated to a particular customer, you would assign it to the tenant which represents that customer. However, if the firewall serves multiple customers, it doesn't *belong* to any particular customer, so tenant assignment would not be appropriate. +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) + +### Group + +The [tenant group](./tenantgroup.md) to which this tenant belongs (if any). diff --git a/docs/models/tenancy/tenantgroup.md b/docs/models/tenancy/tenantgroup.md index 078a71a72..f9929ff66 100644 --- a/docs/models/tenancy/tenantgroup.md +++ b/docs/models/tenancy/tenantgroup.md @@ -1,5 +1,19 @@ # Tenant Groups -Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Departments." The assignment of a tenant to a group is optional. +[Tenants](./tenant.md) can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Departments." The assignment of a tenant to a group is optional. Tenant groups may be nested recursively to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team. + +## Fields + +### Parent + +The parent tenant group (if any). + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/users/objectpermission.md b/docs/models/users/objectpermission.md deleted file mode 100644 index 48970dd05..000000000 --- a/docs/models/users/objectpermission.md +++ /dev/null @@ -1,55 +0,0 @@ -# Object Permissions - -A permission in NetBox represents a relationship shared by several components: - -* Object type(s) - One or more types of object in NetBox -* User(s)/Group(s) - One or more users or groups of users -* Action(s) - The action(s) that can be performed on an object -* Constraints - An arbitrary filter used to limit the granted action(s) to a specific subset of objects - -At a minimum, a permission assignment must specify one object type, one user or group, and one action. The specification of constraints is optional: A permission without any constraints specified will apply to all instances of the selected model(s). - -## Actions - -There are four core actions that can be permitted for each type of object within NetBox, roughly analogous to the CRUD convention (create, read, update, and delete): - -* **View** - Retrieve an object from the database -* **Add** - Create a new object -* **Change** - Modify an existing object -* **Delete** - Delete an existing object - -In addition to these, permissions can also grant custom actions that may be required by a specific model or plugin. For example, the `napalm_read` permission on the device model allows a user to execute NAPALM queries on a device via NetBox's REST API. These can be specified when granting a permission in the "additional actions" field. - -!!! note - Internally, all actions granted by a permission (both built-in and custom) are stored as strings in an array field named `actions`. - -## Constraints - -Constraints are expressed as a JSON object or list representing a [Django query filter](https://docs.djangoproject.com/en/stable/ref/models/querysets/#field-lookups). This is the same syntax that you would pass to the QuerySet `filter()` method when performing a query using the Django ORM. As with query filters, double underscores can be used to traverse related objects or invoke lookup expressions. Some example queries and their corresponding definitions are shown below. - -All attributes defined within a single JSON object are applied with a logical AND. For example, suppose you assign a permission for the site model with the following constraints. - -```json -{ - "status": "active", - "region__name": "Americas" -} -``` - -The permission will grant access only to sites which have a status of "active" **and** which are assigned to the "Americas" region. - -To achieve a logical OR with a different set of constraints, define multiple objects within a list. For example, if you want to constrain the permission to VLANs with an ID between 100 and 199 _or_ a status of "reserved," do the following: - -```json -[ - { - "vid__gte": 100, - "vid__lt": 200 - }, - { - "status": "reserved" - } -] -``` - -Additionally, where multiple permissions have been assigned for an object type, their collective constraints will be merged using a logical "OR" operation. diff --git a/docs/models/users/token.md b/docs/models/users/token.md deleted file mode 100644 index d98b51369..000000000 --- a/docs/models/users/token.md +++ /dev/null @@ -1,12 +0,0 @@ -## Tokens - -A token is a unique identifier mapped to a NetBox user account. Each user may have one or more tokens which he or she can use for authentication when making REST API requests. To create a token, navigate to the API tokens page under your user profile. - -!!! note - All users can create and manage REST API tokens under the user control panel in the UI. The ability to view, add, change, or delete tokens via the REST API itself is controlled by the relevant model permissions, assigned to users and/or groups in the admin UI. These permissions should be used with great care to avoid accidentally permitting a user to create tokens for other user accounts. - -Each token contains a 160-bit key represented as 40 hexadecimal characters. When creating a token, you'll typically leave the key field blank so that a random key will be automatically generated. However, NetBox allows you to specify a key in case you need to restore a previously deleted token to operation. - -By default, a token can be used to perform all actions via the API that a user would be permitted to do via the web UI. Deselecting the "write enabled" option will restrict API requests made with the token to read operations (e.g. GET) only. - -Additionally, a token can be set to expire at a specific time. This can be useful if an external client needs to be granted temporary access to NetBox. diff --git a/docs/models/virtualization/cluster.md b/docs/models/virtualization/cluster.md index 7fc9bfc06..50b5dbd1d 100644 --- a/docs/models/virtualization/cluster.md +++ b/docs/models/virtualization/cluster.md @@ -1,5 +1,28 @@ # Clusters -A cluster is a logical grouping of physical resources within which virtual machines run. A cluster must be assigned a type (technological classification), and may optionally be assigned to a cluster group, site, and/or tenant. Each cluster must have a unique name within its assigned group and/or site, if any. +A cluster is a logical grouping of physical resources within which [virtual machines](./virtualmachine.md) run. Physical [devices](../dcim/device.md) may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. -Physical devices may be associated with clusters as hosts. This allows users to track on which host(s) a particular virtual machine may reside. However, NetBox does not support pinning a specific VM within a cluster to a particular host device. +## Fields + +### Name + +A human-friendly name for the cluster. Must be unique within the assigned group and site. + +### Type + +The [cluster type](./clustertype.md) assigned for this cluster. + +### Group + +The [cluster group](./clustergroup.md) to which this cluster belongs. + +### Status + +The cluster's operational status. + +!!! tip + Additional statuses may be defined by setting `Cluster.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Site + +The [site](../dcim/site.md) with which the cluster is associated. diff --git a/docs/models/virtualization/clustergroup.md b/docs/models/virtualization/clustergroup.md index 6dd0f9688..bae177792 100644 --- a/docs/models/virtualization/clustergroup.md +++ b/docs/models/virtualization/clustergroup.md @@ -1,3 +1,13 @@ # Cluster Groups -Cluster groups may be created for the purpose of organizing clusters. The arrangement of clusters into groups is optional. +Cluster groups may be created for the purpose of organizing [clusters](./cluster.md). The arrangement of clusters into groups is optional. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/virtualization/clustertype.md b/docs/models/virtualization/clustertype.md index cee557df3..51eed5ec4 100644 --- a/docs/models/virtualization/clustertype.md +++ b/docs/models/virtualization/clustertype.md @@ -1,3 +1,13 @@ # Cluster Types -A cluster type represents a technology or mechanism by which a cluster is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. +A cluster type represents a technology or mechanism by which a [cluster](./cluster.md) is formed. For example, you might create a cluster type named "VMware vSphere" for a locally hosted cluster or "DigitalOcean NYC3" for one hosted by a cloud provider. + +## Fields + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index de9b5f214..769d49154 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -1,16 +1,53 @@ # Virtual Machines -A virtual machine represents a virtual compute instance hosted within a cluster. Each VM must be assigned to exactly one cluster. +A virtual machine (VM) represents a virtual compute instance hosted within a [cluster](./cluster.md). Each VM must be assigned to a [site](../dcim/site.md) and/or cluster, and may optionally be assigned to a particular host [device](../dcim/device.md) within a cluster. -Like devices, each VM can be assigned a platform and/or functional role, and must have one of the following operational statuses assigned to it: +Virtual machines may have virtual [interfaces](./vminterface.md) assigned to them, but do not support any physical component. When a VM has one or more interfaces with IP addresses assigned, a primary IP for the device can be designated, for both IPv4 and IPv6. -* Active -* Offline -* Planned -* Staged -* Failed -* Decommissioning +## Fields -Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). +### Name -Each VM may optionally be assigned to a tenant. Virtual machines may have virtual interfaces assigned to them, but do not support any physical component. +The virtual machine's configured name. Must be unique to the assigned cluster and tenant. + +### Role + +The functional [role](../dcim/devicerole.md) assigned to the VM. + +### Status + +The VM's operational status. + +!!! tip + Additional statuses may be defined by setting `VirtualMachine.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. + +### Site & Cluster + +The [site](../dcim/site.md) and/or [cluster](./cluster.md) to which the VM is assigned. + +### Device + +The physical host [device](../dcim/device.md) within the assigned site/cluster on which this VM resides. + +### Platform + +A VM may be associated with a particular [platform](../dcim/platform.md) to indicate its operating system. + +### Primary IPv4 & IPv6 Addresses + +Each VM may designate one primary IPv4 address and/or one primary IPv6 address for management purposes. + +!!! tip + NetBox will prefer IPv6 addresses over IPv4 addresses by default. This can be changed by setting the `PREFER_IPV4` configuration parameter. + +### vCPUs + +The number of virtual CPUs provisioned. A VM may be allocated a partial vCPU count (e.g. 1.5 vCPU). + +### Memory + +The amount of running memory provisioned, in megabytes. + +### Disk + +The amount of disk storage provisioned, in gigabytes. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 7f1a5082d..264fb95ba 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -1,3 +1,55 @@ ## Interfaces -Virtual machine interfaces behave similarly to device interfaces, and can be assigned to VRFs, and may have IP addresses, VLANs, and services attached to them. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. +[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and services attached to them, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. + +## Fields + +### Virtual Machine + +The [virtual machine](./virtualmachine.md) to which this interface is assigned. + +### Name + +The interface's name. Must be unique to the assigned VM. + +### Parent Interface + +Identifies the parent interface of a subinterface (e.g. used to employ encapsulation). + +### Bridged Interface + +An interface on the same VM with which this interface is bridged. + +### Enabled + +If not selected, this interface will be treated as disabled/inoperative. + +### MAC Address + +The 48-bit MAC address (for Ethernet interfaces). + +### MTU + +The interface's configured maximum transmissible unit (MTU). + +### 802.1Q Mode + +For switched Ethernet interfaces, this identifies the 802.1Q encapsulation strategy in effect. Options include: + +* **Access:** All traffic is assigned to a single VLAN, with no tagging. +* **Tagged:** One untagged "native" VLAN is allowed, as well as any number of tagged VLANs. +* **Tagged (all):** Implies that all VLANs are carried by the interface. One untagged VLAN may be designated. + +This field must be left blank for routed interfaces which do employ 802.1Q encapsulation. + +### Untagged VLAN + +The "native" (untagged) VLAN for the interface. Valid only when one of the above 802.1Q mode is selected. + +### Tagged VLANs + +The tagged VLANs which are configured to be carried by this interface. Valid only for the "tagged" 802.1Q mode above. + +### VRF + +The [virtual routing and forwarding](../ipam/vrf.md) instance to which this interface is assigned. diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 80a3a40b0..5bb3dbd65 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -1,11 +1,38 @@ # Wireless LANs -A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups. +A wireless LAN is a set of interfaces connected via a common wireless channel, identified by its SSID and authentication parameters. Wireless [interfaces](../dcim/interface.md) can be associated with wireless LANs to model multi-acess wireless segments. -An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs. +## Fields -Each wireless LAN may have authentication attributes associated with it, including: +### SSID -* Authentication type -* Cipher -* Pre-shared key +The service set identifier (SSID) for the wireless network. + +### Group + +The [wireless LAN group](./wirelesslangroup.md) to which this wireless LAN is assigned (if any). + +### VLAN + +Each wireless LAN can optionally be mapped to a [VLAN](../ipam/vlan.md), to model a bridge between wired and wireless segments. + +### Authentication Type + +The type of wireless authentication in use. Options include: + +* Open +* WEP +* WPA Personal (PSK) +* WPA Enterprise + +### Authentication Cipher + +The security cipher used to apply wireless authentication. Options include: + +* Auto (automatic) +* TKIP +* AES + +### Pre-Shared Key + +The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. diff --git a/docs/models/wireless/wirelesslangroup.md b/docs/models/wireless/wirelesslangroup.md index e477abd0b..37871f208 100644 --- a/docs/models/wireless/wirelesslangroup.md +++ b/docs/models/wireless/wirelesslangroup.md @@ -1,3 +1,17 @@ # Wireless LAN Groups -Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group. +Wireless LAN groups can be used to organize and classify [wireless LANs](./wirelesslan.md). These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may be assigned only to one group. + +## Fields + +### Parent + +The parent wireless LAN group (if any). + +### Name + +A unique human-friendly name. + +### Slug + +A unique URL-friendly identifier. (This value can be used for filtering.) diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md index 85cdbd6d9..c9b331570 100644 --- a/docs/models/wireless/wirelesslink.md +++ b/docs/models/wireless/wirelesslink.md @@ -1,9 +1,42 @@ # Wireless Links -A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model. +A wireless link represents a connection between exactly two wireless interfaces. Unlike a [wireless LAN](./wirelesslan.md), which permit an arbitrary number of client associations, wireless links are used to model point-to-point wireless connections. -Each wireless link may have authentication attributes associated with it, including: +## Fields -* Authentication type -* Cipher -* Pre-shared key +### Interfaces + +Select two interfaces: One for side A and one for side B. (Both must be wireless interfaces.) + +### Status + +The operational status of the link. Options include: + +* Connected +* Planned +* Decommissioning + +### SSID + +The service set identifier (SSID) for the wireless link (optional). + +### Authentication Type + +The type of wireless authentication in use. Options include: + +* Open +* WEP +* WPA Personal (PSK) +* WPA Enterprise + +### Authentication Cipher + +The security cipher used to apply wireless authentication. Options include: + +* Auto (automatic) +* TKIP +* AES + +### Pre-Shared Key + +The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. diff --git a/docs/plugins/development/exceptions.md b/docs/plugins/development/exceptions.md new file mode 100644 index 000000000..80f5db258 --- /dev/null +++ b/docs/plugins/development/exceptions.md @@ -0,0 +1,28 @@ +# Exceptions + +The exception classes listed here may be raised by a plugin to alter NetBox's default behavior in various scenarios. + +## `AbortRequest` + +NetBox provides several [generic views](./views.md) and [REST API viewsets](./rest-api.md) which facilitate the creation, modification, and deletion of objects, either individually or in bulk. Under certain conditions, it may be desirable for a plugin to interrupt these actions and cleanly abort the request, reporting an error message to the end user or API consumer. + +For example, a plugin may prohibit the creation of a site with a prohibited name by connecting a receiver to Django's `pre_save` signal for the Site model: + +```python +from django.db.models.signals import pre_save +from django.dispatch import receiver +from dcim.models import Site +from utilities.exceptions import AbortRequest + +PROHIBITED_NAMES = ('foo', 'bar', 'baz') + +@receiver(pre_save, sender=Site) +def test_abort_request(instance, **kwargs): + if instance.name.lower() in PROHIBITED_NAMES: + raise AbortRequest(f"Site name can't be {instance.name}!") +``` + +An error message must be supplied when raising `AbortRequest`. This will be conveyed to the user and should clearly explain the reason for which the request was aborted, as well as any potential remedy. + +!!! tip "Consider custom validation rules" + This exception is intended to be used for handling complex evaluation logic and should be used sparingly. For simple object validation (such as the contrived example above), consider using [custom validation rules](../../customization/custom-validation.md) instead. diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 0e1fec6e5..c58621b81 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -37,7 +37,7 @@ This class performs two crucial functions: 1. Apply any fields, methods, and/or attributes necessary to the operation of these features 2. Register the model with NetBox as utilizing these features -Simply subclass BaseModel when defining a model in your plugin: +Simply subclass NetBoxModel when defining a model in your plugin: ```python # models.py @@ -49,6 +49,24 @@ class MyModel(NetBoxModel): ... ``` +### The `clone()` Method + +!!! info + This method was introduced in NetBox v3.3. + +The `NetBoxModel` class includes a `clone()` method to be used for gathering attributes which can be used to create a "cloned" instance. This is used primarily for form initialization, e.g. when using the "clone" button in the NetBox UI. By default, this method will replicate any fields listed in the model's `clone_fields` list, if defined. + +Plugin models can leverage this method by defining `clone_fields` as a list of field names to be replicated, or override this method to replace or extend its content: + +```python +class MyModel(NetBoxModel): + + def clone(self): + attrs = super().clone() + attrs['extra-value'] = 123 + return attrs +``` + ### Enabling Features Individually If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) @@ -138,7 +156,7 @@ class StatusChoices(ChoiceSet): key = 'MyModel.status' ``` -To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: +To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: ```python FIELD_CHOICES = { diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md index 64616c442..20838149f 100644 --- a/docs/plugins/development/templates.md +++ b/docs/plugins/development/templates.md @@ -215,6 +215,8 @@ The following custom template tags are available in NetBox. ::: utilities.templatetags.builtins.tags.checkmark +::: utilities.templatetags.builtins.tags.customfield_value + ::: utilities.templatetags.builtins.tags.tag ## Filters diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index 92626f8d3..cabcd7045 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -51,15 +51,16 @@ This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Rem NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. -| View Class | Description | -|--------------------|--------------------------------| -| `ObjectView` | View a single object | -| `ObjectEditView` | Create or edit a single object | -| `ObjectDeleteView` | Delete a single object | -| `ObjectListView` | View a list of objects | -| `BulkImportView` | Import a set of new objects | -| `BulkEditView` | Edit multiple objects | -| `BulkDeleteView` | Delete multiple objects | +| View Class | Description | +|----------------------|--------------------------------------------------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectChildrenView` | A list of child objects within the context of a parent | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | !!! warning Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. @@ -99,6 +100,12 @@ Below are the class definitions for NetBox's object views. These views handle CR members: - get_object +::: netbox.views.generic.ObjectChildrenView + selection: + members: + - get_children + - prep_table_data + ## Multi-Object Views Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. diff --git a/docs/rest-api/filtering.md b/docs/reference/filtering.md similarity index 89% rename from docs/rest-api/filtering.md rename to docs/reference/filtering.md index 45dfcfa36..7ddda6f3c 100644 --- a/docs/rest-api/filtering.md +++ b/docs/reference/filtering.md @@ -106,3 +106,23 @@ expression: `n`. Here is an example of a lookup expression on a foreign key, it ```no-highlight GET /api/ipam/vlans/?group_id__n=3203 ``` + +## Ordering Objects + +To order results by a particular field, include the `ordering` query parameter. For example, order the list of sites according to their facility values: + +```no-highlight +GET /api/dcim/sites/?ordering=facility +``` + +To invert the ordering, prepend a hyphen to the field name: + +```no-highlight +GET /api/dcim/sites/?ordering=-facility +``` + +Multiple fields can be specified by separating the field names with a comma. For example: + +```no-highlight +GET /api/dcim/sites/?ordering=facility,-name +``` diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index f8bb365be..8a6a86174 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,17 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 3.3](./version-3.3.md) (August 2022) + +* Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) +* L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157)) +* PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) +* Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) +* Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) +* Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) +* Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495)) +* Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166)) + #### [Version 3.2](./version-3.2.md) (April 2022) * Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 7d6341f44..06b889c22 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -357,7 +357,7 @@ And the response: ... ``` -All GraphQL requests are made at the `/graphql` URL (which also serves the GraphiQL UI). The API is currently read-only, however users who wish to disable it until needed can do so by setting the `GRAPHQL_ENABLED` configuration parameter to False. For more detail on NetBox's GraphQL implementation, see [the GraphQL API documentation](../graphql-api/overview.md). +All GraphQL requests are made at the `/graphql` URL (which also serves the GraphiQL UI). The API is currently read-only, however users who wish to disable it until needed can do so by setting the `GRAPHQL_ENABLED` configuration parameter to False. For more detail on NetBox's GraphQL implementation, see [the GraphQL API documentation](../integrations/graphql-api.md). #### IP Ranges ([#834](https://github.com/netbox-community/netbox/issues/834)) @@ -367,7 +367,7 @@ More information about IP ranges is available [in the documentation](../models/i #### Custom Model Validation ([#5963](https://github.com/netbox-community/netbox/issues/5963)) -This release introduces the [`CUSTOM_VALIDATORS`](../configuration/dynamic-settings.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description: +This release introduces the [`CUSTOM_VALIDATORS`](../configuration/data-validation.md#custom_validators) configuration parameter, which allows administrators to map NetBox models to custom validator classes to enforce custom validation logic. For example, the following configuration requires every site to have a name of at least ten characters and a description: ```python from extras.validators import CustomValidator diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 27ba4e69e..9dce1dfd4 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -313,8 +313,6 @@ Some parameters of NetBox's configuration are now accessible via the admin UI. T Dynamic configuration parameters may also still be defined within `configuration.py`, and the settings defined here take precedence over those defined via the user interface. -For a complete list of supported parameters, please see the [dynamic configuration documentation](../configuration/dynamic-settings.md). - #### First Hop Redundancy Protocol (FHRP) Groups ([#6235](https://github.com/netbox-community/netbox/issues/6235)) A new FHRP group model has been introduced to aid in modeling the configurations of protocols such as HSRP, VRRP, and GLBP. Each FHRP group may be assigned one or more virtual IP addresses, as well as an authentication type and key. Member device and VM interfaces may be associated with one or more FHRP groups, with each assignment receiving a numeric priority designation. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d5aad7a69..a69dc27f6 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -311,7 +311,7 @@ Custom field object assignment is fully supported in the REST API, and functions #### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) -Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/optional-settings.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: +Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/data-validation.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: ```python FIELD_CHOICES = { @@ -335,7 +335,7 @@ FIELD_CHOICES = { #### Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) -A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/dynamic-settings.md##default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: +A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/default-values.md#default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: ```python DEFAULT_USER_PREFERENCES = { diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md new file mode 100644 index 000000000..382d6c29e --- /dev/null +++ b/docs/release-notes/version-3.3.md @@ -0,0 +1,239 @@ +# NetBox v3.3 + +## v3.3.1 (FUTURE) + +### Enhancements + +* [#10061](https://github.com/netbox-community/netbox/issues/10061) - Replicate type when cloning L2VPN instances + +### Bug Fixes + +* [#10040](https://github.com/netbox-community/netbox/issues/10040) - Fix exception when ordering prefixes by flat representation +* [#10053](https://github.com/netbox-community/netbox/issues/10053) - Custom fields header should not be displayed when editing circuit terminations with no custom fields + +--- + +## v3.3.0 (2022-08-17) + +### Breaking Changes + +* Device position, device type height, and rack unit values are now reported as decimals (e.g. `1.0` or `1.5`) to support modeling half-height rack units. +* The `nat_outside` relation on the IP address model now returns a list of zero or more related IP addresses, rather than a single instance (or None). +* Several fields on the cable API serializers have been altered or removed to support multiple-object cable terminations: + +| Old Name | Old Type | New Name | New Type | +|----------------------|----------|-----------------------|----------| +| `termination_a_type` | string | _Removed_ | - | +| `termination_b_type` | string | _Removed_ | - | +| `termination_a_id` | integer | _Removed_ | - | +| `termination_b_id` | integer | _Removed_ | - | +| `termination_a` | object | `a_terminations` | list | +| `termination_b` | object | `b_terminations` | list | + +* As with the cable model, several API fields on all objects to which cables can be connected (interfaces, circuit terminations, etc.) have been changed: + +| Old Name | Old Type | New Name | New Type | +|--------------------------------|----------|---------------------------------|----------| +| `link_peer` | object | `link_peers` | list | +| `link_peer_type` | string | `link_peers_type` | string | +| `connected_endpoint` | object | `connected_endpoints` | list | +| `connected_endpoint_type` | string | `connected_endpoints_type` | string | +| `connected_endpoint_reachable` | boolean | `connected_endpoints_reachable` | boolean | + +* The cable path serialization returned by the `/paths/` endpoint for pass-through ports has been simplified, and the following fields removed: `origin_type`, `origin`, `destination_type`, `destination`. (Additionally, `is_complete` has been added.) + +### New Features + +#### Multi-object Cable Terminations ([#9102](https://github.com/netbox-community/netbox/issues/9102)) + +When creating a cable in NetBox, each end can now be attached to multiple termination points. This allows accurate modeling of duplex fiber connections to individual termination ports and breakout cables, for example. (Note that all terminations attached to one end of a cable must be the same object type, but do not need to connect to the same parent object.) Additionally, cable terminations can now be modified without needing to delete and recreate the cable. + +#### L2VPN Modeling ([#8157](https://github.com/netbox-community/netbox/issues/8157)) + +NetBox can now model a variety of L2 VPN technologies, including VXLAN, VPLS, and others. Interfaces and VLANs can be attached to L2VPNs to track connectivity across an overlay. Similarly to VRFs, each L2VPN can also have import and export route targets associated with it. + +#### PoE Interface Attributes ([#1099](https://github.com/netbox-community/netbox/issues/1099)) + +Two new fields have been added to the device interface model to track Power over Ethernet (PoE) capabilities: + +* **PoE mode**: Power supplying equipment (PSE) or powered device (PD) +* **PoE type**: Applicable IEEE standard or other power type + +#### Half-Height Rack Units ([#51](https://github.com/netbox-community/netbox/issues/51)) + +Device type height can now be specified in 0.5U increments, allowing for the creation of devices consume partial rack units. Additionally, a device can be installed at the half-unit mark within a rack (e.g. U2.5). For example, two half-height devices positioned in sequence will consume a single rack unit; two consecutive 1.5U devices will consume 3U of space. + +#### Restrict API Tokens by Client IP ([#8233](https://github.com/netbox-community/netbox/issues/8233)) + +API tokens can now be restricted to use by certain client IP addresses or networks. For example, an API token with its `allowed_ips` list set to `[192.0.2.0/24]` will permit authentication only from API clients within that network; requests from other sources will fail authentication. This enables administrators to restrict the use of a token to specific clients. + +#### Reference User in Permission Constraints ([#9074](https://github.com/netbox-community/netbox/issues/9074)) + +NetBox's permission constraints have been expanded to support referencing the current user associated with a request using the special `$user` token. As an example, this enables an administrator to efficiently grant each user to edit his or her own journal entries, but not those created by other users. + +```json +{ + "created_by": "$user" +} +``` + +#### Custom Field Grouping ([#8495](https://github.com/netbox-community/netbox/issues/8495)) + +A `group_name` field has been added to the custom field model to enable organizing related custom fields by group. Similarly to custom links, custom fields which have been assigned to the same group will be rendered within that group when viewing an object in the UI. (Custom field grouping has no effect on API operation.) + +#### Toggle Custom Field Visibility ([#9166](https://github.com/netbox-community/netbox/issues/9166)) + +The behavior of each custom field within the NetBox UI can now be controlled individually by toggling its UI visibility. Three options are available: + +* **Read/write**: The custom field is included when viewing and editing objects (default). +* **Read-only**: The custom field is displayed when viewing an object, but it cannot be edited via the UI. (It will appear in the form as a read-only field.) +* **Hidden**: The custom field will never be displayed within the UI. This option is recommended for fields which are not intended for use by human users. + +Custom field UI visibility has no impact on API operation. + +### Enhancements + +* [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations +* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations +* [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster +* [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit +* [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location +* [#8171](https://github.com/netbox-community/netbox/issues/8171) - Populate next available address when cloning an IP +* [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster +* [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster +* [#8511](https://github.com/netbox-community/netbox/issues/8511) - Enable custom fields and tags for circuit terminations +* [#8995](https://github.com/netbox-community/netbox/issues/8995) - Enable arbitrary ordering of REST API results +* [#9070](https://github.com/netbox-community/netbox/issues/9070) - Hide navigation menu items based on user permissions +* [#9177](https://github.com/netbox-community/netbox/issues/9177) - Add tenant assignment for wireless LANs & links +* [#9391](https://github.com/netbox-community/netbox/issues/9391) - Remove 500-character limit for custom link text & URL fields +* [#9536](https://github.com/netbox-community/netbox/issues/9536) - Track API token usage times +* [#9582](https://github.com/netbox-community/netbox/issues/9582) - Enable assigning config contexts based on device location + +### Bug Fixes (from Beta2) + +* [#9758](https://github.com/netbox-community/netbox/issues/9758) - Display parent object of connected termination +* [#9900](https://github.com/netbox-community/netbox/issues/9900) - Pre-populate site & rack fields for cable connection form +* [#9938](https://github.com/netbox-community/netbox/issues/9938) - Exclude virtual interfaces from terminations list when connecting a cable +* [#9939](https://github.com/netbox-community/netbox/issues/9939) - Fix list of next nodes for split paths under trace view + +### Plugins API + +* [#9075](https://github.com/netbox-community/netbox/issues/9075) - Introduce `AbortRequest` exception for cleanly interrupting object mutations +* [#9092](https://github.com/netbox-community/netbox/issues/9092) - Add support for `ObjectChildrenView` generic view +* [#9228](https://github.com/netbox-community/netbox/issues/9228) - Subclasses of `ChangeLoggingMixin` can override `serialize_object()` to control JSON serialization for change logging +* [#9414](https://github.com/netbox-community/netbox/issues/9414) - Add `clone()` method to NetBoxModel for copying instance attributes +* [#9647](https://github.com/netbox-community/netbox/issues/9647) - Introduce `customfield_value` template tag + +### Other Changes + +* [#9261](https://github.com/netbox-community/netbox/issues/9261) - `NetBoxTable` no longer automatically clears pre-existing calls to `prefetch_related()` on its queryset +* [#9434](https://github.com/netbox-community/netbox/issues/9434) - Enabled `django-rich` test runner for more user-friendly output +* [#9903](https://github.com/netbox-community/netbox/issues/9903) - Implement a mechanism for automatically updating denormalized fields + +### REST API Changes + +* List results can now be ordered by field, by appending `?ordering={fieldname}` to the query. Multiple fields can be specified by separating the field names with a comma, e.g. `?ordering=site,name`. To invert the ordering, prepend a hyphen to the field name, e.g. `?ordering=-name`. +* Added the following endpoints: + * `/api/dcim/cable-terminations/` + * `/api/ipam/l2vpns/` + * `/api/ipam/l2vpn-terminations/` +* circuits.Circuit + * Added optional `termination_date` field +* circuits.CircuitTermination + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` + * Added `custom_fields` and `tags` fields +* dcim.Cable + * `termination_a_type` has been renamed to `a_terminations_type` + * `termination_b_type` has been renamed to `b_terminations_type` + * `termination_a` renamed to `a_terminations` and now returns a list of objects + * `termination_b` renamed to `b_terminations` and now returns a list of objects + * `termination_a_id` has been removed + * `termination_b_id` has been removed +* dcim.ConsolePort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.ConsoleServerPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.Device + * The `position` field has been changed from an integer to a decimal +* dcim.DeviceType + * The `u_height` field has been changed from an integer to a decimal +* dcim.FrontPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.Interface + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` + * Added the optional `poe_mode` and `poe_type` fields + * Added the `l2vpn_termination` read-only field +* dcim.InterfaceTemplate + * Added the optional `poe_mode` and `poe_type` fields +* dcim.Location + * Added required `status` field (default value: `active`) +* dcim.PowerOutlet + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.PowerFeed + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.PowerPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* dcim.Rack + * The `elevation` endpoint now includes half-height rack units, and utilizes decimal values for the ID and name of each unit +* dcim.RearPort + * `link_peer` has been renamed to `link_peers` and now returns a list of objects + * `link_peer_type` has been renamed to `link_peers_type` + * `connected_endpoint` has been renamed to `connected_endpoints` and now returns a list of objects + * `connected_endpoint_type` has been renamed to `connected_endpoints_type` + * `connected_endpoint_reachable` has been renamed to `connected_endpoints_reachable` +* extras.ConfigContext + * Added the `locations` many-to-many field to track the assignment of ConfigContexts to Locations +* extras.CustomField + * Added `group_name` and `ui_visibility` fields +* ipam.IPAddress + * The `nat_inside` field no longer requires a unique value + * The `nat_outside` field has changed from a single IP address instance to a list of multiple IP addresses +* ipam.VLAN + * Added the `l2vpn_termination` read-only field +* users.Token + * Added the `allowed_ips` array field + * Added the read-only `last_used` datetime field +* virtualization.Cluster + * Added required `status` field (default value: `active`) +* virtualization.VirtualMachine + * The `site` field is now directly writable (rather than being inferred from the assigned cluster) + * The `cluster` field is now optional. A virtual machine must have a site and/or cluster assigned. + * Added the optional `device` field + * Added the `l2vpn_termination` read-only field +wireless.WirelessLAN + * Added `tenant` field +wireless.WirelessLink + * Added `tenant` field diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md deleted file mode 100644 index 11b8cd6bf..000000000 --- a/docs/rest-api/authentication.md +++ /dev/null @@ -1,68 +0,0 @@ -# REST API Authentication - -The NetBox REST API primarily employs token-based authentication. For convenience, cookie-based authentication can also be used when navigating the browsable API. - -{!models/users/token.md!} - -## Authenticating to the API - -An authentication token is attached to a request by setting the `Authorization` header to the string `Token` followed by a space and the user's token: - -``` -$ curl -H "Authorization: Token $TOKEN" \ --H "Accept: application/json; indent=4" \ -https://netbox/api/dcim/sites/ -{ - "count": 10, - "next": null, - "previous": null, - "results": [...] -} -``` - -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../configuration/optional-settings.md#exempt_view_permissions) configuration parameter). However, if a token _is_ required but not present in a request, the API will return a 403 (Forbidden) response: - -``` -$ curl https://netbox/api/dcim/sites/ -{ - "detail": "Authentication credentials were not provided." -} -``` - -## Initial Token Provisioning - -Ideally, each user should provision his or her own REST API token(s) via the web UI. However, you may encounter where a token must be created by a user via the REST API itself. NetBox provides a special endpoint to provision tokens using a valid username and password combination. - -To provision a token via the REST API, make a `POST` request to the `/api/users/tokens/provision/` endpoint: - -``` -$ curl -X POST \ --H "Content-Type: application/json" \ --H "Accept: application/json; indent=4" \ -https://netbox/api/users/tokens/provision/ \ ---data '{ - "username": "hankhill", - "password": "I<3C3H8", -}' -``` - -Note that we are _not_ passing an existing REST API token with this request. If the supplied credentials are valid, a new REST API token will be automatically created for the user. Note that the key will be automatically generated, and write ability will be enabled. - -```json -{ - "id": 6, - "url": "https://netbox/api/users/tokens/6/", - "display": "3c9cb9 (hankhill)", - "user": { - "id": 2, - "url": "https://netbox/api/users/users/2/", - "display": "hankhill", - "username": "hankhill" - }, - "created": "2021-06-11T20:09:13.339367Z", - "expires": null, - "key": "9fc9b897abec9ada2da6aec9dbc34596293c9cb9", - "write_enabled": true, - "description": "" -} -``` diff --git a/mkdocs.yml b/mkdocs.yml index 507b25627..eef000481 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,6 +5,7 @@ repo_name: netbox-community/netbox repo_url: https://github.com/netbox-community/netbox theme: name: material + custom_dir: docs/_theme/ icon: repo: fontawesome/brands/github palette: @@ -37,6 +38,7 @@ plugins: show_root_toc_entry: false show_source: false extra: + readthedocs: !ENV READTHEDOCS social: - icon: fontawesome/brands/github link: https://github.com/netbox-community/netbox @@ -47,18 +49,37 @@ extra_css: markdown_extensions: - admonition - attr_list - - markdown_include.include: - base_path: 'docs/' - headingOffset: 1 - pymdownx.emoji: emoji_index: !!python/name:materialx.emoji.twemoji emoji_generator: !!python/name:materialx.emoji.to_svg - - pymdownx.superfences + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format - pymdownx.tabbed: alternate_style: true nav: - - Introduction: 'index.md' - - Installation: + - Introduction: 'introduction.md' + - Features: + - Facilities: 'features/facilities.md' + - Devices & Cabling: 'features/devices-cabling.md' + - Power Tracking: 'features/power-tracking.md' + - IPAM: 'features/ipam.md' + - VLAN Management: 'features/vlan-management.md' + - L2VPN & Overlay: 'features/l2vpn-overlay.md' + - Circuits: 'features/circuits.md' + - Wireless: 'features/wireless.md' + - Virtualization: 'features/virtualization.md' + - Tenancy: 'features/tenancy.md' + - Contacts: 'features/contacts.md' + - Context Data: 'features/context-data.md' + - Change Logging: 'features/change-logging.md' + - Journaling: 'features/journaling.md' + - Auth & Permissions: 'features/authentication-permissions.md' + - API & Integration: 'features/api-integration.md' + - Customization: 'features/customization.md' + - Installation & Upgrade: - Installing NetBox: 'installation/index.md' - 1. PostgreSQL: 'installation/1-postgresql.md' - 2. Redis: 'installation/2-redis.md' @@ -67,43 +88,36 @@ nav: - 5. HTTP Server: 'installation/5-http-server.md' - 6. LDAP (Optional): 'installation/6-ldap.md' - Upgrading NetBox: 'installation/upgrading.md' - - Migrating to systemd: 'installation/migrating-to-systemd.md' + - Getting Started: + - Planning: 'getting-started/planning.md' + - Populating Data: 'getting-started/populating-data.md' - Configuration: - Configuring NetBox: 'configuration/index.md' - - Required Settings: 'configuration/required-settings.md' - - Optional Settings: 'configuration/optional-settings.md' - - Dynamic Settings: 'configuration/dynamic-settings.md' - - Error Reporting: 'configuration/error-reporting.md' + - Required Parameters: 'configuration/required-parameters.md' + - System: 'configuration/system.md' + - Security: 'configuration/security.md' - Remote Authentication: 'configuration/remote-authentication.md' - - Core Functionality: - - IP Address Management: 'core-functionality/ipam.md' - - VLAN Management: 'core-functionality/vlans.md' - - Sites and Racks: 'core-functionality/sites-and-racks.md' - - Devices and Cabling: 'core-functionality/devices.md' - - Device Types: 'core-functionality/device-types.md' - - Modules: 'core-functionality/modules.md' - - Virtualization: 'core-functionality/virtualization.md' - - Service Mapping: 'core-functionality/services.md' - - Circuits: 'core-functionality/circuits.md' - - Wireless: 'core-functionality/wireless.md' - - Power Tracking: 'core-functionality/power.md' - - Tenancy: 'core-functionality/tenancy.md' - - Contacts: 'core-functionality/contacts.md' + - Data & Validation: 'configuration/data-validation.md' + - Default Values: 'configuration/default-values.md' + - Error Reporting: 'configuration/error-reporting.md' + - Plugins: 'configuration/plugins.md' + - NAPALM: 'configuration/napalm.md' + - Date & Time: 'configuration/date-time.md' + - Miscellaneous: 'configuration/miscellaneous.md' + - Development: 'configuration/development.md' - Customization: - Custom Fields: 'customization/custom-fields.md' + - Custom Links: 'customization/custom-links.md' - Custom Validation: 'customization/custom-validation.md' - - Custom Links: 'models/extras/customlink.md' - Export Templates: 'customization/export-templates.md' - - Custom Scripts: 'customization/custom-scripts.md' - Reports: 'customization/reports.md' - - Additional Features: - - Change Logging: 'additional-features/change-logging.md' - - Context Data: 'models/extras/configcontext.md' - - Journaling: 'additional-features/journaling.md' - - NAPALM: 'additional-features/napalm.md' - - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - - Tags: 'models/extras/tag.md' - - Webhooks: 'additional-features/webhooks.md' + - Custom Scripts: 'customization/custom-scripts.md' + - Integrations: + - REST API: 'integrations/rest-api.md' + - GraphQL API: 'integrations/graphql-api.md' + - Webhooks: 'integrations/webhooks.md' + - NAPALM: 'integrations/napalm.md' + - Prometheus Metrics: 'integrations/prometheus-metrics.md' - Plugins: - Using Plugins: 'plugins/index.md' - Developing Plugins: @@ -118,6 +132,7 @@ nav: - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Tasks: 'plugins/development/background-tasks.md' + - Exceptions: 'plugins/development/exceptions.md' - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' @@ -128,13 +143,98 @@ nav: - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - - REST API: - - Overview: 'rest-api/overview.md' - - Filtering: 'rest-api/filtering.md' - - Authentication: 'rest-api/authentication.md' - - GraphQL API: - - Overview: 'graphql-api/overview.md' + - Data Model: + - Circuits: + - Circuit: 'models/circuits/circuit.md' + - Circuit Termination: 'models/circuits/circuittermination.md' + - Circuit Type: 'models/circuits/circuittype.md' + - Provider: 'models/circuits/provider.md' + - Provider Network: 'models/circuits/providernetwork.md' + - DCIM: + - Cable: 'models/dcim/cable.md' + - ConsolePort: 'models/dcim/consoleport.md' + - ConsolePortTemplate: 'models/dcim/consoleporttemplate.md' + - ConsoleServerPort: 'models/dcim/consoleserverport.md' + - ConsoleServerPortTemplate: 'models/dcim/consoleserverporttemplate.md' + - Device: 'models/dcim/device.md' + - DeviceBay: 'models/dcim/devicebay.md' + - DeviceBayTemplate: 'models/dcim/devicebaytemplate.md' + - DeviceRole: 'models/dcim/devicerole.md' + - DeviceType: 'models/dcim/devicetype.md' + - FrontPort: 'models/dcim/frontport.md' + - FrontPortTemplate: 'models/dcim/frontporttemplate.md' + - Interface: 'models/dcim/interface.md' + - InterfaceTemplate: 'models/dcim/interfacetemplate.md' + - InventoryItem: 'models/dcim/inventoryitem.md' + - InventoryItemRole: 'models/dcim/inventoryitemrole.md' + - InventoryItemTemplate: 'models/dcim/inventoryitemtemplate.md' + - Location: 'models/dcim/location.md' + - Manufacturer: 'models/dcim/manufacturer.md' + - Module: 'models/dcim/module.md' + - ModuleBay: 'models/dcim/modulebay.md' + - ModuleBayTemplate: 'models/dcim/modulebaytemplate.md' + - ModuleType: 'models/dcim/moduletype.md' + - Platform: 'models/dcim/platform.md' + - PowerFeed: 'models/dcim/powerfeed.md' + - PowerOutlet: 'models/dcim/poweroutlet.md' + - PowerOutletTemplate: 'models/dcim/poweroutlettemplate.md' + - PowerPanel: 'models/dcim/powerpanel.md' + - PowerPort: 'models/dcim/powerport.md' + - PowerPortTemplate: 'models/dcim/powerporttemplate.md' + - Rack: 'models/dcim/rack.md' + - RackReservation: 'models/dcim/rackreservation.md' + - RackRole: 'models/dcim/rackrole.md' + - RearPort: 'models/dcim/rearport.md' + - RearPortTemplate: 'models/dcim/rearporttemplate.md' + - Region: 'models/dcim/region.md' + - Site: 'models/dcim/site.md' + - SiteGroup: 'models/dcim/sitegroup.md' + - VirtualChassis: 'models/dcim/virtualchassis.md' + - Extras: + - ConfigContext: 'models/extras/configcontext.md' + - CustomField: 'models/extras/customfield.md' + - CustomLink: 'models/extras/customlink.md' + - ExportTemplate: 'models/extras/exporttemplate.md' + - ImageAttachment: 'models/extras/imageattachment.md' + - JournalEntry: 'models/extras/journalentry.md' + - Tag: 'models/extras/tag.md' + - Webhook: 'models/extras/webhook.md' + - IPAM: + - ASN: 'models/ipam/asn.md' + - Aggregate: 'models/ipam/aggregate.md' + - FHRPGroup: 'models/ipam/fhrpgroup.md' + - FHRPGroupAssignment: 'models/ipam/fhrpgroupassignment.md' + - IPAddress: 'models/ipam/ipaddress.md' + - IPRange: 'models/ipam/iprange.md' + - L2VPN: 'models/ipam/l2vpn.md' + - L2VPNTermination: 'models/ipam/l2vpntermination.md' + - Prefix: 'models/ipam/prefix.md' + - RIR: 'models/ipam/rir.md' + - Role: 'models/ipam/role.md' + - RouteTarget: 'models/ipam/routetarget.md' + - Service: 'models/ipam/service.md' + - ServiceTemplate: 'models/ipam/servicetemplate.md' + - VLAN: 'models/ipam/vlan.md' + - VLANGroup: 'models/ipam/vlangroup.md' + - VRF: 'models/ipam/vrf.md' + - Tenancy: + - Contact: 'models/tenancy/contact.md' + - ContactGroup: 'models/tenancy/contactgroup.md' + - ContactRole: 'models/tenancy/contactrole.md' + - Tenant: 'models/tenancy/tenant.md' + - TenantGroup: 'models/tenancy/tenantgroup.md' + - Virtualization: + - Cluster: 'models/virtualization/cluster.md' + - ClusterGroup: 'models/virtualization/clustergroup.md' + - ClusterType: 'models/virtualization/clustertype.md' + - VMInterface: 'models/virtualization/vminterface.md' + - VirtualMachine: 'models/virtualization/virtualmachine.md' + - Wireless: + - WirelessLAN: 'models/wireless/wirelesslan.md' + - WirelessLANGroup: 'models/wireless/wirelesslangroup.md' + - WirelessLink: 'models/wireless/wirelesslink.md' - Reference: + - Filtering: 'reference/filtering.md' - Conditions: 'reference/conditions.md' - Markdown: 'reference/markdown.md' - Development: @@ -151,6 +251,7 @@ nav: - Release Checklist: 'development/release-checklist.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.3: 'release-notes/version-3.3.md' - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' - Version 3.0: 'release-notes/version-3.0.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 6f7cb4f21..8fc1bfaf7 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from circuits.models import * -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 19570f067..c1d856f39 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -2,12 +2,12 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices from circuits.models import * -from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer -from dcim.api.serializers import LinkTerminationSerializer +from dcim.api.nested_serializers import NestedSiteSerializer +from dcim.api.serializers import CabledObjectSerializer from ipam.models import ASN from ipam.api.nested_serializers import NestedASNSerializer -from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -92,23 +92,22 @@ class CircuitSerializer(NetBoxModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', - 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', + 'commit_rate', 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', ] -class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer): +class CircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() site = NestedSiteSerializer(required=False, allow_null=True) provider_network = NestedProviderNetworkSerializer(required=False, allow_null=True) - cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', - 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', 'created', 'last_updated', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 616adfaa4..9d75009d5 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 3573c05e3..f5f3f0fab 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -58,7 +58,7 @@ class CircuitViewSet(NetBoxModelViewSet): class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = CircuitTermination.objects.prefetch_related( - 'circuit', 'site', 'provider_network', 'cable' + 'circuit', 'site', 'provider_network', 'cable__terminations' ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filtersets.CircuitTerminationFilterSet diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index b7fa100a8..cee38fb18 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -1,10 +1,10 @@ import django_filters from django.db.models import Q -from dcim.filtersets import CableTerminationFilterSet +from dcim.filtersets import CabledObjectFilterSet from dcim.models import Region, Site, SiteGroup from ipam.models import ASN -from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet +from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -183,7 +183,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte class Meta: model = Circuit - fields = ['id', 'cid', 'description', 'install_date', 'commit_rate'] + fields = ['id', 'cid', 'description', 'install_date', 'termination_date', 'commit_rate'] def search(self, queryset, name, value): if not value.strip(): @@ -198,7 +198,7 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte ).distinct() -class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFilterSet): +class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -224,7 +224,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi class Meta: model = CircuitTermination - fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description'] + fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description', 'cable_end'] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 6e283219a..b6ba42afb 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -7,7 +7,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect, ) @@ -122,6 +122,14 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) + install_date = forms.DateField( + required=False, + widget=DatePicker() + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker() + ) commit_rate = forms.IntegerField( required=False, label='Commit rate (Kbps)' @@ -137,7 +145,9 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm): model = Circuit fieldsets = ( - (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ('Circuit', ('provider', 'type', 'status', 'description')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), + ('Tenancy', ('tenant',)), ) nullable_fields = ( 'tenant', 'commit_rate', 'description', 'comments', diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 6da79f75c..cc2d0409a 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -72,5 +72,6 @@ class CircuitCSVForm(NetBoxModelCSVForm): class Meta: model = Circuit fields = [ - 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', + 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', + 'description', 'comments', ] diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index 46d3824bb..29410ffdf 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -7,7 +7,7 @@ from dcim.models import Region, Site, SiteGroup from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField +from utilities.forms import DatePicker, DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -84,7 +84,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi fieldsets = ( (None, ('q', 'tag')), ('Provider', ('provider_id', 'provider_network_id')), - ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Attributes', ('type_id', 'status', 'install_date', 'termination_date', 'commit_rate')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -130,6 +130,14 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi }, label=_('Site') ) + install_date = forms.DateField( + required=False, + widget=DatePicker + ) + termination_date = forms.DateField( + required=False, + widget=DatePicker + ) commit_rate = forms.IntegerField( required=False, min_value=0, diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 8fd5fb92d..7bd7abbbf 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -93,15 +93,16 @@ class CircuitForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Circuit', ('provider', 'cid', 'type', 'status', 'description', 'tags')), + ('Service Parameters', ('install_date', 'termination_date', 'commit_rate')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Circuit fields = [ - 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', - 'comments', 'tags', + 'cid', 'type', 'provider', 'status', 'install_date', 'termination_date', 'commit_rate', 'description', + 'tenant_group', 'tenant', 'comments', 'tags', ] help_texts = { 'cid': "Unique circuit ID", @@ -110,11 +111,12 @@ class CircuitForm(TenancyForm, NetBoxModelForm): widgets = { 'status': StaticSelect(), 'install_date': DatePicker(), + 'termination_date': DatePicker(), 'commit_rate': SelectSpeedWidget(), } -class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): +class CircuitTerminationForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False, @@ -159,7 +161,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): model = CircuitTermination fields = [ 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', - 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index 027b53203..e96fe98a5 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,4 +1,6 @@ from circuits import filtersets, models +from dcim.graphql.mixins import CabledObjectMixin +from extras.graphql.mixins import CustomFieldsMixin, TagsMixin from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( @@ -10,7 +12,7 @@ __all__ = ( ) -class CircuitTerminationType(ObjectType): +class CircuitTerminationType(CustomFieldsMixin, TagsMixin, CabledObjectMixin, ObjectType): class Meta: model = models.CircuitTermination diff --git a/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py new file mode 100644 index 000000000..c686bf042 --- /dev/null +++ b/netbox/circuits/migrations/0036_circuit_termination_date_tags_custom_fields.py @@ -0,0 +1,28 @@ +import django.core.serializers.json +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0035_provider_asns'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='termination_date', + field=models.DateField(blank=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='circuittermination', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), + ] diff --git a/netbox/circuits/migrations/0037_new_cabling_models.py b/netbox/circuits/migrations/0037_new_cabling_models.py new file mode 100644 index 000000000..ee08147f3 --- /dev/null +++ b/netbox/circuits/migrations/0037_new_cabling_models.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0036_circuit_termination_date_tags_custom_fields'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + ] diff --git a/netbox/circuits/migrations/0038_cabling_cleanup.py b/netbox/circuits/migrations/0038_cabling_cleanup.py new file mode 100644 index 000000000..0672057e3 --- /dev/null +++ b/netbox/circuits/migrations/0038_cabling_cleanup.py @@ -0,0 +1,20 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0037_new_cabling_models'), + ('dcim', '0160_populate_cable_ends'), + ] + + operations = [ + migrations.RemoveField( + model_name='circuittermination', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='circuittermination', + name='_link_peer_type', + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index c14e365e1..c08b5473a 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,10 @@ from django.db import models from django.urls import reverse from circuits.choices import * -from dcim.models import LinkTermination -from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel +from dcim.models import CabledObjectModel +from netbox.models import ( + ChangeLoggedModel, CustomFieldsMixin, CustomLinksMixin, OrganizationalModel, NetBoxModel, TagsMixin, +) from netbox.models.features import WebhooksMixin __all__ = ( @@ -79,7 +81,12 @@ class Circuit(NetBoxModel): install_date = models.DateField( blank=True, null=True, - verbose_name='Date installed' + verbose_name='Installed' + ) + termination_date = models.DateField( + blank=True, + null=True, + verbose_name='Terminates' ) commit_rate = models.PositiveIntegerField( blank=True, @@ -119,9 +126,9 @@ class Circuit(NetBoxModel): null=True ) - clone_fields = [ - 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', - ] + clone_fields = ( + 'provider', 'type', 'status', 'tenant', 'install_date', 'termination_date', 'commit_rate', 'description', + ) class Meta: ordering = ['provider', 'cid'] @@ -141,7 +148,14 @@ class Circuit(NetBoxModel): return CircuitStatusChoices.colors.get(self.status) -class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): +class CircuitTermination( + CustomFieldsMixin, + CustomLinksMixin, + TagsMixin, + WebhooksMixin, + ChangeLoggedModel, + CabledObjectModel +): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index 4211a54a6..e136e13ea 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -61,9 +61,9 @@ class Provider(NetBoxModel): to='tenancy.ContactAssignment' ) - clone_fields = [ + clone_fields = ( 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - ] + ) class Meta: ordering = ['name'] diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 6ec9cc6c3..70f2abb41 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -24,4 +24,4 @@ def rebuild_cablepaths(instance, raw=False, **kwargs): if not raw: peer_termination = instance.get_peer_termination() if peer_termination: - rebuild_paths(peer_termination) + rebuild_paths([peer_termination]) diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py index ea4310def..f9ab7e190 100644 --- a/netbox/circuits/tables/circuits.py +++ b/netbox/circuits/tables/circuits.py @@ -68,8 +68,9 @@ class CircuitTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Circuit fields = ( - 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'tenant_group', 'termination_a', 'termination_z', + 'install_date', 'termination_date', 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 205236712..abcfa8a00 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -208,12 +208,12 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): ProviderNetwork.objects.bulk_create(provider_networks) circuits = ( - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), - Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), - Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), - Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', termination_date='2021-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'), + Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', termination_date='2021-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'), + Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', termination_date='2021-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', termination_date='2021-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', termination_date='2021-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), + Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 6', install_date='2020-01-06', termination_date='2021-01-06', commit_rate=6000, status=CircuitStatusChoices.STATUS_OFFLINE), ) Circuit.objects.bulk_create(circuits) @@ -235,6 +235,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'install_date': ['2020-01-01', '2020-01-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_termination_date(self): + params = {'termination_date': ['2021-01-01', '2021-01-02']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_commit_rate(self): params = {'commit_rate': ['1000', '2000']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -356,7 +360,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): )) CircuitTermination.objects.bulk_create(circuit_terminations) - Cable(termination_a=circuit_terminations[0], termination_b=circuit_terminations[1]).save() + Cable(a_terminations=[circuit_terminations[0]], b_terminations=[circuit_terminations[1]]).save() def test_term_side(self): params = {'term_side': 'A'} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 17c846c86..fa6146b93 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -130,6 +130,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'status': CircuitStatusChoices.STATUS_DECOMMISSIONED, 'tenant': None, 'install_date': datetime.date(2020, 1, 1), + 'termination_date': datetime.date(2021, 1, 1), 'commit_rate': 1000, 'description': 'A new circuit', 'comments': 'Some comments', @@ -245,7 +246,7 @@ class CircuitTerminationTestCase( device=device, name='Interface 1' ) - Cable(termination_a=circuittermination, termination_b=interface).save() + Cable(a_terminations=[circuittermination], b_terminations=[interface]).save() response = self.client.get(reverse('circuits:circuittermination_trace', kwargs={'pk': circuittermination.pk})) self.assertHttpStatus(response, 200) diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index f3ee64cf0..5b15b29ac 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from dcim.views import CableCreateView, PathTraceView +from dcim.views import PathTraceView from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * @@ -60,7 +60,6 @@ urlpatterns = [ path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), - path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), path('circuit-terminations//trace/', PathTraceView.as_view(), name='circuittermination_trace', kwargs={'model': CircuitTermination}), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 11f211b27..423bd67d6 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -30,7 +30,8 @@ class ProviderView(generic.ObjectView): circuits = Circuit.objects.restrict(request.user, 'view').filter( provider=instance ).prefetch_related( - 'type', 'tenant', 'tenant__group', 'terminations__site' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) circuits_table = tables.CircuitTable(circuits, user=request.user, exclude=('provider',)) circuits_table.configure(request) @@ -91,7 +92,8 @@ class ProviderNetworkView(generic.ObjectView): Q(termination_a__provider_network=instance.pk) | Q(termination_z__provider_network=instance.pk) ).prefetch_related( - 'type', 'tenant', 'tenant__group', 'terminations__site' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) circuits_table = tables.CircuitTable(circuits, user=request.user) circuits_table.configure(request) @@ -192,7 +194,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'tenant__group', 'termination_a', 'termination_z' + 'tenant__group', 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet filterset_form = forms.CircuitFilterForm @@ -220,7 +223,8 @@ class CircuitBulkImportView(generic.BulkImportView): class CircuitBulkEditView(generic.BulkEditView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' + 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable @@ -229,7 +233,8 @@ class CircuitBulkEditView(generic.BulkEditView): class CircuitBulkDeleteView(generic.BulkDeleteView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' + 'termination_a__site', 'termination_z__site', + 'termination_a__provider_network', 'termination_z__provider_network', ) filterset = filtersets.CircuitFilterSet table = tables.CircuitTable diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d95480aa7..249a3f167 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,5 @@ +import decimal + from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -7,12 +9,14 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.api.nested_serializers import ( - NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, + NestedASNSerializer, NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, + NestedVRFSerializer, ) from ipam.models import ASN, VLAN -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + GenericObjectSerializer, NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, + WritableNestedSerializer, ) from netbox.config import ConfigItem from netbox.constants import NESTED_SERIALIZER_PREFIX @@ -26,58 +30,68 @@ from wireless.models import WirelessLAN from .nested_serializers import * -class LinkTerminationSerializer(serializers.ModelSerializer): - link_peer_type = serializers.SerializerMethodField(read_only=True) - link_peer = serializers.SerializerMethodField(read_only=True) +class CabledObjectSerializer(serializers.ModelSerializer): + cable = NestedCableSerializer(read_only=True) + cable_end = serializers.CharField(read_only=True) + link_peers_type = serializers.SerializerMethodField(read_only=True) + link_peers = serializers.SerializerMethodField(read_only=True) _occupied = serializers.SerializerMethodField(read_only=True) - def get_link_peer_type(self, obj): - if obj._link_peer is not None: - return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}' + def get_link_peers_type(self, obj): + """ + Return the type of the peer link terminations, or None. + """ + if not obj.cable: + return None + + if obj.link_peers: + return f'{obj.link_peers[0]._meta.app_label}.{obj.link_peers[0]._meta.model_name}' + return None - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_link_peer(self, obj): + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_link_peers(self, obj): """ Return the appropriate serializer for the link termination model. """ - if obj._link_peer is not None: - serializer = get_serializer_for_model(obj._link_peer, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj._link_peer, context=context).data - return None + if not obj.link_peers: + return [] + + # Return serialized peer termination objects + serializer = get_serializer_for_model(obj.link_peers[0], prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(obj.link_peers, context=context, many=True).data @swagger_serializer_method(serializer_or_field=serializers.BooleanField) def get__occupied(self, obj): return obj._occupied -class ConnectedEndpointSerializer(serializers.ModelSerializer): - connected_endpoint_type = serializers.SerializerMethodField(read_only=True) - connected_endpoint = serializers.SerializerMethodField(read_only=True) - connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True) +class ConnectedEndpointsSerializer(serializers.ModelSerializer): + """ + Legacy serializer for pre-v3.3 connections + """ + connected_endpoints_type = serializers.SerializerMethodField(read_only=True) + connected_endpoints = serializers.SerializerMethodField(read_only=True) + connected_endpoints_reachable = serializers.SerializerMethodField(read_only=True) - def get_connected_endpoint_type(self, obj): - if obj._path is not None and obj._path.destination is not None: - return f'{obj._path.destination._meta.app_label}.{obj._path.destination._meta.model_name}' - return None + def get_connected_endpoints_type(self, obj): + if endpoints := obj.connected_endpoints: + return f'{endpoints[0]._meta.app_label}.{endpoints[0]._meta.model_name}' - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_connected_endpoint(self, obj): + @swagger_serializer_method(serializer_or_field=serializers.ListField) + def get_connected_endpoints(self, obj): """ Return the appropriate serializer for the type of connected object. """ - if obj._path is not None and obj._path.destination is not None: - serializer = get_serializer_for_model(obj._path.destination, prefix=NESTED_SERIALIZER_PREFIX) + if endpoints := obj.connected_endpoints: + serializer = get_serializer_for_model(endpoints[0], prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} - return serializer(obj._path.destination, context=context).data - return None + return serializer(endpoints, many=True, context=context).data @swagger_serializer_method(serializer_or_field=serializers.BooleanField) - def get_connected_endpoint_reachable(self, obj): - if obj._path is not None: - return obj._path.is_active - return None + def get_connected_endpoints_reachable(self, obj): + return obj._path and obj._path.is_complete and obj._path.is_active # @@ -150,6 +164,7 @@ class LocationSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() parent = NestedLocationSerializer(required=False, allow_null=True) + status = ChoiceField(choices=LocationStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) @@ -157,8 +172,8 @@ class LocationSerializer(NestedGroupModelSerializer): class Meta: model = Location fields = [ - 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'tenant', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', ] @@ -202,7 +217,11 @@ class RackUnitSerializer(serializers.Serializer): """ A rack unit is an abstraction formed by the set (rack, position, face); it does not exist as a row in the database. """ - id = serializers.IntegerField(read_only=True) + id = serializers.DecimalField( + max_digits=4, + decimal_places=1, + read_only=True + ) name = serializers.CharField(read_only=True) face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -247,7 +266,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( - default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH ) exclude = serializers.IntegerField( required=False, @@ -284,6 +306,13 @@ class ManufacturerSerializer(NetBoxModelSerializer): class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() + u_height = serializers.DecimalField( + max_digits=4, + decimal_places=1, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=1.0 + ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) device_count = serializers.IntegerField(read_only=True) @@ -440,12 +469,22 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): default=None ) type = ChoiceField(choices=InterfaceTypeChoices) + poe_mode = ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + allow_blank=True + ) + poe_type = ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + allow_blank=True + ) class Meta: model = InterfaceTemplate fields = [ 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', - 'created', 'last_updated', + 'poe_mode', 'poe_type', 'created', 'last_updated', ] @@ -590,7 +629,14 @@ class DeviceSerializer(NetBoxModelSerializer): location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True, default=None) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, default='') - position = serializers.IntegerField(allow_null=True, label='Position (U)', min_value=1, default=None) + position = serializers.DecimalField( + max_digits=4, + decimal_places=1, + allow_null=True, + label='Position (U)', + min_value=decimal.Decimal(0.5), + default=None + ) status = ChoiceField(choices=DeviceStatusChoices, required=False) airflow = ChoiceField(choices=DeviceAirflowChoices, allow_blank=True, required=False) primary_ip = NestedIPAddressSerializer(read_only=True) @@ -660,7 +706,7 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -677,18 +723,18 @@ class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializ allow_null=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = ConsoleServerPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -705,18 +751,18 @@ class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co allow_null=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = ConsolePort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'connected_endpoints', + 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -737,21 +783,18 @@ class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Co allow_blank=True, required=False ) - cable = NestedCableSerializer( - read_only=True - ) class Meta: model = PowerOutlet fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -763,19 +806,18 @@ class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn allow_blank=True, required=False ) - cable = NestedCableSerializer(read_only=True) class Meta: model = PowerPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', '_occupied', + 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', + 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -790,6 +832,8 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) + poe_mode = ChoiceField(choices=InterfacePoEModeChoices, required=False, allow_blank=True) + poe_type = ChoiceField(choices=InterfacePoETypeChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) tagged_vlans = SerializedPKRelatedField( queryset=VLAN.objects.all(), @@ -798,7 +842,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) - cable = NestedCableSerializer(read_only=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( queryset=WirelessLAN.objects.all(), @@ -814,10 +858,11 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', - 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', + 'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', + 'tagged_vlans', 'mark_connected', 'cable', 'cable_end', 'wireless_link', 'link_peers', 'link_peers_type', + 'wireless_lans', 'vrf', 'l2vpn_termination', 'connected_endpoints', 'connected_endpoints_type', + 'connected_endpoints_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -834,7 +879,7 @@ class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn return super().validate(data) -class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): +class RearPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -842,13 +887,12 @@ class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): allow_null=True ) type = ChoiceField(choices=PortTypeChoices) - cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', + 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -864,7 +908,7 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): +class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() module = ComponentNestedModuleSerializer( @@ -873,14 +917,13 @@ class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() - cable = NestedCableSerializer(read_only=True) class Meta: model = FrontPort fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', - 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -963,14 +1006,8 @@ class InventoryItemRoleSerializer(NetBoxModelSerializer): class CableSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') - termination_a_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination_b_type = ContentTypeField( - queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) - ) - termination_a = serializers.SerializerMethodField(read_only=True) - termination_b = serializers.SerializerMethodField(read_only=True) + a_terminations = GenericObjectSerializer(many=True, required=False) + b_terminations = GenericObjectSerializer(many=True, required=False) status = ChoiceField(choices=LinkStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False) @@ -978,34 +1015,10 @@ class CableSerializer(NetBoxModelSerializer): class Meta: model = Cable fields = [ - 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', - 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'type', 'a_terminations', 'b_terminations', 'status', 'tenant', 'label', 'color', + 'length', 'length_unit', 'tags', 'custom_fields', 'created', 'last_updated', ] - def _get_termination(self, obj, side): - """ - Serialize a nested representation of a termination. - """ - if side.lower() not in ['a', 'b']: - raise ValueError("Termination side must be either A or B.") - termination = getattr(obj, 'termination_{}'.format(side.lower())) - if termination is None: - return None - serializer = get_serializer_for_model(termination, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - data = serializer(termination, context=context).data - - 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') - class TracedCableSerializer(serializers.ModelSerializer): """ @@ -1020,46 +1033,40 @@ class TracedCableSerializer(serializers.ModelSerializer): ] +class CableTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cabletermination-detail') + termination_type = ContentTypeField( + queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) + ) + termination = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = CableTermination + fields = [ + 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination' + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_termination(self, obj): + serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(obj.termination, context=context).data + + class CablePathSerializer(serializers.ModelSerializer): - origin_type = ContentTypeField(read_only=True) - origin = serializers.SerializerMethodField(read_only=True) - destination_type = ContentTypeField(read_only=True) - destination = serializers.SerializerMethodField(read_only=True) path = serializers.SerializerMethodField(read_only=True) class Meta: model = CablePath - fields = [ - 'id', 'origin_type', 'origin', 'destination_type', 'destination', 'path', 'is_active', 'is_split', - ] - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_origin(self, obj): - """ - Return the appropriate serializer for the origin. - """ - serializer = get_serializer_for_model(obj.origin, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.origin, context=context).data - - @swagger_serializer_method(serializer_or_field=serializers.DictField) - def get_destination(self, obj): - """ - Return the appropriate serializer for the destination, if any. - """ - if obj.destination_id is not None: - serializer = get_serializer_for_model(obj.destination, prefix=NESTED_SERIALIZER_PREFIX) - context = {'request': self.context['request']} - return serializer(obj.destination, context=context).data - return None + fields = ['id', 'path', 'is_active', 'is_complete', 'is_split'] @swagger_serializer_method(serializer_or_field=serializers.ListField) def get_path(self, obj): ret = [] - for node in obj.get_path(): - serializer = get_serializer_for_model(node, prefix=NESTED_SERIALIZER_PREFIX) + for nodes in obj.path_objects: + serializer = get_serializer_for_model(nodes[0], prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} - ret.append(serializer(node, context=context).data) + ret.append(serializer(nodes, context=context, many=True).data) return ret @@ -1102,7 +1109,7 @@ class PowerPanelSerializer(NetBoxModelSerializer): ] -class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, ConnectedEndpointsSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -1126,13 +1133,12 @@ class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, Conn choices=PowerFeedPhaseChoices, default=PowerFeedPhaseChoices.PHASE_SINGLE ) - cable = NestedCableSerializer(read_only=True) class Meta: model = PowerFeed fields = [ 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_end', 'link_peers', + 'link_peers_type', 'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', + 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index f67d241d5..47bbfd525 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views @@ -56,6 +56,7 @@ router.register('inventory-item-roles', views.InventoryItemRoleViewSet) # Cables router.register('cables', views.CableViewSet) +router.register('cable-terminations', views.CableTerminationViewSet) # Virtual chassis router.register('virtual-chassis', views.VirtualChassisViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 6387e26f8..c18eab01f 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,5 +1,4 @@ import socket -from collections import OrderedDict from django.http import Http404, HttpResponse, HttpResponseForbidden from django.shortcuts import get_object_or_404 @@ -13,7 +12,9 @@ from rest_framework.viewsets import ViewSet from circuits.models import Circuit from dcim import filtersets +from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * +from dcim.svg import CableTraceSVG from extras.api.views import ConfigContextQuerySetMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -52,37 +53,30 @@ class PathEndpointMixin(object): # Initialize the path array path = [] + # Render SVG image if requested if request.GET.get('render', None) == 'svg': - # Render SVG try: - width = min(int(request.GET.get('width')), 1600) + width = int(request.GET.get('width', CABLE_TRACE_SVG_DEFAULT_WIDTH)) except (ValueError, TypeError): - width = None - drawing = obj.get_trace_svg( - base_url=request.build_absolute_uri('/'), - width=width - ) - return HttpResponse(drawing.tostring(), content_type='image/svg+xml') + width = CABLE_TRACE_SVG_DEFAULT_WIDTH + drawing = CableTraceSVG(obj, base_url=request.build_absolute_uri('/'), width=width) + return HttpResponse(drawing.render().tostring(), content_type='image/svg+xml') - for near_end, cable, far_end in obj.trace(): - if near_end is None: - # Split paths + # Serialize path objects, iterating over each three-tuple in the path + for near_ends, cable, far_ends in obj.trace(): + if near_ends: + serializer_a = get_serializer_for_model(near_ends[0], prefix=NESTED_SERIALIZER_PREFIX) + near_ends = serializer_a(near_ends, many=True, context={'request': request}).data + else: + # Path is split; stop here break + if cable: + cable = serializers.TracedCableSerializer(cable[0], context={'request': request}).data + if far_ends: + serializer_b = get_serializer_for_model(far_ends[0], prefix=NESTED_SERIALIZER_PREFIX) + far_ends = serializer_b(far_ends, many=True, context={'request': request}).data - # Serialize each object - serializer_a = get_serializer_for_model(near_end, prefix=NESTED_SERIALIZER_PREFIX) - x = serializer_a(near_end, context={'request': request}).data - if cable is not None: - y = serializers.TracedCableSerializer(cable, context={'request': request}).data - else: - y = None - if far_end is not None: - serializer_b = get_serializer_for_model(far_end, prefix=NESTED_SERIALIZER_PREFIX) - z = serializer_b(far_end, context={'request': request}).data - else: - z = None - - path.append((x, y, z)) + path.append((near_ends, cable, far_ends)) return Response(path) @@ -95,7 +89,7 @@ class PassThroughPortMixin(object): Return all CablePaths which traverse a given pass-through port. """ obj = get_object_or_404(self.queryset, pk=pk) - cablepaths = CablePath.objects.filter(path__contains=obj).prefetch_related('origin', 'destination') + cablepaths = CablePath.objects.filter(_nodes__contains=obj) serializer = serializers.CablePathSerializer(cablepaths, context={'request': request}, many=True) return Response(serializer.data) @@ -216,6 +210,14 @@ class RackViewSet(NetBoxModelViewSet): data = serializer.validated_data if data['render'] == 'svg': + # Determine attributes for highlighting devices (if any) + highlight_params = [] + for param in request.GET.getlist('highlight'): + try: + highlight_params.append(param.split(':', 1)) + except ValueError: + pass + # Render and return the elevation as an SVG drawing with the correct content type drawing = rack.get_elevation_svg( face=data['face'], @@ -224,7 +226,8 @@ class RackViewSet(NetBoxModelViewSet): unit_height=data['unit_height'], legend_width=data['legend_width'], include_images=data['include_images'], - base_url=request.build_absolute_uri('/') + base_url=request.build_absolute_uri('/'), + highlight_params=highlight_params ) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') @@ -480,7 +483,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): return HttpResponseForbidden() napalm_methods = request.GET.getlist('method') - response = OrderedDict([(m, None) for m in napalm_methods]) + response = {m: None for m in napalm_methods} config = get_config() username = config.NAPALM_USERNAME @@ -549,7 +552,7 @@ class ModuleViewSet(NetBoxModelViewSet): class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsolePort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet @@ -558,7 +561,7 @@ class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet @@ -567,7 +570,7 @@ class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerPort.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet @@ -576,7 +579,7 @@ class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerOutlet.objects.prefetch_related( - 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet @@ -585,8 +588,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', - 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet @@ -595,7 +598,7 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = FrontPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable__terminations', 'tags' ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet @@ -604,7 +607,7 @@ class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = RearPort.objects.prefetch_related( - 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + 'device__device_type__manufacturer', 'module__module_bay', 'cable__terminations', 'tags' ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet @@ -649,14 +652,18 @@ class InventoryItemRoleViewSet(NetBoxModelViewSet): # class CableViewSet(NetBoxModelViewSet): - metadata_class = ContentTypeMetadata - queryset = Cable.objects.prefetch_related( - 'termination_a', 'termination_b' - ) + queryset = Cable.objects.prefetch_related('terminations__termination') serializer_class = serializers.CableSerializer filterset_class = filtersets.CableFilterSet +class CableTerminationViewSet(NetBoxModelViewSet): + metadata_class = ContentTypeMetadata + queryset = CableTermination.objects.prefetch_related('cable', 'termination') + serializer_class = serializers.CableTerminationSerializer + filterset_class = filtersets.CableTerminationFilterSet + + # # Virtual chassis # @@ -690,7 +697,7 @@ class PowerPanelViewSet(NetBoxModelViewSet): class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( - 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' + 'power_panel', 'rack', '_path', 'cable__terminations', 'tags' ) serializer_class = serializers.PowerFeedSerializer filterset_class = filtersets.PowerFeedFilterSet @@ -750,13 +757,13 @@ class ConnectedDeviceViewSet(ViewSet): device=peer_device, name=peer_interface_name ) - endpoint = peer_interface.connected_endpoint + endpoints = peer_interface.connected_endpoints # If an Interface, return the parent device - if type(endpoint) is Interface: + if endpoints and type(endpoints[0]) is Interface: device = get_object_or_404( Device.objects.restrict(request.user, 'view'), - pk=endpoint.device_id + pk=endpoints[0].device_id ) return Response(serializers.DeviceSerializer(device, context={'request': request}).data) diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py index 78a243f84..4be2df659 100644 --- a/netbox/dcim/apps.py +++ b/netbox/dcim/apps.py @@ -1,10 +1,26 @@ from django.apps import AppConfig +from netbox import denormalized + class DCIMConfig(AppConfig): name = "dcim" verbose_name = "DCIM" def ready(self): - import dcim.signals + from .models import CableTermination + + # Register denormalized fields + denormalized.register(CableTermination, '_device', { + '_rack': 'rack', + '_location': 'location', + '_site': 'site', + }) + denormalized.register(CableTermination, '_rack', { + '_location': 'location', + '_site': 'site', + }) + denormalized.register(CableTermination, '_location', { + '_site': 'site', + }) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fd71e5c29..79049384a 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -23,6 +23,28 @@ class SiteStatusChoices(ChoiceSet): ] +# +# Locations +# + +class LocationStatusChoices(ChoiceSet): + key = 'Location.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_RETIRED = 'retired' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] + + # # Racks # @@ -1031,6 +1053,51 @@ class InterfaceModeChoices(ChoiceSet): ) +class InterfacePoEModeChoices(ChoiceSet): + + MODE_PD = 'pd' + MODE_PSE = 'pse' + + CHOICES = ( + (MODE_PD, 'PD'), + (MODE_PSE, 'PSE'), + ) + + +class InterfacePoETypeChoices(ChoiceSet): + + TYPE_1_8023AF = 'type1-ieee802.3af' + TYPE_2_8023AT = 'type2-ieee802.3at' + TYPE_3_8023BT = 'type3-ieee802.3bt' + TYPE_4_8023BT = 'type4-ieee802.3bt' + + PASSIVE_24V_2PAIR = 'passive-24v-2pair' + PASSIVE_24V_4PAIR = 'passive-24v-4pair' + PASSIVE_48V_2PAIR = 'passive-48v-2pair' + PASSIVE_48V_4PAIR = 'passive-48v-4pair' + + CHOICES = ( + ( + 'IEEE Standard', + ( + (TYPE_1_8023AF, '802.3af (Type 1)'), + (TYPE_2_8023AT, '802.3at (Type 2)'), + (TYPE_3_8023BT, '802.3bt (Type 3)'), + (TYPE_4_8023BT, '802.3bt (Type 4)'), + ) + ), + ( + 'Passive', + ( + (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), + (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), + (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + ) + ), + ) + + # # FrontPorts/RearPorts # @@ -1243,6 +1310,22 @@ class CableLengthUnitChoices(ChoiceSet): ) +# +# CableTerminations +# + +class CableEndChoices(ChoiceSet): + + SIDE_A = 'A' + SIDE_B = 'B' + + CHOICES = ( + (SIDE_A, 'A'), + (SIDE_B, 'B'), + # ('', ''), + ) + + # # PowerFeeds # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 155f19c88..9e41ed113 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 -RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 +RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 +RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 # @@ -84,6 +85,8 @@ MODULAR_COMPONENT_MODELS = Q( # Cabling and connections # +CABLE_TRACE_SVG_DEFAULT_WIDTH = 400 + # Cable endpoint types CABLE_TERMINATION_MODELS = Q( Q(app_label='circuits', model__in=( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index f5342106e..874d08ba5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -21,6 +21,7 @@ from .models import * __all__ = ( 'CableFilterSet', + 'CabledObjectFilterSet', 'CableTerminationFilterSet', 'ConsoleConnectionFilterSet', 'ConsolePortFilterSet', @@ -216,10 +217,14 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=LocationStatusChoices, + null_value=None + ) class Meta: model = Location - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'status', 'description'] def search(self, queryset, name, value): if not value.strip(): @@ -647,6 +652,12 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo choices=InterfaceTypeChoices, null_value=None ) + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) class Meta: model = InterfaceTemplate @@ -1116,7 +1127,7 @@ class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): ) -class CableTerminationFilterSet(django_filters.FilterSet): +class CabledObjectFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', lookup_expr='isnull', @@ -1139,7 +1150,7 @@ class PathEndpointFilterSet(django_filters.FilterSet): class ConsolePortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1149,13 +1160,13 @@ class ConsolePortFilterSet( class Meta: model = ConsolePort - fields = ['id', 'name', 'label', 'description'] + fields = ['id', 'name', 'label', 'description', 'cable_end'] class ConsoleServerPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1165,13 +1176,13 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'label', 'description'] + fields = ['id', 'name', 'label', 'description', 'cable_end'] class PowerPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1181,13 +1192,13 @@ class PowerPortFilterSet( class Meta: model = PowerPort - fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] + fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description', 'cable_end'] class PowerOutletFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): type = django_filters.MultipleChoiceFilter( @@ -1201,13 +1212,13 @@ class PowerOutletFilterSet( class Meta: model = PowerOutlet - fields = ['id', 'name', 'label', 'feed_leg', 'description'] + fields = ['id', 'name', 'label', 'feed_leg', 'description', 'cable_end'] class InterfaceFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet, + CabledObjectFilterSet, PathEndpointFilterSet ): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis @@ -1247,6 +1258,12 @@ class InterfaceFilterSet( ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() + poe_mode = django_filters.MultipleChoiceFilter( + choices=InterfacePoEModeChoices + ) + poe_type = django_filters.MultipleChoiceFilter( + choices=InterfacePoETypeChoices + ) vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1280,8 +1297,8 @@ class InterfaceFilterSet( class Meta: model = Interface fields = [ - 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', + 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'cable_end', ] def filter_device(self, queryset, name, value): @@ -1335,7 +1352,7 @@ class InterfaceFilterSet( class FrontPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet + CabledObjectFilterSet ): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -1344,13 +1361,13 @@ class FrontPortFilterSet( class Meta: model = FrontPort - fields = ['id', 'name', 'label', 'type', 'color', 'description'] + fields = ['id', 'name', 'label', 'type', 'color', 'description', 'cable_end'] class RearPortFilterSet( ModularDeviceComponentFilterSet, NetBoxModelFilterSet, - CableTerminationFilterSet + CabledObjectFilterSet ): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, @@ -1359,7 +1376,7 @@ class RearPortFilterSet( class Meta: model = RearPort - fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] + fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description', 'cable_end'] class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): @@ -1507,10 +1524,18 @@ class VirtualChassisFilterSet(NetBoxModelFilterSet): class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): - termination_a_type = ContentTypeFilter() - termination_a_id = MultiValueNumberFilter() - termination_b_type = ContentTypeFilter() - termination_b_id = MultiValueNumberFilter() + termination_a_type = ContentTypeFilter( + field_name='terminations__termination_type' + ) + termination_a_id = MultiValueNumberFilter( + field_name='terminations__termination_id' + ) + termination_b_type = ContentTypeFilter( + field_name='terminations__termination_type' + ) + termination_b_id = MultiValueNumberFilter( + field_name='terminations__termination_id' + ) type = django_filters.MultipleChoiceFilter( choices=CableTypeChoices ) @@ -1521,44 +1546,57 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): choices=ColorChoices ) device_id = MultiValueNumberFilter( - method='filter_device' + method='filter_by_termination' ) device = MultiValueCharFilter( - method='filter_device', + method='filter_by_termination', field_name='device__name' ) rack_id = MultiValueNumberFilter( - method='filter_device', - field_name='device__rack_id' + method='filter_by_termination', + field_name='rack_id' ) rack = MultiValueCharFilter( - method='filter_device', - field_name='device__rack__name' + method='filter_by_termination', + field_name='rack__name' + ) + location_id = MultiValueNumberFilter( + method='filter_by_termination', + field_name='location_id' + ) + location = MultiValueCharFilter( + method='filter_by_termination', + field_name='location__name' ) site_id = MultiValueNumberFilter( - method='filter_device', - field_name='device__site_id' + method='filter_by_termination', + field_name='site_id' ) site = MultiValueCharFilter( - method='filter_device', - field_name='device__site__slug' + method='filter_by_termination', + field_name='site__slug' ) class Meta: model = Cable - fields = ['id', 'label', 'length', 'length_unit', 'termination_a_id', 'termination_b_id'] + fields = ['id', 'label', 'length', 'length_unit'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter(label__icontains=value) - def filter_device(self, queryset, name, value): - queryset = queryset.filter( - Q(**{'_termination_a_{}__in'.format(name): value}) | - Q(**{'_termination_b_{}__in'.format(name): value}) - ) - return queryset + def filter_by_termination(self, queryset, name, value): + # Filter by a related object cached on CableTermination. Note the underscore preceding the field name. + # Supported objects: device, rack, location, site + return queryset.filter(**{f'terminations___{name}__in': value}).distinct() + + +class CableTerminationFilterSet(BaseFilterSet): + + class Meta: + model = CableTermination + fields = ['id', 'cable', 'cable_end', 'termination_type', 'termination_id'] class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): @@ -1618,7 +1656,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', @@ -1672,7 +1710,9 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEn class Meta: model = PowerFeed - fields = ['id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'] + fields = [ + 'id', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'cable_end', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 314a7a75f..43b852928 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,12 +72,15 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, [ + 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'poe_mode', 'poe_type', + ]), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'poe_type', 'mark_connected', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 231d01ddd..8f765ae9b 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -158,6 +158,12 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): 'site_id': '$site' } ) + status = forms.ChoiceField( + choices=add_blank_choice(LocationStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -169,7 +175,7 @@ class LocationBulkEditForm(NetBoxModelBulkEditForm): model = Location fieldsets = ( - (None, ('site', 'parent', 'tenant', 'description')), + (None, ('site', 'parent', 'status', 'tenant', 'description')), ) nullable_fields = ('parent', 'tenant', 'description') @@ -812,8 +818,22 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): description = forms.CharField( required=False ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE mode' + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE type' + ) - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description', 'poe_mode', 'poe_type') class FrontPortTemplateBulkEditForm(BulkEditForm): @@ -1063,6 +1083,20 @@ class InterfaceBulkEditForm( widget=BulkEditNullBooleanSelect, label='Management only' ) + poe_mode = forms.ChoiceField( + choices=add_blank_choice(InterfacePoEModeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE mode' + ) + poe_type = forms.ChoiceField( + choices=add_blank_choice(InterfacePoETypeChoices), + required=False, + initial='', + widget=StaticSelect(), + label='PoE type' + ) mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -1105,14 +1139,15 @@ class InterfaceBulkEditForm( (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', - 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'vlan_group', 'untagged_vlan', - 'tagged_vlans', 'vrf', + 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index b28c16fad..f0fd9bf86 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -124,6 +124,10 @@ class LocationCSVForm(NetBoxModelCSVForm): 'invalid_choice': 'Location not found.', } ) + status = CSVChoiceField( + choices=LocationStatusChoices, + help_text='Operational status' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -133,7 +137,7 @@ class LocationCSVForm(NetBoxModelCSVForm): class Meta: model = Location - fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') + fields = ('site', 'parent', 'name', 'slug', 'status', 'tenant', 'description') class RackRoleCSVForm(NetBoxModelCSVForm): @@ -622,6 +626,16 @@ class InterfaceCSVForm(NetBoxModelCSVForm): choices=InterfaceDuplexChoices, required=False ) + poe_mode = CSVChoiceField( + choices=InterfacePoEModeChoices, + required=False, + help_text='PoE mode' + ) + poe_type = CSVChoiceField( + choices=InterfacePoETypeChoices, + required=False, + help_text='PoE type' + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, @@ -642,9 +656,9 @@ class InterfaceCSVForm(NetBoxModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', + 'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', + 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) def __init__(self, data=None, *args, **kwargs): @@ -941,7 +955,7 @@ class CableCSVForm(NetBoxModelCSVForm): except ObjectDoesNotExist: raise forms.ValidationError(f"{side.upper()} side termination not found: {device} {name}") - setattr(self.instance, f'termination_{side}', termination_object) + setattr(self.instance, f'{side}_terminations', [termination_object]) return termination_object def clean_side_a_name(self): diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 1ba7adf84..cc5cf362f 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,279 +1,171 @@ +from django import forms + from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.models import Tag -from netbox.forms import NetBoxModelForm -from tenancy.forms import TenancyForm -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect - -__all__ = ( - 'ConnectCableToCircuitTerminationForm', - 'ConnectCableToConsolePortForm', - 'ConnectCableToConsoleServerPortForm', - 'ConnectCableToFrontPortForm', - 'ConnectCableToInterfaceForm', - 'ConnectCableToPowerFeedForm', - 'ConnectCableToPowerPortForm', - 'ConnectCableToPowerOutletForm', - 'ConnectCableToRearPortForm', -) +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from .models import CableForm -class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): - """ - Base form for connecting a Cable to a Device component - """ - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_rack = DynamicModelChoiceField( - queryset=Rack.objects.all(), - label='Rack', - required=False, - null_option='None', - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_device = DynamicModelChoiceField( - queryset=Device.objects.all(), - label='Device', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - 'rack_id': '$termination_b_rack', - } - ) +def get_cable_form(a_type, b_type): - class Meta: - model = Cable - fields = [ - 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_rack', - 'termination_b_device', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', - ] - widgets = { - 'status': StaticSelect, - 'type': StaticSelect, - 'length_unit': StaticSelect, - } + class FormMetaclass(forms.models.ModelFormMetaclass): - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + def __new__(mcs, name, bases, attrs): + for cable_end, term_cls in (('a', a_type), ('b', b_type)): -class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsolePort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField( + queryset=Region.objects.all(), + label='Region', + required=False, + initial_params={ + 'sites': f'$termination_{cable_end}_site' + } + ) + attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + label='Site group', + required=False, + initial_params={ + 'sites': f'$termination_{cable_end}_site' + } + ) + attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField( + queryset=Site.objects.all(), + label='Site', + required=False, + query_params={ + 'region_id': f'$termination_{cable_end}_region', + 'group_id': f'$termination_{cable_end}_sitegroup', + } + ) + attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', + required=False, + null_option='None', + query_params={ + 'site_id': f'$termination_{cable_end}_site' + } + ) + # Device component + if hasattr(term_cls, 'device'): -class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=ConsoleServerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField( + queryset=Rack.objects.all(), + label='Rack', + required=False, + null_option='None', + initial_params={ + 'devices': f'$termination_{cable_end}_device' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + } + ) + attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField( + queryset=Device.objects.all(), + label='Device', + required=False, + initial_params={ + f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + 'rack_id': f'$termination_{cable_end}_rack', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label=term_cls._meta.verbose_name.title(), + disabled_indicator='_occupied', + query_params={ + 'device_id': f'$termination_{cable_end}_device', + 'kind': 'physical', # Exclude virtual interfaces + } + ) + # PowerFeed + elif term_cls == PowerFeed: -class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_powerpanel'] = DynamicModelChoiceField( + queryset=PowerPanel.objects.all(), + label='Power Panel', + required=False, + initial_params={ + 'powerfeeds__in': f'${cable_end}_terminations' + }, + query_params={ + 'site_id': f'$termination_{cable_end}_site', + 'location_id': f'$termination_{cable_end}_location', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label='Power Feed', + disabled_indicator='_occupied', + query_params={ + 'powerpanel_id': f'$termination_{cable_end}_powerpanel', + } + ) + # CircuitTermination + elif term_cls == CircuitTermination: -class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=PowerOutlet.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField( + queryset=Provider.objects.all(), + label='Provider', + initial_params={ + 'circuits': f'$termination_{cable_end}_circuit' + }, + required=False + ) + attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + label='Circuit', + initial_params={ + 'terminations__in': f'${cable_end}_terminations' + }, + query_params={ + 'provider_id': f'$termination_{cable_end}_provider', + 'site_id': f'$termination_{cable_end}_site', + } + ) + attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField( + queryset=term_cls.objects.all(), + label='Side', + disabled_indicator='_occupied', + query_params={ + 'circuit_id': f'$termination_{cable_end}_circuit', + } + ) + return super().__new__(mcs, name, bases, attrs) -class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=Interface.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device', - 'kind': 'physical', - } - ) + class _CableForm(CableForm, metaclass=FormMetaclass): + def __init__(self, *args, **kwargs): -class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=FrontPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict() + for field_name in ('a_terminations', 'b_terminations'): + if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list: + kwargs['initial'][field_name] = [kwargs['initial'][field_name]] + super().__init__(*args, **kwargs) -class ConnectCableToRearPortForm(ConnectCableToDeviceForm): - termination_b_id = DynamicModelChoiceField( - queryset=RearPort.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'device_id': '$termination_b_device' - } - ) + if self.instance and self.instance.pk: + # Initialize A/B terminations when modifying an existing Cable instance + self.initial['a_terminations'] = self.instance.a_terminations + self.initial['b_terminations'] = self.instance.b_terminations + def clean(self): + super().clean() -class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): - termination_b_provider = DynamicModelChoiceField( - queryset=Provider.objects.all(), - label='Provider', - required=False - ) - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_circuit = DynamicModelChoiceField( - queryset=Circuit.objects.all(), - label='Circuit', - query_params={ - 'provider_id': '$termination_b_provider', - 'site_id': '$termination_b_site', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=CircuitTermination.objects.all(), - label='Side', - disabled_indicator='_occupied', - query_params={ - 'circuit_id': '$termination_b_circuit' - } - ) + # Set the A/B terminations on the Cable instance + self.instance.a_terminations = self.cleaned_data['a_terminations'] + self.instance.b_terminations = self.cleaned_data['b_terminations'] - class Meta(ConnectCableToDeviceForm.Meta): - fields = [ - 'termination_b_provider', 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', - 'termination_b_circuit', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color', - 'length', 'length_unit', 'tags', - ] - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) - - -class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm): - termination_b_region = DynamicModelChoiceField( - queryset=Region.objects.all(), - label='Region', - required=False - ) - termination_b_sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), - label='Site group', - required=False - ) - termination_b_site = DynamicModelChoiceField( - queryset=Site.objects.all(), - label='Site', - required=False, - query_params={ - 'region_id': '$termination_b_region', - 'group_id': '$termination_b_sitegroup', - } - ) - termination_b_location = DynamicModelChoiceField( - queryset=Location.objects.all(), - label='Location', - required=False, - query_params={ - 'site_id': '$termination_b_site' - } - ) - termination_b_powerpanel = DynamicModelChoiceField( - queryset=PowerPanel.objects.all(), - label='Power Panel', - required=False, - query_params={ - 'site_id': '$termination_b_site', - 'location_id': '$termination_b_location', - } - ) - termination_b_id = DynamicModelChoiceField( - queryset=PowerFeed.objects.all(), - label='Name', - disabled_indicator='_occupied', - query_params={ - 'power_panel_id': '$termination_b_powerpanel' - } - ) - - class Meta(ConnectCableToDeviceForm.Meta): - fields = [ - 'termination_b_region', 'termination_b_sitegroup', 'termination_b_site', 'termination_b_location', - 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'tenant_group', 'tenant', 'label', - 'color', 'length', 'length_unit', 'tags', - ] - - def clean_termination_b_id(self): - # Return the PK rather than the object - return getattr(self.cleaned_data['termination_b_id'], 'pk', None) + return _CableForm diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 12905aec9..16ff6fee2 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -166,7 +166,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF model = Location fieldsets = ( (None, ('q', 'tag')), - ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Attributes', ('region_id', 'site_group_id', 'site_id', 'parent_id', 'status')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), ) @@ -198,6 +198,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF }, label=_('Parent') ) + status = MultipleChoiceField( + choices=LocationStatusChoices, + required=False + ) tag = TagFilterField(model) @@ -739,7 +743,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable fieldsets = ( (None, ('q', 'tag')), - ('Location', ('site_id', 'rack_id', 'device_id')), + ('Location', ('site_id', 'location_id', 'rack_id', 'device_id')), ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), ('Tenant', ('tenant_group_id', 'tenant_id')), ) @@ -756,13 +760,23 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location'), + null_option='None', + query_params={ + 'site_id': '$site_id' + } + ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, label=_('Rack'), null_option='None', query_params={ - 'site_id': '$site_id' + 'site_id': '$site_id', + 'location_id': '$location_id', } ) device_id = DynamicModelMultipleChoiceField( @@ -770,8 +784,9 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): required=False, query_params={ 'site_id': '$site_id', - 'tenant_id': '$tenant_id', + 'location_id': '$location_id', 'rack_id': '$rack_id', + 'tenant_id': '$tenant_id', }, label=_('Device') ) @@ -982,6 +997,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) @@ -1022,6 +1038,16 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) + poe_mode = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False, + label='PoE mode' + ) + poe_type = MultipleChoiceField( + choices=InterfacePoEModeChoices, + required=False, + label='PoE type' + ) rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index fb09b9871..edf25cf2c 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -194,7 +194,7 @@ class LocationForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags', )), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -202,8 +202,12 @@ class LocationForm(TenancyForm, NetBoxModelForm): class Meta: model = Location fields = ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', + 'tags', ) + widgets = { + 'status': StaticSelect(), + } class RackRoleForm(NetBoxModelForm): @@ -467,7 +471,7 @@ class DeviceForm(TenancyForm, NetBoxModelForm): 'location_id': '$location', } ) - position = forms.IntegerField( + position = forms.DecimalField( required=False, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( @@ -1048,12 +1052,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] widgets = { 'device_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(), 'type': StaticSelect(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), } @@ -1329,6 +1335,7 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('PoE', ('poe_mode', 'poe_type')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('Wireless', ( 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', @@ -1339,14 +1346,16 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): model = Interface fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', - 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', - 'vrf', 'tags', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode', + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), + 'poe_mode': StaticSelect(), + 'poe_type': StaticSelect(), 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 606333e83..023aba8f1 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -1,6 +1,6 @@ from django import forms -from dcim.choices import InterfaceTypeChoices, PortTypeChoices +from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices from dcim.models import * from utilities.forms import BootstrapMixin @@ -112,11 +112,21 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( choices=InterfaceTypeChoices.CHOICES ) + poe_mode = forms.ChoiceField( + choices=InterfacePoEModeChoices, + required=False, + label='PoE mode' + ) + poe_type = forms.ChoiceField( + choices=InterfacePoETypeChoices, + required=False, + label='PoE type' + ) class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] diff --git a/netbox/dcim/graphql/mixins.py b/netbox/dcim/graphql/mixins.py new file mode 100644 index 000000000..d8488aa5f --- /dev/null +++ b/netbox/dcim/graphql/mixins.py @@ -0,0 +1,5 @@ +class CabledObjectMixin: + + def resolve_cable_end(self, info): + # Handle empty values + return self.cable_end or None diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d25a6bba6..52a98278a 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -7,6 +7,7 @@ from extras.graphql.mixins import ( from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType +from .mixins import CabledObjectMixin __all__ = ( 'CableType', @@ -99,7 +100,15 @@ class CableType(NetBoxObjectType): return self.length_unit or None -class ConsolePortType(ComponentObjectType): +class CableTerminationType(NetBoxObjectType): + + class Meta: + model = models.CableTermination + fields = '__all__' + filterset_class = filtersets.CableTerminationFilterSet + + +class ConsolePortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.ConsolePort @@ -121,7 +130,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType): return self.type or None -class ConsoleServerPortType(ComponentObjectType): +class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.ConsoleServerPort @@ -203,7 +212,7 @@ class DeviceTypeType(NetBoxObjectType): return self.airflow or None -class FrontPortType(ComponentObjectType): +class FrontPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.FrontPort @@ -219,13 +228,19 @@ class FrontPortTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.FrontPortTemplateFilterSet -class InterfaceType(IPAddressesMixin, ComponentObjectType): +class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin): class Meta: model = models.Interface exclude = ('_path',) filterset_class = filtersets.InterfaceFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + def resolve_mode(self, info): return self.mode or None @@ -243,6 +258,12 @@ class InterfaceTemplateType(ComponentTemplateObjectType): fields = '__all__' filterset_class = filtersets.InterfaceTemplateFilterSet + def resolve_poe_mode(self, info): + return self.poe_mode or None + + def resolve_poe_type(self, info): + return self.poe_type or None + class InventoryItemType(ComponentObjectType): @@ -316,7 +337,7 @@ class PlatformType(OrganizationalObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(NetBoxObjectType): +class PowerFeedType(NetBoxObjectType, CabledObjectMixin): class Meta: model = models.PowerFeed @@ -324,7 +345,7 @@ class PowerFeedType(NetBoxObjectType): filterset_class = filtersets.PowerFeedFilterSet -class PowerOutletType(ComponentObjectType): +class PowerOutletType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.PowerOutlet @@ -360,7 +381,7 @@ class PowerPanelType(NetBoxObjectType): filterset_class = filtersets.PowerPanelFilterSet -class PowerPortType(ComponentObjectType): +class PowerPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.PowerPort @@ -412,7 +433,7 @@ class RackRoleType(OrganizationalObjectType): filterset_class = filtersets.RackRoleFilterSet -class RearPortType(ComponentObjectType): +class RearPortType(ComponentObjectType, CabledObjectMixin): class Meta: model = models.RearPort diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py index d0cd64486..4bb81bfd4 100644 --- a/netbox/dcim/management/commands/trace_paths.py +++ b/netbox/dcim/management/commands/trace_paths.py @@ -81,7 +81,7 @@ class Command(BaseCommand): self.stdout.write(f'Retracing {origins_count} cabled {model._meta.verbose_name_plural}...') i = 0 for i, obj in enumerate(origins, start=1): - create_cablepath(obj) + create_cablepath([obj]) if not i % 100: self.draw_progress_bar(i * 100 / origins_count) self.draw_progress_bar(100) diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py new file mode 100644 index 000000000..dd21fddcf --- /dev/null +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='devicetype', + name='u_height', + field=models.DecimalField(decimal_places=1, default=1.0, max_digits=4), + ), + migrations.AlterField( + model_name='device', + name='position', + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + ), + ] diff --git a/netbox/dcim/migrations/0155_interface_poe_mode_type.py b/netbox/dcim/migrations/0155_interface_poe_mode_type.py new file mode 100644 index 000000000..13f2ddfc0 --- /dev/null +++ b/netbox/dcim/migrations/0155_interface_poe_mode_type.py @@ -0,0 +1,33 @@ +# Generated by Django 4.0.5 on 2022-06-22 00:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0154_half_height_rack_units'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_mode', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='interfacetemplate', + name='poe_type', + field=models.CharField(blank=True, max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0156_location_status.py b/netbox/dcim/migrations/0156_location_status.py new file mode 100644 index 000000000..b20273755 --- /dev/null +++ b/netbox/dcim/migrations/0156_location_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.5 on 2022-06-22 17:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0155_interface_poe_mode_type'), + ] + + operations = [ + migrations.AddField( + model_name='location', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/dcim/migrations/0157_new_cabling_models.py b/netbox/dcim/migrations/0157_new_cabling_models.py new file mode 100644 index 000000000..a3a650086 --- /dev/null +++ b/netbox/dcim/migrations/0157_new_cabling_models.py @@ -0,0 +1,95 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0156_location_status'), + ] + + operations = [ + + # Create CableTermination model + migrations.CreateModel( + name='CableTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('cable_end', models.CharField(max_length=1)), + ('termination_id', models.PositiveBigIntegerField()), + ('cable', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='dcim.cable')), + ('termination_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'circuits'), ('model__in', ('circuittermination',))), models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'powerfeed', 'poweroutlet', 'powerport', 'rearport'))), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('_device', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.device')), + ('_rack', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.rack')), + ('_location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.location')), + ('_site', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='dcim.site')), + ], + options={ + 'ordering': ('cable', 'cable_end', 'pk'), + }, + ), + migrations.AddConstraint( + model_name='cabletermination', + constraint=models.UniqueConstraint(fields=('termination_type', 'termination_id'), name='dcim_cable_termination_unique_termination'), + ), + + # Update CablePath model + migrations.RenameField( + model_name='cablepath', + old_name='path', + new_name='_nodes', + ), + migrations.AddField( + model_name='cablepath', + name='path', + field=models.JSONField(default=list), + ), + migrations.AddField( + model_name='cablepath', + name='is_complete', + field=models.BooleanField(default=False), + ), + + # Add cable_end field to cable termination models + migrations.AddField( + model_name='consoleport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='consoleserverport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='frontport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='interface', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='powerfeed', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='poweroutlet', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='powerport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + migrations.AddField( + model_name='rearport', + name='cable_end', + field=models.CharField(blank=True, max_length=1), + ), + ] diff --git a/netbox/dcim/migrations/0158_populate_cable_terminations.py b/netbox/dcim/migrations/0158_populate_cable_terminations.py new file mode 100644 index 000000000..72d7f154a --- /dev/null +++ b/netbox/dcim/migrations/0158_populate_cable_terminations.py @@ -0,0 +1,87 @@ +import sys + +from django.db import migrations + + +def cache_related_objects(termination): + """ + Replicate caching logic from CableTermination.cache_related_objects() + """ + attrs = {} + + # Device components + if getattr(termination, 'device', None): + attrs['_device'] = termination.device + attrs['_rack'] = termination.device.rack + attrs['_location'] = termination.device.location + attrs['_site'] = termination.device.site + + # Power feeds + elif getattr(termination, 'rack', None): + attrs['_rack'] = termination.rack + attrs['_location'] = termination.rack.location + attrs['_site'] = termination.rack.site + + # Circuit terminations + elif getattr(termination, 'site', None): + attrs['_site'] = termination.site + + return attrs + + +def populate_cable_terminations(apps, schema_editor): + """ + Replicate terminations from the Cable model into CableTermination instances. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Cable = apps.get_model('dcim', 'Cable') + CableTermination = apps.get_model('dcim', 'CableTermination') + + # Retrieve the necessary data from Cable objects + cables = Cable.objects.values( + 'id', 'termination_a_type', 'termination_a_id', 'termination_b_type', 'termination_b_id' + ) + + # Queue CableTerminations to be created + cable_terminations = [] + cable_count = cables.count() + for i, cable in enumerate(cables, start=1): + for cable_end in ('a', 'b'): + # We must manually instantiate the termination object, because GFK fields are not + # supported within migrations. + termination_ct = ContentType.objects.get(pk=cable[f'termination_{cable_end}_type']) + termination_model = apps.get_model(termination_ct.app_label, termination_ct.model) + termination = termination_model.objects.get(pk=cable[f'termination_{cable_end}_id']) + + cable_terminations.append(CableTermination( + cable_id=cable['id'], + cable_end=cable_end.upper(), + termination_type_id=cable[f'termination_{cable_end}_type'], + termination_id=cable[f'termination_{cable_end}_id'], + **cache_related_objects(termination) + )) + + # Output progress occasionally + if 'test' not in sys.argv and not i % 100: + progress = float(i) * 100 / cable_count + if i == 100: + print('') + sys.stdout.write(f"\r Updated {i}/{cable_count} cables ({progress:.2f}%)") + sys.stdout.flush() + + # Bulk create the termination objects + CableTermination.objects.bulk_create(cable_terminations, batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0157_new_cabling_models'), + ] + + operations = [ + migrations.RunPython( + code=populate_cable_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0159_populate_cable_paths.py b/netbox/dcim/migrations/0159_populate_cable_paths.py new file mode 100644 index 000000000..22fe4b67e --- /dev/null +++ b/netbox/dcim/migrations/0159_populate_cable_paths.py @@ -0,0 +1,50 @@ +from django.db import migrations + +from dcim.utils import compile_path_node + + +def populate_cable_paths(apps, schema_editor): + """ + Replicate terminations from the Cable model into CableTermination instances. + """ + CablePath = apps.get_model('dcim', 'CablePath') + + # Construct the new two-dimensional path, and add the origin & destination objects to the nodes list + cable_paths = [] + for cablepath in CablePath.objects.all(): + + # Origin + origin = compile_path_node(cablepath.origin_type_id, cablepath.origin_id) + cablepath.path.append([origin]) + cablepath._nodes.insert(0, origin) + + # Transit nodes + cablepath.path.extend([ + [node] for node in cablepath._nodes[1:] + ]) + + # Destination + if cablepath.destination_id: + destination = compile_path_node(cablepath.destination_type_id, cablepath.destination_id) + cablepath.path.append([destination]) + cablepath._nodes.append(destination) + cablepath.is_complete = True + + cable_paths.append(cablepath) + + # Bulk update all CableTerminations + CablePath.objects.bulk_update(cable_paths, fields=('path', '_nodes', 'is_complete'), batch_size=100) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0158_populate_cable_terminations'), + ] + + operations = [ + migrations.RunPython( + code=populate_cable_paths, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0160_populate_cable_ends.py b/netbox/dcim/migrations/0160_populate_cable_ends.py new file mode 100644 index 000000000..53e042abc --- /dev/null +++ b/netbox/dcim/migrations/0160_populate_cable_ends.py @@ -0,0 +1,46 @@ +from django.db import migrations + + +def populate_cable_terminations(apps, schema_editor): + Cable = apps.get_model('dcim', 'Cable') + + cable_termination_models = ( + apps.get_model('dcim', 'ConsolePort'), + apps.get_model('dcim', 'ConsoleServerPort'), + apps.get_model('dcim', 'PowerPort'), + apps.get_model('dcim', 'PowerOutlet'), + apps.get_model('dcim', 'Interface'), + apps.get_model('dcim', 'FrontPort'), + apps.get_model('dcim', 'RearPort'), + apps.get_model('dcim', 'PowerFeed'), + apps.get_model('circuits', 'CircuitTermination'), + ) + + for model in cable_termination_models: + model.objects.filter( + id__in=Cable.objects.filter( + termination_a_type__app_label=model._meta.app_label, + termination_a_type__model=model._meta.model_name + ).values_list('termination_a_id', flat=True) + ).update(cable_end='A') + model.objects.filter( + id__in=Cable.objects.filter( + termination_b_type__app_label=model._meta.app_label, + termination_b_type__model=model._meta.model_name + ).values_list('termination_b_id', flat=True) + ).update(cable_end='B') + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0037_new_cabling_models'), + ('dcim', '0159_populate_cable_paths'), + ] + + operations = [ + migrations.RunPython( + code=populate_cable_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/migrations/0161_cabling_cleanup.py b/netbox/dcim/migrations/0161_cabling_cleanup.py new file mode 100644 index 000000000..8a1b7a09e --- /dev/null +++ b/netbox/dcim/migrations/0161_cabling_cleanup.py @@ -0,0 +1,134 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0160_populate_cable_ends'), + ] + + operations = [ + + # Remove old fields from Cable + migrations.AlterModelOptions( + name='cable', + options={'ordering': ('pk',)}, + ), + migrations.AlterUniqueTogether( + name='cable', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_a_type', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_id', + ), + migrations.RemoveField( + model_name='cable', + name='termination_b_type', + ), + migrations.RemoveField( + model_name='cable', + name='_termination_a_device', + ), + migrations.RemoveField( + model_name='cable', + name='_termination_b_device', + ), + + # Remove old fields from CablePath + migrations.AlterUniqueTogether( + name='cablepath', + unique_together=set(), + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='destination_type', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_id', + ), + migrations.RemoveField( + model_name='cablepath', + name='origin_type', + ), + + # Remove link peer type/ID fields from cable termination models + migrations.RemoveField( + model_name='consoleport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='consoleport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='consoleserverport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='frontport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='frontport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='interface', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='interface', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='powerfeed', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='powerfeed', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='poweroutlet', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='powerport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='powerport', + name='_link_peer_type', + ), + migrations.RemoveField( + model_name='rearport', + name='_link_peer_id', + ), + migrations.RemoveField( + model_name='rearport', + name='_link_peer_type', + ), + + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index dcc564717..2be64451f 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,10 +1,12 @@ +import itertools from collections import defaultdict from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.db import models from django.db.models import Sum +from django.dispatch import Signal from django.urls import reverse from dcim.choices import * @@ -13,17 +15,21 @@ from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object from netbox.models import NetBoxModel from utilities.fields import ColorField +from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters -from .devices import Device +from wireless.models import WirelessLink from .device_components import FrontPort, RearPort - __all__ = ( 'Cable', 'CablePath', + 'CableTermination', ) +trace_paths = Signal() + + # # Cables # @@ -32,28 +38,6 @@ class Cable(NetBoxModel): """ A physical connection between two endpoints. """ - termination_a_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_a_id = models.PositiveBigIntegerField() - termination_a = GenericForeignKey( - ct_field='termination_a_type', - fk_field='termination_a_id' - ) - termination_b_type = models.ForeignKey( - to=ContentType, - limit_choices_to=CABLE_TERMINATION_MODELS, - on_delete=models.PROTECT, - related_name='+' - ) - termination_b_id = models.PositiveBigIntegerField() - termination_b = GenericForeignKey( - ct_field='termination_b_type', - fk_field='termination_b_id' - ) type = models.CharField( max_length=50, choices=CableTypeChoices, @@ -96,31 +80,11 @@ class Cable(NetBoxModel): blank=True, null=True ) - # Cache the associated device (where applicable) for the A and B terminations. This enables filtering of Cables by - # their associated Devices. - _termination_a_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - _termination_b_device = models.ForeignKey( - to=Device, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) class Meta: - ordering = ['pk'] - unique_together = ( - ('termination_a_type', 'termination_a_id'), - ('termination_b_type', 'termination_b_id'), - ) + ordering = ('pk',) - def __init__(self, *args, **kwargs): + def __init__(self, *args, a_terminations=None, b_terminations=None, **kwargs): super().__init__(*args, **kwargs) # A copy of the PK to be used by __str__ in case the object is deleted @@ -129,19 +93,13 @@ class Cable(NetBoxModel): # Cache the original status so we can check later if it's been changed self._orig_status = self.status - @classmethod - def from_db(cls, db, field_names, values): - """ - Cache the original A and B terminations of existing Cable instances for later reference inside clean(). - """ - instance = super().from_db(db, field_names, values) + self._terminations_modified = False - instance._orig_termination_a_type_id = instance.termination_a_type_id - instance._orig_termination_a_id = instance.termination_a_id - instance._orig_termination_b_type_id = instance.termination_b_type_id - instance._orig_termination_b_id = instance.termination_b_id - - return instance + # Assign or retrieve A/B terminations + if a_terminations: + self.a_terminations = a_terminations + if b_terminations: + self.b_terminations = b_terminations def __str__(self): pk = self.pk or self._pk @@ -150,124 +108,68 @@ class Cable(NetBoxModel): def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) + @property + def a_terminations(self): + if hasattr(self, '_a_terminations'): + return self._a_terminations + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_A + ] + + @a_terminations.setter + def a_terminations(self, value): + self._terminations_modified = True + self._a_terminations = value + + @property + def b_terminations(self): + if hasattr(self, '_b_terminations'): + return self._b_terminations + # Query self.terminations.all() to leverage cached results + return [ + ct.termination for ct in self.terminations.all() if ct.cable_end == CableEndChoices.SIDE_B + ] + + @b_terminations.setter + def b_terminations(self, value): + self._terminations_modified = True + self._b_terminations = value + def clean(self): - from circuits.models import CircuitTermination - super().clean() - # Validate that termination A exists - if not hasattr(self, 'termination_a_type'): - raise ValidationError('Termination A type has not been specified') - 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) - }) - - # Validate that termination B exists - if not hasattr(self, 'termination_b_type'): - raise ValidationError('Termination B type has not been specified') - 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) - }) - - # If editing an existing Cable instance, check that neither termination has been modified. - if self.pk: - err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' - if ( - self.termination_a_type_id != self._orig_termination_a_type_id or - self.termination_a_id != self._orig_termination_a_id - ): - raise ValidationError({ - 'termination_a': err_msg - }) - if ( - self.termination_b_type_id != self._orig_termination_b_type_id or - self.termination_b_id != self._orig_termination_b_id - ): - raise ValidationError({ - 'termination_b': err_msg - }) - - type_a = self.termination_a_type.model - type_b = self.termination_b_type.model - - # Validate interface types - if type_a == 'interface' and self.termination_a.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_a_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_a.get_type_display() - ) - }) - if type_b == 'interface' and self.termination_b.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination_b_id': 'Cables cannot be terminated to {} interfaces'.format( - self.termination_b.get_type_display() - ) - }) - - # Check that termination types are compatible - if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a): - raise ValidationError( - f"Incompatible termination types: {self.termination_a_type} and {self.termination_b_type}" - ) - - # Check that two connected RearPorts have the same number of positions (if both are >1) - if isinstance(self.termination_a, RearPort) and isinstance(self.termination_b, RearPort): - if self.termination_a.positions > 1 and self.termination_b.positions > 1: - if self.termination_a.positions != self.termination_b.positions: - raise ValidationError( - f"{self.termination_a} has {self.termination_a.positions} position(s) but " - f"{self.termination_b} has {self.termination_b.positions}. " - f"Both terminations must have the same number of positions (if greater than one)." - ) - - # A termination point cannot be connected to itself - if self.termination_a == self.termination_b: - raise ValidationError(f"Cannot connect {self.termination_a_type} to itself") - - # 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") - - # A CircuitTermination attached to a ProviderNetwork cannot have a Cable - if isinstance(self.termination_a, CircuitTermination) and self.termination_a.provider_network is not None: - raise ValidationError({ - 'termination_a_id': "Circuit terminations attached to a provider network may not be cabled." - }) - if isinstance(self.termination_b, CircuitTermination) and self.termination_b.provider_network is not None: - raise ValidationError({ - 'termination_b_id': "Circuit terminations attached to a provider network may not be cabled." - }) - - # 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 - )) - # Validate length and length_unit if self.length is not None and not self.length_unit: raise ValidationError("Must specify a unit when setting a cable length") elif self.length is None: self.length_unit = '' + if self.pk is None and (not self.a_terminations or not self.b_terminations): + raise ValidationError("Must define A and B terminations when creating a new cable.") + + if self._terminations_modified: + + # Check that all termination objects for either end are of the same type + for terms in (self.a_terminations, self.b_terminations): + if len(terms) > 1 and not all(isinstance(t, type(terms[0])) for t in terms[1:]): + raise ValidationError("Cannot connect different termination types to same end of cable.") + + # Check that termination types are compatible + if self.a_terminations and self.b_terminations: + a_type = self.a_terminations[0]._meta.model_name + b_type = self.b_terminations[0]._meta.model_name + if b_type not in COMPATIBLE_TERMINATION_TYPES.get(a_type): + raise ValidationError(f"Incompatible termination types: {a_type} and {b_type}") + + # Run clean() on any new CableTerminations + for termination in self.a_terminations: + CableTermination(cable=self, cable_end='A', termination=termination).clean() + for termination in self.b_terminations: + CableTermination(cable=self, cable_end='B', termination=termination).clean() + def save(self, *args, **kwargs): + _created = self.pk is None # Store the given length (if any) in meters for use in database ordering if self.length and self.length_unit: @@ -275,199 +177,447 @@ class Cable(NetBoxModel): else: self._abs_length = None - # Store the parent Device for the A and B terminations (if applicable) to enable filtering - if hasattr(self.termination_a, 'device'): - self._termination_a_device = self.termination_a.device - if hasattr(self.termination_b, 'device'): - self._termination_b_device = self.termination_b.device - super().save(*args, **kwargs) # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) self._pk = self.pk + # Retrieve existing A/B terminations for the Cable + a_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='A')} + b_terminations = {ct.termination: ct for ct in self.terminations.filter(cable_end='B')} + + # Delete stale CableTerminations + if self._terminations_modified: + for termination, ct in a_terminations.items(): + if termination.pk and termination not in self.a_terminations: + ct.delete() + for termination, ct in b_terminations.items(): + if termination.pk and termination not in self.b_terminations: + ct.delete() + + # Save new CableTerminations (if any) + if self._terminations_modified: + for termination in self.a_terminations: + if not termination.pk or termination not in a_terminations: + CableTermination(cable=self, cable_end='A', termination=termination).save() + for termination in self.b_terminations: + if not termination.pk or termination not in b_terminations: + CableTermination(cable=self, cable_end='B', termination=termination).save() + + trace_paths.send(Cable, instance=self, created=_created) + def get_status_color(self): return LinkStatusChoices.colors.get(self.status) - def get_compatible_types(self): + +class CableTermination(models.Model): + """ + A mapping between side A or B of a Cable and a terminating object (e.g. an Interface or CircuitTermination). + """ + cable = models.ForeignKey( + to='dcim.Cable', + on_delete=models.CASCADE, + related_name='terminations' + ) + cable_end = models.CharField( + max_length=1, + choices=CableEndChoices, + verbose_name='End' + ) + termination_type = models.ForeignKey( + to=ContentType, + limit_choices_to=CABLE_TERMINATION_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + termination_id = models.PositiveBigIntegerField() + termination = GenericForeignKey( + ct_field='termination_type', + fk_field='termination_id' + ) + + # Cached associations to enable efficient filtering + _device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _rack = models.ForeignKey( + to='dcim.Rack', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.CASCADE, + blank=True, + null=True + ) + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('cable', 'cable_end', 'pk') + constraints = ( + models.UniqueConstraint( + fields=('termination_type', 'termination_id'), + name='dcim_cable_termination_unique_termination' + ), + ) + + def __str__(self): + return f'Cable {self.cable} to {self.termination}' + + def clean(self): + super().clean() + + # Validate interface type (if applicable) + if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' + }) + + # A CircuitTermination attached to a ProviderNetwork cannot have a Cable + if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: + raise ValidationError({ + 'termination': "Circuit terminations attached to a provider network may not be cabled." + }) + + def save(self, *args, **kwargs): + + # Cache objects associated with the terminating object (for filtering) + self.cache_related_objects() + + super().save(*args, **kwargs) + + # Set the cable on the terminating object + termination_model = self.termination._meta.model + termination_model.objects.filter(pk=self.termination_id).update( + cable=self.cable, + cable_end=self.cable_end + ) + + def delete(self, *args, **kwargs): + + # Delete the cable association on the terminating object + termination_model = self.termination._meta.model + termination_model.objects.filter(pk=self.termination_id).update( + cable=None, + cable_end='' + ) + + super().delete(*args, **kwargs) + + def cache_related_objects(self): """ - Return all termination types compatible with termination A. + Cache objects related to the termination (e.g. device, rack, site) directly on the object to + enable efficient filtering. """ - if self.termination_a is None: - return - return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] + assert self.termination is not None + + # Device components + if getattr(self.termination, 'device', None): + self._device = self.termination.device + self._rack = self.termination.device.rack + self._location = self.termination.device.location + self._site = self.termination.device.site + + # Power feeds + elif getattr(self.termination, 'rack', None): + self._rack = self.termination.rack + self._location = self.termination.rack.location + self._site = self.termination.rack.site + + # Circuit terminations + elif getattr(self.termination, 'site', None): + self._site = self.termination.site class CablePath(models.Model): """ - A CablePath instance represents the physical path from an origin to a destination, including all intermediate - elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do - not terminate on a PathEndpoint). + A CablePath instance represents the physical path from a set of origin nodes to a set of destination nodes, + including all intermediate elements. - `path` contains a list of nodes within the path, each represented by a tuple of (type, ID). The first element in the - path must be a Cable instance, followed by a pair of pass-through ports. For example, consider the following + `path` contains the ordered set of nodes, arranged in lists of (type, ID) tuples. (Each cable in the path can + terminate to one or more objects.) For example, consider the following topology: - 1 2 3 - Interface A --- Front Port A | Rear Port A --- Rear Port B | Front Port B --- Interface B + A B C + Interface 1 --- Front Port 1 | Rear Port 1 --- Rear Port 2 | Front Port 3 --- Interface 2 + Front Port 2 Front Port 4 This path would be expressed as: CablePath( - origin = Interface A - destination = Interface B - path = [Cable 1, Front Port A, Rear Port A, Cable 2, Rear Port B, Front Port B, Cable 3] + path = [ + [Interface 1], + [Cable A], + [Front Port 1, Front Port 2], + [Rear Port 1], + [Cable B], + [Rear Port 2], + [Front Port 3, Front Port 4], + [Cable C], + [Interface 2], + ] ) - `is_active` is set to True only if 1) `destination` is not null, and 2) every Cable within the path has a status of - "connected". + `is_active` is set to True only if every Cable within the path has a status of "connected". `is_complete` is True + if the instance represents a complete end-to-end path from origin(s) to destination(s). `is_split` is True if the + path diverges across multiple cables. + + `_nodes` retains a flattened list of all nodes within the path to enable simple filtering. """ - origin_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+' + path = models.JSONField( + default=list ) - origin_id = models.PositiveBigIntegerField() - origin = GenericForeignKey( - ct_field='origin_type', - fk_field='origin_id' - ) - destination_type = models.ForeignKey( - to=ContentType, - on_delete=models.CASCADE, - related_name='+', - blank=True, - null=True - ) - destination_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - destination = GenericForeignKey( - ct_field='destination_type', - fk_field='destination_id' - ) - path = PathField() is_active = models.BooleanField( default=False ) + is_complete = models.BooleanField( + default=False + ) is_split = models.BooleanField( default=False ) - - class Meta: - unique_together = ('origin_type', 'origin_id') + _nodes = PathField() def __str__(self): - status = ' (active)' if self.is_active else ' (split)' if self.is_split else '' - return f"Path #{self.pk}: {self.origin} to {self.destination} via {len(self.path)} nodes{status}" + return f"Path #{self.pk}: {len(self.path)} hops" def save(self, *args, **kwargs): + + # Save the flattened nodes list + self._nodes = list(itertools.chain(*self.path)) + super().save(*args, **kwargs) - # Record a direct reference to this CablePath on its originating object - model = self.origin._meta.model - model.objects.filter(pk=self.origin.pk).update(_path=self.pk) + # Record a direct reference to this CablePath on its originating object(s) + origin_model = self.origin_type.model_class() + origin_ids = [decompile_path_node(node)[1] for node in self.path[0]] + origin_model.objects.filter(pk__in=origin_ids).update(_path=self.pk) + + @property + def origin_type(self): + if self.path: + ct_id, _ = decompile_path_node(self.path[0][0]) + return ContentType.objects.get_for_id(ct_id) + + @property + def destination_type(self): + if self.is_complete: + ct_id, _ = decompile_path_node(self.path[-1][0]) + return ContentType.objects.get_for_id(ct_id) + + @property + def path_objects(self): + """ + Cache and return the complete path as lists of objects, derived from their annotation within the path. + """ + if not hasattr(self, '_path_objects'): + self._path_objects = self._get_path() + return self._path_objects + + @property + def origins(self): + """ + Return the list of originating objects. + """ + return self.path_objects[0] + + @property + def destinations(self): + """ + Return the list of destination objects, if the path is complete. + """ + if not self.is_complete: + return [] + return self.path_objects[-1] @property def segment_count(self): - total_length = 1 + len(self.path) + (1 if self.destination else 0) - return int(total_length / 3) + return int(len(self.path) / 3) @classmethod - def from_origin(cls, origin): + def from_origin(cls, terminations): """ - Create a new CablePath instance as traced from the given path origin. + Create a new CablePath instance as traced from the given termination objects. These can be any object to which a + Cable or WirelessLink connects (interfaces, console ports, circuit termination, etc.). All terminations must be + of the same type and must belong to the same parent object. """ from circuits.models import CircuitTermination - if origin is None or origin.link is None: + if not terminations: return None - destination = None + # Ensure all originating terminations are attached to the same link + if len(terminations) > 1: + assert all(t.link == terminations[0].link for t in terminations[1:]) + path = [] position_stack = [] + is_complete = False is_active = True is_split = False - node = origin - while node.link is not None: - if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED: + while terminations: + + # Terminations must all be of the same type + assert all(isinstance(t, type(terminations[0])) for t in terminations[1:]) + + # Check for a split path (e.g. rear port fanning out to multiple front ports with + # different cables attached) + if len(set(t.link for t in terminations)) > 1: + is_split = True + break + + # Step 1: Record the near-end termination object(s) + path.append([ + object_to_path_node(t) for t in terminations + ]) + + # Step 2: Determine the attached link (Cable or WirelessLink), if any + link = terminations[0].link + if link is None and len(path) == 1: + # If this is the start of the path and no link exists, return None + return None + elif link is None: + # Otherwise, halt the trace if no link exists + break + assert type(link) in (Cable, WirelessLink) + + # Step 3: Record the link and update path status if not "connected" + path.append([object_to_path_node(link)]) + if hasattr(link, 'status') and link.status != LinkStatusChoices.STATUS_CONNECTED: is_active = False - # Follow the link to its far-end termination - path.append(object_to_path_node(node.link)) - peer_termination = node.get_link_peer() + # Step 4: Determine the far-end terminations + if isinstance(link, Cable): + termination_type = ContentType.objects.get_for_model(terminations[0]) + local_cable_terminations = CableTermination.objects.filter( + termination_type=termination_type, + termination_id__in=[t.pk for t in terminations] + ) + # Terminations must all belong to same end of Cable + local_cable_end = local_cable_terminations[0].cable_end + assert all(ct.cable_end == local_cable_end for ct in local_cable_terminations[1:]) + remote_cable_terminations = CableTermination.objects.filter( + cable=link, + cable_end='A' if local_cable_end == 'B' else 'B' + ) + remote_terminations = [ct.termination for ct in remote_cable_terminations] + else: + # WirelessLink + remote_terminations = [link.interface_b] if link.interface_a is terminations[0] else [link.interface_a] - # Follow a FrontPort to its corresponding RearPort - if isinstance(peer_termination, FrontPort): - path.append(object_to_path_node(peer_termination)) - node = peer_termination.rear_port - if node.positions > 1: - position_stack.append(peer_termination.rear_port_position) - path.append(object_to_path_node(node)) + # Step 5: Record the far-end termination object(s) + path.append([ + object_to_path_node(t) for t in remote_terminations + ]) - # Follow a RearPort to its corresponding FrontPort (if any) - elif isinstance(peer_termination, RearPort): - path.append(object_to_path_node(peer_termination)) + # Step 6: Determine the "next hop" terminations, if applicable + if not remote_terminations: + break - # Determine the peer FrontPort's position - if peer_termination.positions == 1: - position = 1 + if isinstance(remote_terminations[0], FrontPort): + # Follow FrontPorts to their corresponding RearPorts + rear_ports = RearPort.objects.filter( + pk__in=[t.rear_port_id for t in remote_terminations] + ) + if len(rear_ports) > 1: + assert all(rp.positions == 1 for rp in rear_ports) + elif rear_ports[0].positions > 1: + position_stack.append([fp.rear_port_position for fp in remote_terminations]) + + terminations = rear_ports + + elif isinstance(remote_terminations[0], RearPort): + + if len(remote_terminations) > 1 or remote_terminations[0].positions == 1: + front_ports = FrontPort.objects.filter( + rear_port_id__in=[rp.pk for rp in remote_terminations], + rear_port_position=1 + ) elif position_stack: - position = position_stack.pop() + front_ports = FrontPort.objects.filter( + rear_port_id=remote_terminations[0].pk, + rear_port_position__in=position_stack.pop() + ) else: - # No position indicated: path has split, so we stop at the RearPort + # No position indicated: path has split, so we stop at the RearPorts is_split = True break - try: - node = FrontPort.objects.get(rear_port=peer_termination, rear_port_position=position) - path.append(object_to_path_node(node)) - except ObjectDoesNotExist: - # No corresponding FrontPort found for the RearPort + terminations = front_ports + + elif isinstance(remote_terminations[0], CircuitTermination): + # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) + term_side = remote_terminations[0].term_side + assert all(ct.term_side == term_side for ct in remote_terminations[1:]) + circuit_termination = CircuitTermination.objects.filter( + circuit=remote_terminations[0].circuit, + term_side='Z' if term_side == 'A' else 'A' + ).first() + if circuit_termination is None: + break + elif circuit_termination.provider_network: + # Circuit terminates to a ProviderNetwork + path.extend([ + [object_to_path_node(circuit_termination)], + [object_to_path_node(circuit_termination.provider_network)], + ]) + break + elif circuit_termination.site and not circuit_termination.cable: + # Circuit terminates to a Site + path.extend([ + [object_to_path_node(circuit_termination)], + [object_to_path_node(circuit_termination.site)], + ]) break - # Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa) - elif isinstance(peer_termination, CircuitTermination): - path.append(object_to_path_node(peer_termination)) - # Get peer CircuitTermination - node = peer_termination.get_peer_termination() - if node: - path.append(object_to_path_node(node)) - if node.provider_network: - destination = node.provider_network - break - elif node.site and not node.cable: - destination = node.site - break - else: - # No peer CircuitTermination exists; halt the trace - break + terminations = [circuit_termination] # Anything else marks the end of the path else: - destination = peer_termination + is_complete = True break - if destination is None: - is_active = False - return cls( - origin=origin, - destination=destination, path=path, + is_complete=is_complete, is_active=is_active, is_split=is_split ) - def get_path(self): + def retrace(self): + """ + Retrace the path from the currently-defined originating termination(s) + """ + _new = self.from_origin(self.origins) + if _new: + self.path = _new.path + self.is_complete = _new.is_complete + self.is_active = _new.is_active + self.is_split = _new.is_split + self.save() + else: + self.delete() + + def _get_path(self): """ Return the path as a list of prefetched objects. """ # Compile a list of IDs to prefetch for each type of model in the path to_prefetch = defaultdict(list) - for node in self.path: + for node in self._nodes: ct_id, object_id = decompile_path_node(node) to_prefetch[ct_id].append(object_id) @@ -484,19 +634,19 @@ class CablePath(models.Model): # Replicate the path using the prefetched objects. path = [] - for node in self.path: - ct_id, object_id = decompile_path_node(node) - path.append(prefetched[ct_id][object_id]) + for step in self.path: + nodes = [] + for node in step: + ct_id, object_id = decompile_path_node(node) + try: + nodes.append(prefetched[ct_id][object_id]) + except KeyError: + # Ignore stale (deleted) object IDs + pass + path.append(nodes) return path - @property - def last_node(self): - """ - Return either the destination or the last node within the path. - """ - return self.destination or path_node_to_object(self.path[-1]) - def get_cable_ids(self): """ Return all Cable IDs within the path. @@ -504,7 +654,7 @@ class CablePath(models.Model): cable_ct = ContentType.objects.get_for_model(Cable).pk cable_ids = [] - for node in self.path: + for node in self._nodes: ct, id = decompile_path_node(node) if ct == cable_ct: cable_ids.append(id) @@ -527,6 +677,6 @@ class CablePath(models.Model): """ Return all available next segments in a split cable path. """ - rearport = path_node_to_object(self.path[-1]) + rearports = self.path_objects[-1] - return FrontPort.objects.filter(rear_port=rearport) + return FrontPort.objects.filter(rear_port__in=rearports) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 74252e480..b7079d375 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,6 +1,6 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from mptt.models import MPTTModel, TreeForeignKey @@ -357,6 +357,18 @@ class InterfaceTemplate(ModularComponentTemplateModel): default=False, verbose_name='Management only' ) + poe_mode = models.CharField( + max_length=50, + choices=InterfacePoEModeChoices, + blank=True, + verbose_name='PoE mode' + ) + poe_type = models.CharField( + max_length=50, + choices=InterfacePoETypeChoices, + blank=True, + verbose_name='PoE type' + ) component_model = Interface @@ -373,6 +385,8 @@ class InterfaceTemplate(ModularComponentTemplateModel): label=self.resolve_label(kwargs.get('module')), type=self.type, mgmt_only=self.mgmt_only, + poe_mode=self.poe_mode, + poe_type=self.poe_type, **kwargs ) @@ -383,6 +397,8 @@ class InterfaceTemplate(ModularComponentTemplateModel): 'mgmt_only': self.mgmt_only, 'label': self.label, 'description': self.description, + 'poe_mode': self.poe_mode, + 'poe_type': self.poe_type, } diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 714ee3ff5..838336e21 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -1,6 +1,8 @@ +from functools import cached_property + from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Sum @@ -10,7 +12,6 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField -from dcim.svg import CableTraceSVG from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField @@ -23,7 +24,7 @@ from wireless.utils import get_channel_attr __all__ = ( 'BaseInterface', - 'LinkTermination', + 'CabledObjectModel', 'ConsolePort', 'ConsoleServerPort', 'DeviceBay', @@ -102,14 +103,10 @@ class ModularComponentModel(ComponentModel): abstract = True -class LinkTermination(models.Model): +class CabledObjectModel(models.Model): """ - An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples - include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields - reference the attached Cable or WirelessLink instance, respectively. - - `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a - shortcut to referencing `instance.link.termination_b`, for example. + An abstract model inherited by all models to which a Cable can terminate. Provides the `cable` and `cable_end` + fields for caching cable associations, as well as `mark_connected` to designate "fake" connections. """ cable = models.ForeignKey( to='dcim.Cable', @@ -118,36 +115,21 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_type = models.ForeignKey( - to=ContentType, - on_delete=models.SET_NULL, - related_name='+', + cable_end = models.CharField( + max_length=1, blank=True, - null=True - ) - _link_peer_id = models.PositiveBigIntegerField( - blank=True, - null=True - ) - _link_peer = GenericForeignKey( - ct_field='_link_peer_type', - fk_field='_link_peer_id' + choices=CableEndChoices ) mark_connected = models.BooleanField( default=False, help_text="Treat as if a cable is connected" ) - # Generic relations to Cable. These ensure that an attached Cable is deleted if the terminated object is deleted. - _cabled_as_a = GenericRelation( - to='dcim.Cable', - content_type_field='termination_a_type', - object_id_field='termination_a_id' - ) - _cabled_as_b = GenericRelation( - to='dcim.Cable', - content_type_field='termination_b_type', - object_id_field='termination_b_id' + cable_terminations = GenericRelation( + to='dcim.CableTermination', + content_type_field='termination_type', + object_id_field='termination_id', + related_query_name='%(class)s', ) class Meta: @@ -156,22 +138,19 @@ class LinkTermination(models.Model): def clean(self): super().clean() - if self.mark_connected and self.cable_id: + if self.cable and not self.cable_end: + raise ValidationError({ + "cable_end": "Must specify cable end (A or B) when attaching a cable." + }) + if self.cable_end and not self.cable: + raise ValidationError({ + "cable_end": "Cable end must not be set without a cable." + }) + if self.mark_connected and self.cable: raise ValidationError({ "mark_connected": "Cannot mark as connected with a cable attached." }) - def get_link_peer(self): - return self._link_peer - - @property - def _occupied(self): - return bool(self.mark_connected or self.cable_id) - - @property - def parent_object(self): - raise NotImplementedError("CableTermination models must implement parent_object()") - @property def link(self): """ @@ -179,10 +158,31 @@ class LinkTermination(models.Model): """ return self.cable + @cached_property + def link_peers(self): + if self.cable: + peers = self.cable.terminations.exclude(cable_end=self.cable_end).prefetch_related('termination') + return [peer.termination for peer in peers] + return [] + + @property + def _occupied(self): + return bool(self.mark_connected or self.cable_id) + + @property + def parent_object(self): + raise NotImplementedError(f"{self.__class__.__name__} models must declare a parent_object property") + + @property + def opposite_cable_end(self): + if not self.cable_end: + return None + return CableEndChoices.SIDE_A if self.cable_end == CableEndChoices.SIDE_B else CableEndChoices.SIDE_B + class PathEndpoint(models.Model): """ - An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, + An abstract model inherited by any CabledObjectModel subclass which represents the end of a CablePath; specifically, these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed. `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in @@ -205,50 +205,48 @@ class PathEndpoint(models.Model): origin = self path = [] - # Construct the complete path + # Construct the complete path (including e.g. bridged interfaces) while origin is not None: if origin._path is None: break - path.extend([origin, *origin._path.get_path()]) - while (len(path) + 1) % 3: - # Pad to ensure we have complete three-tuples (e.g. for paths that end at a non-connected FrontPort) - path.append(None) - path.append(origin._path.destination) + path.extend(origin._path.path_objects) - # Check for bridge interface to continue the trace - origin = getattr(origin._path.destination, 'bridge', None) + # If the path ends at a non-connected pass-through port, pad out the link and far-end terminations + if len(path) % 3 == 1: + path.extend(([], [])) + # If the path ends at a site or provider network, inject a null "link" to render an attachment + elif len(path) % 3 == 2: + path.insert(-1, []) - # Return the path as a list of three-tuples (A termination, cable, B termination) + # Check for a bridged relationship to continue the trace + destinations = origin._path.destinations + if len(destinations) == 1: + origin = getattr(destinations[0], 'bridge', None) + else: + origin = None + + # Return the path as a list of three-tuples (A termination(s), cable(s), B termination(s)) return list(zip(*[iter(path)] * 3)) - def get_trace_svg(self, base_url=None, width=None): - if width is not None: - trace = CableTraceSVG(self, base_url=base_url, width=width) - else: - trace = CableTraceSVG(self, base_url=base_url) - return trace.render() - @property def path(self): return self._path - @property - def connected_endpoint(self): + @cached_property + def connected_endpoints(self): """ Caching accessor for the attached CablePath's destination (if any) """ - if not hasattr(self, '_connected_endpoint'): - self._connected_endpoint = self._path.destination if self._path else None - return self._connected_endpoint + return self._path.destinations if self._path else [] # # Console components # -class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -265,7 +263,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): help_text='Port speed in bits per second' ) - clone_fields = ['device', 'type', 'speed'] + clone_fields = ('device', 'module', 'type', 'speed') class Meta: ordering = ('device', '_name') @@ -275,7 +273,7 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -292,7 +290,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): help_text='Port speed in bits per second' ) - clone_fields = ['device', 'type', 'speed'] + clone_fields = ('device', 'module', 'type', 'speed') class Meta: ordering = ('device', '_name') @@ -306,7 +304,7 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -329,7 +327,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): help_text="Allocated power draw (watts)" ) - clone_fields = ['device', 'maximum_draw', 'allocated_draw'] + clone_fields = ('device', 'module', 'maximum_draw', 'allocated_draw') class Meta: ordering = ('device', '_name') @@ -347,36 +345,57 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." }) + def get_downstream_powerports(self, leg=None): + """ + Return a queryset of all PowerPorts connected via cable to a child PowerOutlet. For example, in the topology + below, PP1.get_downstream_powerports() would return PP2-4. + + ---- PO1 <---> PP2 + / + PP1 ------- PO2 <---> PP3 + \ + ---- PO3 <---> PP4 + + """ + poweroutlets = self.poweroutlets.filter(cable__isnull=False) + if leg: + poweroutlets = poweroutlets.filter(feed_leg=leg) + if not poweroutlets: + return PowerPort.objects.none() + + q = Q() + for poweroutlet in poweroutlets: + q |= Q( + cable=poweroutlet.cable, + cable_end=poweroutlet.opposite_cable_end + ) + + return PowerPort.objects.filter(q) + def get_power_draw(self): """ Return the allocated and maximum power draw (in VA) and child PowerOutlet count for this PowerPort. """ + from dcim.models import PowerFeed + # 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: - poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet) - outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True) - utilization = PowerPort.objects.filter( - _link_peer_type=poweroutlet_ct, - _link_peer_id__in=outlet_ids - ).aggregate( + utilization = self.get_downstream_powerports().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), + 'outlet_count': self.poweroutlets.count(), 'legs': [], } - # Calculate per-leg aggregates for three-phase feeds - if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE: + # Calculate per-leg aggregates for three-phase power feeds + if len(self.link_peers) == 1 and isinstance(self.link_peers[0], PowerFeed) and \ + self.link_peers[0].phase == PowerFeedPhaseChoices.PHASE_3PHASE: for leg, leg_name in PowerOutletFeedLegChoices: - outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True) - utilization = PowerPort.objects.filter( - _link_peer_type=poweroutlet_ct, - _link_peer_id__in=outlet_ids - ).aggregate( + utilization = self.get_downstream_powerports(leg=leg).aggregate( maximum_draw_total=Sum('maximum_draw'), allocated_draw_total=Sum('allocated_draw'), ) @@ -384,7 +403,7 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): 'name': leg_name, 'allocated': utilization['allocated_draw_total'] or 0, 'maximum': utilization['maximum_draw_total'] or 0, - 'outlet_count': len(outlet_ids), + 'outlet_count': self.poweroutlets.filter(feed_leg=leg).count(), }) return ret @@ -393,12 +412,12 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): return { 'allocated': self.allocated_draw or 0, 'maximum': self.maximum_draw or 0, - 'outlet_count': PowerOutlet.objects.filter(power_port=self).count(), + 'outlet_count': self.poweroutlets.count(), 'legs': [], } -class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -422,7 +441,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): help_text="Phase (for three-phase feeds)" ) - clone_fields = ['device', 'type', 'power_port', 'feed_leg'] + clone_fields = ('device', 'module', 'type', 'power_port', 'feed_leg') class Meta: ordering = ('device', '_name') @@ -436,9 +455,7 @@ class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): # 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) - ) + raise ValidationError(f"Parent power port ({self.power_port}) must belong to the same device") # @@ -512,7 +529,7 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -589,6 +606,18 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo validators=(MaxValueValidator(127),), verbose_name='Transmit power (dBm)' ) + poe_mode = models.CharField( + max_length=50, + choices=InterfacePoEModeChoices, + blank=True, + verbose_name='PoE mode' + ) + poe_type = models.CharField( + max_length=50, + choices=InterfacePoETypeChoices, + blank=True, + verbose_name='PoE type' + ) wireless_link = models.ForeignKey( to='wireless.WirelessLink', on_delete=models.SET_NULL, @@ -636,8 +665,17 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo object_id_field='interface_id', related_query_name='+' ) + l2vpn_terminations = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='interface', + ) - clone_fields = ['device', 'parent', 'bridge', 'lag', 'type', 'mgmt_only'] + clone_fields = ( + 'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role', + 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf', + ) class Meta: ordering = ('device', CollateAsChar('_name')) @@ -725,6 +763,24 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo f"of virtual chassis {self.device.virtual_chassis}." }) + # PoE validation + + # Only physical interfaces may have a PoE mode/type assigned + if self.poe_mode and self.is_virtual: + raise ValidationError({ + 'poe_mode': "Virtual interfaces cannot have a PoE mode." + }) + if self.poe_type and self.is_virtual: + raise ValidationError({ + 'poe_type': "Virtual interfaces cannot have a PoE type." + }) + + # An interface with a PoE type set must also specify a mode + if self.poe_type and not self.poe_mode: + raise ValidationError({ + 'poe_type': "Must specify PoE mode when designating a PoE type." + }) + # Wireless validation # RF role & channel may only be set for wireless interfaces @@ -792,12 +848,28 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo def link(self): return self.cable or self.wireless_link + @cached_property + def link_peers(self): + if self.cable: + return super().link_peers + if self.wireless_link: + # Return the opposite side of the attached wireless link + if self.wireless_link.interface_a == self: + return [self.wireless_link.interface_b] + else: + return [self.wireless_link.interface_a] + return [] + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() + # # Pass-through ports # -class FrontPort(ModularComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, CabledObjectModel): """ A pass-through port on the front of a Device. """ @@ -821,7 +893,7 @@ class FrontPort(ModularComponentModel, LinkTermination): ] ) - clone_fields = ['device', 'type'] + clone_fields = ('device', 'type', 'color') class Meta: ordering = ('device', '_name') @@ -850,7 +922,7 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -class RearPort(ModularComponentModel, LinkTermination): +class RearPort(ModularComponentModel, CabledObjectModel): """ A pass-through port on the rear of a Device. """ @@ -868,7 +940,7 @@ class RearPort(ModularComponentModel, LinkTermination): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - clone_fields = ['device', 'type', 'positions'] + clone_fields = ('device', 'type', 'color', 'positions') class Meta: ordering = ('device', '_name') @@ -903,7 +975,7 @@ class ModuleBay(ComponentModel): help_text='Identifier to reference when renaming installed components' ) - clone_fields = ['device'] + clone_fields = ('device',) class Meta: ordering = ('device', '_name') @@ -925,7 +997,7 @@ class DeviceBay(ComponentModel): null=True ) - clone_fields = ['device'] + clone_fields = ('device',) class Meta: ordering = ('device', '_name') @@ -1062,7 +1134,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id'] + clone_fields = ('device', 'parent', 'role', 'manufacturer', 'part_id',) class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d524dcb1..092df3a0e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1,3 +1,5 @@ +import decimal + import yaml from django.apps import apps @@ -99,8 +101,10 @@ class DeviceType(NetBoxModel): blank=True, help_text='Discrete part number (optional)' ) - u_height = models.PositiveSmallIntegerField( - default=1, + u_height = models.DecimalField( + max_digits=4, + decimal_places=1, + default=1.0, verbose_name='Height (U)' ) is_full_depth = models.BooleanField( @@ -133,9 +137,9 @@ class DeviceType(NetBoxModel): blank=True ) - clone_fields = [ + clone_fields = ( 'manufacturer', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - ] + ) class Meta: ordering = ['manufacturer', 'model'] @@ -170,7 +174,7 @@ class DeviceType(NetBoxModel): 'model': self.model, 'slug': self.slug, 'part_number': self.part_number, - 'u_height': self.u_height, + 'u_height': float(self.u_height), 'is_full_depth': self.is_full_depth, 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, @@ -220,6 +224,12 @@ class DeviceType(NetBoxModel): def clean(self): super().clean() + # U height must be divisible by 0.5 + if self.u_height % decimal.Decimal(0.5): + raise ValidationError({ + 'u_height': "U height must be in increments of 0.5 rack units." + }) + # If editing an existing DeviceType to have a larger u_height, first validate that *all* instances of it have # room to expand within their racks. This validation will impose a very high performance penalty when there are # many instances to check, but increasing the u_height of a DeviceType should be a very rare occurrence. @@ -551,10 +561,12 @@ class Device(NetBoxModel, ConfigContextModel): blank=True, null=True ) - position = models.PositiveSmallIntegerField( + position = models.DecimalField( + max_digits=4, + decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1)], + validators=[MinValueValidator(1), MaxValueValidator(99.5)], verbose_name='Position (U)', help_text='The lowest-numbered unit occupied by the device' ) @@ -628,9 +640,10 @@ class Device(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() - clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'airflow', 'cluster', - ] + clone_fields = ( + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'face', 'status', 'airflow', + 'cluster', 'virtual_chassis', + ) class Meta: ordering = ('_name', 'pk') # Name may be null @@ -708,7 +721,11 @@ class Device(NetBoxModel, ConfigContextModel): 'position': "Cannot select a rack position without assigning a rack.", }) - # Validate position/face combination + # Validate rack position and face + if self.position and self.position % decimal.Decimal(0.5): + raise ValidationError({ + 'position': "Position must be in increments of 0.5 rack units." + }) if self.position and not self.face: raise ValidationError({ 'face': "Must specify rack face when defining rack position.", diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 5e355ce42..83eead67f 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -10,7 +10,7 @@ from dcim.constants import * from netbox.config import ConfigItem from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator -from .device_components import LinkTermination, PathEndpoint +from .device_components import CabledObjectModel, PathEndpoint __all__ = ( 'PowerFeed', @@ -72,7 +72,7 @@ class PowerPanel(NetBoxModel): ) -class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, CabledObjectModel): """ An electrical circuit delivered from a PowerPanel. """ @@ -131,10 +131,10 @@ class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): blank=True ) - clone_fields = [ + clone_fields = ( 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'available_power', - ] + 'max_utilization', + ) class Meta: ordering = ['power_panel', 'name'] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index d0600e987..22fca8cf6 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,4 +1,4 @@ -from collections import OrderedDict +import decimal from django.apps import apps from django.contrib.auth.models import User @@ -14,11 +14,10 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from netbox.config import get_config from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField -from utilities.utils import array_to_string +from utilities.utils import array_to_string, drange from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed @@ -185,10 +184,10 @@ class Rack(NetBoxModel): to='extras.ImageAttachment' ) - clone_fields = [ + clone_fields = ( 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - ] + ) class Meta: ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique @@ -247,10 +246,12 @@ class Rack(NetBoxModel): @property def units(self): + """ + Return a list of unit numbers, top to bottom. + """ if self.desc_units: - return range(1, self.u_height + 1) - else: - return reversed(range(1, self.u_height + 1)) + return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5) + return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -268,12 +269,12 @@ class Rack(NetBoxModel): reference to the device. When False, only the bottom most unit for a device is included and that unit contains a height attribute for the device """ - - elevation = OrderedDict() + elevation = {} for u in self.units: + u_name = f'U{u}'.split('.')[0] if not u % 1 else f'U{u}' elevation[u] = { 'id': u, - 'name': f'U{u}', + 'name': u_name, 'face': face, 'device': None, 'occupied': False @@ -283,7 +284,7 @@ class Rack(NetBoxModel): if self.pk: # Retrieve all devices installed within the rack - queryset = Device.objects.prefetch_related( + devices = Device.objects.prefetch_related( 'device_type', 'device_type__manufacturer', 'device_role' @@ -304,9 +305,9 @@ class Rack(NetBoxModel): if user is not None: permitted_device_ids = self.devices.restrict(user, 'view').values_list('pk', flat=True) - for device in queryset: + for device in devices: if expand_devices: - for u in range(device.position, device.position + device.device_type.u_height): + for u in drange(device.position, device.position + device.device_type.u_height, 0.5): if user is None or device.pk in permitted_device_ids: elevation[u]['device'] = device elevation[u]['occupied'] = True @@ -315,8 +316,6 @@ class Rack(NetBoxModel): elevation[device.position]['device'] = device elevation[device.position]['occupied'] = True elevation[device.position]['height'] = device.device_type.u_height - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) return [u for u in elevation.values()] @@ -336,12 +335,12 @@ class Rack(NetBoxModel): devices = devices.exclude(pk__in=exclude) # Initialize the rack unit skeleton - units = list(range(1, self.u_height + 1)) + units = list(self.units) # Remove units consumed by installed devices for d in devices: if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: - for u in range(d.position, d.position + d.device_type.u_height): + for u in drange(d.position, d.position + d.device_type.u_height, 0.5): try: units.remove(u) except ValueError: @@ -351,7 +350,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(range(u, u + u_height)).issubset(units): + if set(drange(u, u + u_height, 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -361,9 +360,9 @@ class Rack(NetBoxModel): Return a dictionary mapping all reserved units within the rack to their reservation. """ reserved_units = {} - for r in self.reservations.all(): - for u in r.units: - reserved_units[u] = r + for reservation in self.reservations.all(): + for u in reservation.units: + reserved_units[u] = reservation return reserved_units def get_elevation_svg( @@ -372,9 +371,11 @@ class Rack(NetBoxModel): user=None, unit_width=None, unit_height=None, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, + margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, - base_url=None + base_url=None, + highlight_params=None ): """ Return an SVG of the rack elevation @@ -386,16 +387,23 @@ class Rack(NetBoxModel): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels + :param margin_width: Width of the rigth-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ - elevation = RackElevationSVG(self, user=user, include_images=include_images, base_url=base_url) - if unit_width is None or unit_height is None: - config = get_config() - unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH - unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + elevation = RackElevationSVG( + self, + unit_width=unit_width, + unit_height=unit_height, + legend_width=legend_width, + margin_width=margin_width, + user=user, + include_images=include_images, + base_url=base_url, + highlight_params=highlight_params + ) - return elevation.render(face, unit_width, unit_height, legend_width) + return elevation.render(face) def get_0u_devices(self): return self.devices.filter(position=0) @@ -406,6 +414,7 @@ class Rack(NetBoxModel): as utilized. """ # Determine unoccupied units + total_units = len(list(self.units)) available_units = self.get_available_units() # Remove reserved units @@ -413,8 +422,8 @@ class Rack(NetBoxModel): if u in available_units: available_units.remove(u) - occupied_unit_count = self.u_height - len(available_units) - percentage = float(occupied_unit_count) / self.u_height * 100 + occupied_unit_count = total_units - len(available_units) + percentage = float(occupied_unit_count) / total_units * 100 return percentage @@ -427,17 +436,17 @@ class Rack(NetBoxModel): if not available_power_total: return 0 - pf_powerports = PowerPort.objects.filter( - _link_peer_type=ContentType.objects.get_for_model(PowerFeed), - _link_peer_id__in=powerfeeds.values_list('id', flat=True) - ) - poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports) - allocated_draw_total = PowerPort.objects.filter( - _link_peer_type=ContentType.objects.get_for_model(PowerOutlet), - _link_peer_id__in=poweroutlets.values_list('id', flat=True) - ).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0 + powerports = [] + for powerfeed in powerfeeds: + powerports.extend([ + peer for peer in powerfeed.link_peers if isinstance(peer, PowerPort) + ]) - return int(allocated_draw_total / available_power_total * 100) + allocated_draw = sum([ + powerport.get_power_draw()['allocated'] for powerport in powerports + ]) + + return int(allocated_draw / available_power_total * 100) class RackReservation(NetBoxModel): diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 70b4e6421..f5c8e6d9d 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -295,10 +295,10 @@ class Site(NetBoxModel): to='extras.ImageAttachment' ) - clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', - ] + clone_fields = ( + 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'physical_address', 'shipping_address', + 'latitude', 'longitude', 'description', + ) class Meta: ordering = ('_name',) @@ -341,6 +341,11 @@ class Location(NestedGroupModel): null=True, db_index=True ) + status = models.CharField( + max_length=50, + choices=LocationStatusChoices, + default=LocationStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -367,7 +372,7 @@ class Location(NestedGroupModel): to='extras.ImageAttachment' ) - clone_fields = ['site', 'parent', 'tenant', 'description'] + clone_fields = ('site', 'parent', 'status', 'tenant', 'description') class Meta: ordering = ['site', 'name'] @@ -413,6 +418,9 @@ class Location(NestedGroupModel): def get_absolute_url(self): return reverse('dcim:location', args=[self.pk]) + def get_status_color(self): + return LocationStatusChoices.colors.get(self.status) + def clean(self): super().clean() diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 79e9c6687..b990daf1a 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,11 +1,11 @@ import logging -from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete from django.dispatch import receiver -from .choices import LinkStatusChoices -from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .choices import CableEndChoices, LinkStatusChoices +from .models import Cable, CablePath, CableTermination, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis +from .models.cables import trace_paths from .utils import create_cablepath, rebuild_paths @@ -68,73 +68,58 @@ def clear_virtualchassis_members(instance, **kwargs): # Cables # - -@receiver(post_save, sender=Cable) +@receiver(trace_paths, sender=Cable) def update_connected_endpoints(instance, created, raw=False, **kwargs): """ - When a Cable is saved, check for and update its two connected endpoints + When a Cable is saved with new terminations, retrace any affected cable paths. """ logger = logging.getLogger('netbox.dcim.cable') if raw: logger.debug(f"Skipping endpoint updates for imported cable {instance}") return - # Cache the Cable on its two termination points - if instance.termination_a.cable != instance: - logger.debug(f"Updating termination A for cable {instance}") - instance.termination_a.cable = instance - instance.termination_a._link_peer = instance.termination_b - instance.termination_a.save() - if instance.termination_b.cable != instance: - logger.debug(f"Updating termination B for cable {instance}") - instance.termination_b.cable = instance - instance.termination_b._link_peer = instance.termination_a - instance.termination_b.save() - - # Create/update cable paths - if created: - for termination in (instance.termination_a, instance.termination_b): - if isinstance(termination, PathEndpoint): - create_cablepath(termination) + # Update cable paths if new terminations have been set + if instance._terminations_modified: + a_terminations = [] + b_terminations = [] + for t in instance.terminations.all(): + if t.cable_end == CableEndChoices.SIDE_A: + a_terminations.append(t.termination) else: - rebuild_paths(termination) + b_terminations.append(t.termination) + for nodes in [a_terminations, b_terminations]: + # Examine type of first termination to determine object type (all must be the same) + if not nodes: + continue + if isinstance(nodes[0], PathEndpoint): + create_cablepath(nodes) + else: + rebuild_paths(nodes) + + # Update status of CablePaths if Cable status has been changed elif instance.status != instance._orig_status: - # We currently don't support modifying either termination of an existing Cable. (This - # may change in the future.) However, we do need to capture status changes and update - # any CablePaths accordingly. if instance.status != LinkStatusChoices.STATUS_CONNECTED: - CablePath.objects.filter(path__contains=instance).update(is_active=False) + CablePath.objects.filter(_nodes__contains=instance).update(is_active=False) else: - rebuild_paths(instance) + rebuild_paths([instance]) @receiver(post_delete, sender=Cable) +def retrace_cable_paths(instance, **kwargs): + """ + When a Cable is deleted, check for and update its connected endpoints + """ + for cablepath in CablePath.objects.filter(_nodes__contains=instance): + cablepath.retrace() + + +@receiver(post_delete, sender=CableTermination) def nullify_connected_endpoints(instance, **kwargs): """ - When a Cable is deleted, check for and update its two connected endpoints + Disassociate the Cable from the termination object, and retrace any affected CablePaths. """ - logger = logging.getLogger('netbox.dcim.cable') + model = instance.termination_type.model_class() + model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='') - # Disassociate the Cable from its termination points - if instance.termination_a is not None: - logger.debug(f"Nullifying termination A for cable {instance}") - model = instance.termination_a._meta.model - model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None) - if instance.termination_b is not None: - logger.debug(f"Nullifying termination B for cable {instance}") - model = instance.termination_b._meta.model - model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None) - - # Delete and retrace any dependent cable paths - for cablepath in CablePath.objects.filter(path__contains=instance): - cp = CablePath.from_origin(cablepath.origin) - if cp: - CablePath.objects.filter(pk=cablepath.pk).update( - path=cp.path, - destination_type=ContentType.objects.get_for_model(cp.destination) if cp.destination else None, - destination_id=cp.destination.pk if cp.destination else None, - is_active=cp.is_active, - is_split=cp.is_split - ) - else: - cablepath.delete() + for cablepath in CablePath.objects.filter(_nodes__contains=instance.cable): + cablepath.retrace() diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py deleted file mode 100644 index 4c093a312..000000000 --- a/netbox/dcim/svg.py +++ /dev/null @@ -1,599 +0,0 @@ -import svgwrite -from svgwrite.container import Group, Hyperlink -from svgwrite.shapes import Line, Rect -from svgwrite.text import Text - -from django.conf import settings -from django.urls import reverse -from django.utils.http import urlencode - -from utilities.utils import foreground_color -from .choices import DeviceFaceChoices -from .constants import RACK_ELEVATION_BORDER_WIDTH - - -__all__ = ( - 'CableTraceSVG', - 'RackElevationSVG', -) - - -def get_device_name(device): - if device.virtual_chassis: - return f'{device.virtual_chassis.name}:{device.vc_position}' - elif device.name: - return device.name - else: - return str(device.device_type) - - -class RackElevationSVG: - """ - Use this class to render a rack elevation as an SVG image. - - :param rack: A NetBox Rack instance - :param user: User instance. If specified, only devices viewable by this user will be fully displayed. - :param include_images: If true, the SVG document will embed front/rear device face images, where available - :param base_url: Base URL for links within the SVG document. If none, links will be relative. - """ - def __init__(self, rack, user=None, include_images=True, base_url=None): - self.rack = rack - self.include_images = include_images - if base_url is not None: - self.base_url = base_url.rstrip('/') - else: - self.base_url = '' - - # Determine the subset of devices within this rack that are viewable by the user, if any - permitted_devices = self.rack.devices - if user is not None: - permitted_devices = permitted_devices.restrict(user, 'view') - self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) - - @staticmethod - def _get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - device.device_type.u_height, - device.asset_tag or '', - device.serial or '' - ) - - @staticmethod - def _add_gradient(drawing, id_, color): - gradient = drawing.linearGradient( - start=(0, 0), - end=(0, 25), - spreadMethod='repeat', - id_=id_, - gradientTransform='rotate(45, 0, 0)', - gradientUnits='userSpaceOnUse' - ) - gradient.add_stop_color(offset='0%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color='#f7f7f7') - gradient.add_stop_color(offset='50%', color=color) - gradient.add_stop_color(offset='100%', color=color) - drawing.defs.add(gradient) - - @staticmethod - def _setup_drawing(width, height): - drawing = svgwrite.Drawing(size=(width, height)) - - # add the stylesheet - with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file: - drawing.defs.add(drawing.style(css_file.read())) - - # add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff') - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') - - return drawing - - def _draw_device_front(self, drawing, device, start, end, text): - name = get_device_name(device) - if device.devicebay_count: - name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) - - color = device.device_role.color - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot')) - hex_color = '#{}'.format(foreground_color(color)) - link.add(drawing.text(str(name), insert=text, fill=hex_color)) - - # Embed front device type image if one exists - if self.include_images and device.device_type.front_image: - url = device.device_type.front_image.url - # Convert any relative URLs to absolute - if url.startswith('/'): - url = '{}{}'.format(self.base_url, url) - image = drawing.image( - href=url, - insert=start, - size=end, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(str(name), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) - - def _draw_device_rear(self, drawing, device, start, end, text): - link = drawing.add( - drawing.a( - href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})), - target='_top', - fill='black' - ) - ) - link.set_desc(self._get_device_description(device)) - link.add(drawing.rect(start, end, class_="slot blocked")) - link.add(drawing.text(get_device_name(device), insert=text)) - - # Embed rear device type image if one exists - if self.include_images and device.device_type.rear_image: - url = device.device_type.rear_image.url - # Convert any relative URLs to absolute - if url.startswith('/'): - url = '{}{}'.format(self.base_url, url) - image = drawing.image( - href=url, - insert=start, - size=end, - class_='device-image' - ) - image.fit(scale='slice') - link.add(image) - link.add(drawing.text(get_device_name(device), insert=text, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label')) - - def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation): - link_url = '{}{}?{}'.format( - self.base_url, - reverse('dcim:device_add'), - urlencode({ - 'site': rack.site.pk, - 'location': rack.location.pk if rack.location else '', - 'rack': rack.pk, - 'face': face_id, - 'position': id_ - }) - ) - link = drawing.add( - drawing.a(href=link_url, target='_top') - ) - if reservation: - link.set_desc('{} — {} · {}'.format( - reservation.description, reservation.user, reservation.created - )) - link.add(drawing.rect(start, end, class_=class_)) - link.add(drawing.text("add device", insert=text, class_='add-device')) - - def merge_elevations(self, face): - elevation = self.rack.get_rack_units(face=face, expand_devices=False) - if face == DeviceFaceChoices.FACE_REAR: - other_face = DeviceFaceChoices.FACE_FRONT - else: - other_face = DeviceFaceChoices.FACE_REAR - other = self.rack.get_rack_units(face=other_face) - - unit_cursor = 0 - for u in elevation: - o = other[unit_cursor] - if not u['device'] and o['device'] and o['device'].device_type.is_full_depth: - u['device'] = o['device'] - u['height'] = 1 - unit_cursor += u.get('height', 1) - - return elevation - - def render(self, face, unit_width, unit_height, legend_width): - """ - Return an SVG document representing a rack elevation. - """ - drawing = self._setup_drawing( - unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2, - unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 - ) - reserved_units = self.rack.get_reserved_units() - - unit_cursor = 0 - for ru in range(0, self.rack.u_height): - start_y = ru * unit_height - position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) - unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru - drawing.add( - drawing.text(str(unit), position_coordinates, class_="unit") - ) - - for unit in self.merge_elevations(face): - - # Loop through all units in the elevation - device = unit['device'] - height = unit.get('height', 1) - - # Setup drawing coordinates - x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH - y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH - end_y = unit_height * height - start_cordinates = (x_offset, y_offset) - end_cordinates = (unit_width, end_y) - text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2) - - # Draw the device - if device and device.face == face and device.pk in self.permitted_device_ids: - self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids: - self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) - elif device: - # Devices which the user does not have permission to view are rendered only as unavailable space - drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked')) - else: - # Draw shallow devices, reservations, or empty units - class_ = 'slot' - reservation = reserved_units.get(unit["id"]) - if device: - class_ += ' occupied' - if reservation: - class_ += ' reserved' - self._draw_empty( - drawing, - self.rack, - start_cordinates, - end_cordinates, - text_cordinates, - unit["id"], - face, - class_, - reservation - ) - - unit_cursor += height - - # Wrap the drawing with a border - border_width = RACK_ELEVATION_BORDER_WIDTH - border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 - frame = drawing.rect( - insert=(legend_width + border_offset, border_offset), - size=(unit_width + border_width, self.rack.u_height * unit_height + border_width), - class_='rack' - ) - drawing.add(frame) - - return drawing - - -OFFSET = 0.5 -PADDING = 10 -LINE_HEIGHT = 20 - - -class CableTraceSVG: - """ - Generate a graphical representation of a CablePath in SVG format. - - :param origin: The originating termination - :param width: Width of the generated image (in pixels) - :param base_url: Base URL for links within the SVG document. If none, links will be relative. - """ - def __init__(self, origin, width=400, base_url=None): - self.origin = origin - self.width = width - self.base_url = base_url.rstrip('/') if base_url is not None else '' - - # Establish a cursor to track position on the y axis - # Center edges on pixels to render sharp borders - self.cursor = OFFSET - - @property - def center(self): - return self.width / 2 - - @classmethod - def _get_labels(cls, instance): - """ - Return a list of text labels for the given instance based on model type. - """ - labels = [str(instance)] - if instance._meta.model_name == 'device': - labels.append(f'{instance.device_type.manufacturer} {instance.device_type}') - location_label = f'{instance.site}' - if instance.location: - location_label += f' / {instance.location}' - if instance.rack: - location_label += f' / {instance.rack}' - labels.append(location_label) - elif instance._meta.model_name == 'circuit': - labels[0] = f'Circuit {instance}' - labels.append(instance.provider) - elif instance._meta.model_name == 'circuittermination': - if instance.xconnect_id: - labels.append(f'{instance.xconnect_id}') - elif instance._meta.model_name == 'providernetwork': - labels.append(instance.provider) - - return labels - - @classmethod - def _get_color(cls, instance): - """ - Return the appropriate fill color for an object within a cable path. - """ - if hasattr(instance, 'parent_object'): - # Termination - return 'f0f0f0' - if hasattr(instance, 'device_role'): - # Device - return instance.device_role.color - else: - # Other parent object - return 'e0e0e0' - - def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10): - """ - Return an SVG Link element containing a Rect and one or more text labels representing a - parent object or cable termination point. - - :param width: Box width - :param color: Box fill color - :param url: Hyperlink URL - :param labels: Iterable of text labels - :param y_indent: Vertical indent (for overlapping other boxes) (default: 0) - :param padding_multiplier: Add extra vertical padding (default: 1) - :param radius: Box corner radius (default: 10) - """ - self.cursor -= y_indent - - # Create a hyperlink - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') - - # Add the box - position = ( - OFFSET + (self.width - width) / 2, - self.cursor - ) - height = PADDING * padding_multiplier \ - + LINE_HEIGHT * len(labels) \ - + PADDING * padding_multiplier - box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}') - link.add(box) - self.cursor += PADDING * padding_multiplier - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center, self.cursor - LINE_HEIGHT / 2) - text_color = f'#{foreground_color(color, dark="303030")}' - text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) - link.add(text) - - self.cursor += PADDING * padding_multiplier - - return link - - def _draw_cable(self, color, url, labels): - """ - Return an SVG group containing a line element and text labels representing a Cable. - - :param color: Cable (line) color - :param url: Hyperlink URL - :param labels: Iterable of text labels - """ - group = Group(class_='connector') - - # Draw a "shadow" line to give the cable a border - start = (OFFSET + self.center, self.cursor) - height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - end = (start[0], start[1] + height) - cable_shadow = Line(start=start, end=end, class_='cable-shadow') - group.add(cable_shadow) - - # Draw the cable - cable = Line(start=start, end=end, style=f'stroke: #{color}') - group.add(cable) - - self.cursor += PADDING * 2 - - # Add link - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) - text = Text(label, insert=text_coords, class_='bold' if not i else []) - link.add(text) - - group.add(link) - self.cursor += PADDING * 2 - - return group - - def _draw_wirelesslink(self, url, labels): - """ - Draw a line with labels representing a WirelessLink. - - :param url: Hyperlink URL - :param labels: Iterable of text labels - """ - group = Group(class_='connector') - - # Draw the wireless link - start = (OFFSET + self.center, self.cursor) - height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 - end = (start[0], start[1] + height) - line = Line(start=start, end=end, class_='wireless-link') - group.add(line) - - self.cursor += PADDING * 2 - - # Add link - link = Hyperlink(href=f'{self.base_url}{url}', target='_blank') - - # Add text label(s) - for i, label in enumerate(labels): - self.cursor += LINE_HEIGHT - text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) - text = Text(label, insert=text_coords, class_='bold' if not i else []) - link.add(text) - - group.add(link) - self.cursor += PADDING * 2 - - return group - - def _draw_attachment(self): - """ - Return an SVG group containing a line element and "Attachment" label. - """ - group = Group(class_='connector') - - # Draw attachment (line) - start = (OFFSET + self.center, OFFSET + self.cursor) - height = PADDING * 2 + LINE_HEIGHT + PADDING * 2 - end = (start[0], start[1] + height) - line = Line(start=start, end=end, class_='attachment') - group.add(line) - self.cursor += PADDING * 4 - - return group - - def render(self): - """ - Return an SVG document representing a cable trace. - """ - from dcim.models import Cable - from wireless.models import WirelessLink - - traced_path = self.origin.trace() - - # Prep elements list - parent_objects = [] - terminations = [] - connectors = [] - - # Iterate through each (term, cable, term) segment in the path - for i, segment in enumerate(traced_path): - near_end, connector, far_end = segment - - # Near end parent - if i == 0: - # If this is the first segment, draw the originating termination's parent object - parent_object = self._draw_box( - width=self.width, - color=self._get_color(near_end.parent_object), - url=near_end.parent_object.get_absolute_url(), - labels=self._get_labels(near_end.parent_object), - padding_multiplier=2 - ) - parent_objects.append(parent_object) - - # Near end termination - if near_end is not None: - termination = self._draw_box( - width=self.width * .8, - color=self._get_color(near_end), - url=near_end.get_absolute_url(), - labels=self._get_labels(near_end), - y_indent=PADDING, - radius=5 - ) - terminations.append(termination) - - # Connector (a Cable or WirelessLink) - if connector is not None: - - # Cable - if type(connector) is Cable: - connector_labels = [ - f'Cable {connector}', - connector.get_status_display() - ] - if connector.type: - connector_labels.append(connector.get_type_display()) - if connector.length and connector.length_unit: - connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}') - cable = self._draw_cable( - color=connector.color or '000000', - url=connector.get_absolute_url(), - labels=connector_labels - ) - connectors.append(cable) - - # WirelessLink - elif type(connector) is WirelessLink: - connector_labels = [ - f'Wireless link {connector}', - connector.get_status_display() - ] - if connector.ssid: - connector_labels.append(connector.ssid) - wirelesslink = self._draw_wirelesslink( - url=connector.get_absolute_url(), - labels=connector_labels - ) - connectors.append(wirelesslink) - - # Far end termination - termination = self._draw_box( - width=self.width * .8, - color=self._get_color(far_end), - url=far_end.get_absolute_url(), - labels=self._get_labels(far_end), - radius=5 - ) - terminations.append(termination) - - # Far end parent - parent_object = self._draw_box( - width=self.width, - color=self._get_color(far_end.parent_object), - url=far_end.parent_object.get_absolute_url(), - labels=self._get_labels(far_end.parent_object), - y_indent=PADDING, - padding_multiplier=2 - ) - parent_objects.append(parent_object) - - elif far_end: - - # Attachment - attachment = self._draw_attachment() - connectors.append(attachment) - - # ProviderNetwork - parent_object = self._draw_box( - width=self.width, - color=self._get_color(far_end), - url=far_end.get_absolute_url(), - labels=self._get_labels(far_end), - padding_multiplier=2 - ) - parent_objects.append(parent_object) - - # Determine drawing size - self.drawing = svgwrite.Drawing( - size=(self.width, self.cursor + 2) - ) - - # Attach CSS stylesheet - with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file: - self.drawing.defs.add(self.drawing.style(css_file.read())) - - # Add elements to the drawing in order of depth (Z axis) - for element in connectors + parent_objects + terminations: - self.drawing.add(element) - - return self.drawing diff --git a/netbox/dcim/svg/__init__.py b/netbox/dcim/svg/__init__.py new file mode 100644 index 000000000..21e27d495 --- /dev/null +++ b/netbox/dcim/svg/__init__.py @@ -0,0 +1,2 @@ +from .cables import * +from .racks import * diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py new file mode 100644 index 000000000..26d16fafe --- /dev/null +++ b/netbox/dcim/svg/cables.py @@ -0,0 +1,399 @@ +import svgwrite +from svgwrite.container import Group, Hyperlink +from svgwrite.shapes import Line, Polyline, Rect +from svgwrite.text import Text + +from django.conf import settings + +from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH +from utilities.utils import foreground_color + + +__all__ = ( + 'CableTraceSVG', +) + + +OFFSET = 0.5 +PADDING = 10 +LINE_HEIGHT = 20 +FANOUT_HEIGHT = 35 +FANOUT_LEG_HEIGHT = 15 + + +class Node(Hyperlink): + """ + Create a node to be represented in the SVG document as a rectangular box with a hyperlink. + + Arguments: + position: (x, y) coordinates of the box's top left corner + width: Box width + url: Hyperlink URL + color: Box fill color (RRGGBB format) + labels: An iterable of text strings. Each label will render on a new line within the box. + radius: Box corner radius, for rounded corners (default: 10) + """ + + def __init__(self, position, width, url, color, labels, radius=10, **extra): + super(Node, self).__init__(href=url, target='_blank', **extra) + + x, y = position + + # Add the box + dimensions = (width - 2, PADDING + LINE_HEIGHT * len(labels) + PADDING) + box = Rect((x + OFFSET, y), dimensions, rx=radius, class_='parent-object', style=f'fill: #{color}') + self.add(box) + + cursor = y + PADDING + + # Add text label(s) + for i, label in enumerate(labels): + cursor += LINE_HEIGHT + text_coords = (x + width / 2, cursor - LINE_HEIGHT / 2) + text_color = f'#{foreground_color(color, dark="303030")}' + text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else []) + self.add(text) + + @property + def box(self): + return self.elements[0] if self.elements else None + + @property + def top_center(self): + return self.box['x'] + self.box['width'] / 2, self.box['y'] + + @property + def bottom_center(self): + return self.box['x'] + self.box['width'] / 2, self.box['y'] + self.box['height'] + + +class Connector(Group): + """ + Return an SVG group containing a line element and text labels representing a Cable. + + Arguments: + color: Cable (line) color + url: Hyperlink URL + labels: Iterable of text labels + """ + + def __init__(self, start, url, color, labels=[], **extra): + super().__init__(class_='connector', **extra) + + self.start = start + self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 + self.end = (start[0], start[1] + self.height) + self.color = color or '000000' + + # Draw a "shadow" line to give the cable a border + cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') + self.add(cable_shadow) + + # Draw the cable + cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') + self.add(cable) + + # Add link + link = Hyperlink(href=url, target='_blank') + + # Add text label(s) + cursor = start[1] + cursor += PADDING * 2 + for i, label in enumerate(labels): + cursor += LINE_HEIGHT + text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2) + text = Text(label, insert=text_coords, class_='bold' if not i else []) + link.add(text) + + self.add(link) + + +class CableTraceSVG: + """ + Generate a graphical representation of a CablePath in SVG format. + + :param origin: The originating termination + :param width: Width of the generated image (in pixels) + :param base_url: Base URL for links within the SVG document. If none, links will be relative. + """ + def __init__(self, origin, width=CABLE_TRACE_SVG_DEFAULT_WIDTH, base_url=None): + self.origin = origin + self.width = width + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Establish a cursor to track position on the y axis + # Center edges on pixels to render sharp borders + self.cursor = OFFSET + + # Prep elements lists + self.parent_objects = [] + self.terminations = [] + self.connectors = [] + + @property + def center(self): + return self.width / 2 + + @classmethod + def _get_labels(cls, instance): + """ + Return a list of text labels for the given instance based on model type. + """ + labels = [str(instance)] + if instance._meta.model_name == 'device': + labels.append(f'{instance.device_type.manufacturer} {instance.device_type}') + location_label = f'{instance.site}' + if instance.location: + location_label += f' / {instance.location}' + if instance.rack: + location_label += f' / {instance.rack}' + labels.append(location_label) + elif instance._meta.model_name == 'circuit': + labels[0] = f'Circuit {instance}' + labels.append(instance.provider) + elif instance._meta.model_name == 'circuittermination': + if instance.xconnect_id: + labels.append(f'{instance.xconnect_id}') + elif instance._meta.model_name == 'providernetwork': + labels.append(instance.provider) + + return labels + + @classmethod + def _get_color(cls, instance): + """ + Return the appropriate fill color for an object within a cable path. + """ + if hasattr(instance, 'parent_object'): + # Termination + return 'f0f0f0' + if hasattr(instance, 'device_role'): + # Device + return instance.device_role.color + else: + # Other parent object + return 'e0e0e0' + + def draw_parent_objects(self, obj_list): + """ + Draw a set of parent objects. + """ + width = self.width / len(obj_list) + for i, obj in enumerate(obj_list): + node = Node( + position=(i * width, self.cursor), + width=width, + url=f'{self.base_url}{obj.get_absolute_url()}', + color=self._get_color(obj), + labels=self._get_labels(obj) + ) + self.parent_objects.append(node) + if i + 1 == len(obj_list): + self.cursor += node.box['height'] + + def draw_terminations(self, terminations): + """ + Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable. + """ + nodes = [] + nodes_height = 0 + width = self.width / len(terminations) + + for i, term in enumerate(terminations): + node = Node( + position=(i * width, self.cursor), + width=width, + url=f'{self.base_url}{term.get_absolute_url()}', + color=self._get_color(term), + labels=self._get_labels(term), + radius=5 + ) + nodes_height = max(nodes_height, node.box['height']) + nodes.append(node) + + self.cursor += nodes_height + self.terminations.extend(nodes) + + return nodes + + def draw_fanin(self, node, connector): + points = ( + node.bottom_center, + (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT), + connector.start, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{connector.color}'), + )) + + def draw_fanout(self, node, connector): + points = ( + connector.end, + (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT), + node.top_center, + ) + self.connectors.extend(( + Polyline(points=points, class_='cable-shadow'), + Polyline(points=points, style=f'stroke: #{connector.color}'), + )) + + def draw_cable(self, cable): + labels = [ + f'Cable {cable}', + cable.get_status_display() + ] + if cable.type: + labels.append(cable.get_type_display()) + if cable.length and cable.length_unit: + labels.append(f'{cable.length} {cable.get_length_unit_display()}') + connector = Connector( + start=(self.center + OFFSET, self.cursor), + color=cable.color or '000000', + url=f'{self.base_url}{cable.get_absolute_url()}', + labels=labels + ) + + self.cursor += connector.height + + return connector + + def draw_wirelesslink(self, wirelesslink): + """ + Draw a line with labels representing a WirelessLink. + """ + group = Group(class_='connector') + + labels = [ + f'Wireless link {wirelesslink}', + wirelesslink.get_status_display() + ] + if wirelesslink.ssid: + labels.append(wirelesslink.ssid) + + # Draw the wireless link + start = (OFFSET + self.center, self.cursor) + height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 + end = (start[0], start[1] + height) + line = Line(start=start, end=end, class_='wireless-link') + group.add(line) + + self.cursor += PADDING * 2 + + # Add link + link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank') + + # Add text label(s) + for i, label in enumerate(labels): + self.cursor += LINE_HEIGHT + text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2) + text = Text(label, insert=text_coords, class_='bold' if not i else []) + link.add(text) + + group.add(link) + self.cursor += PADDING * 2 + + return group + + def draw_attachment(self): + """ + Return an SVG group containing a line element and "Attachment" label. + """ + group = Group(class_='connector') + + # Draw attachment (line) + start = (OFFSET + self.center, OFFSET + self.cursor) + height = PADDING * 2 + LINE_HEIGHT + PADDING * 2 + end = (start[0], start[1] + height) + line = Line(start=start, end=end, class_='attachment') + group.add(line) + self.cursor += PADDING * 4 + + return group + + def render(self): + """ + Return an SVG document representing a cable trace. + """ + from dcim.models import Cable + from wireless.models import WirelessLink + + traced_path = self.origin.trace() + + # Iterate through each (terms, cable, terms) segment in the path + for i, segment in enumerate(traced_path): + near_ends, links, far_ends = segment + + # Near end parent + if i == 0: + # If this is the first segment, draw the originating termination's parent object + self.draw_parent_objects(set(end.parent_object for end in near_ends)) + + # Near end termination(s) + terminations = self.draw_terminations(near_ends) + + # Connector (a Cable or WirelessLink) + if links: + link = links[0] # Remove Cable from list + + # Cable + if type(link) is Cable: + + # Account for fan-ins height + if len(near_ends) > 1: + self.cursor += FANOUT_HEIGHT + + cable = self.draw_cable(link) + self.connectors.append(cable) + + # Draw fan-ins + if len(near_ends) > 1: + for term in terminations: + self.draw_fanin(term, cable) + + # WirelessLink + elif type(link) is WirelessLink: + wirelesslink = self.draw_wirelesslink(link) + self.connectors.append(wirelesslink) + + # Far end termination(s) + if len(far_ends) > 1: + self.cursor += FANOUT_HEIGHT + terminations = self.draw_terminations(far_ends) + for term in terminations: + self.draw_fanout(term, cable) + elif far_ends: + self.draw_terminations(far_ends) + else: + # Link is not connected to anything + break + + # Far end parent + parent_objects = set(end.parent_object for end in far_ends) + self.draw_parent_objects(parent_objects) + + # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with + # a CircuitTermination) + elif far_ends: + + # Attachment + attachment = self.draw_attachment() + self.connectors.append(attachment) + + # Object + self.draw_parent_objects(far_ends) + + # Determine drawing size + self.drawing = svgwrite.Drawing( + size=(self.width, self.cursor + 2) + ) + + # Attach CSS stylesheet + with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file: + self.drawing.defs.add(self.drawing.style(css_file.read())) + + # Add elements to the drawing in order of depth (Z axis) + for element in self.connectors + self.parent_objects + self.terminations: + self.drawing.add(element) + + return self.drawing diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py new file mode 100644 index 000000000..28527498f --- /dev/null +++ b/netbox/dcim/svg/racks.py @@ -0,0 +1,323 @@ +import decimal +import svgwrite +from svgwrite.container import Hyperlink +from svgwrite.image import Image +from svgwrite.gradients import LinearGradient +from svgwrite.shapes import Rect +from svgwrite.text import Text + +from django.conf import settings +from django.core.exceptions import FieldError +from django.db.models import Q +from django.urls import reverse +from django.utils.http import urlencode + +from netbox.config import get_config +from utilities.utils import foreground_color, array_to_ranges +from dcim.constants import RACK_ELEVATION_BORDER_WIDTH + + +__all__ = ( + 'RackElevationSVG', +) + + +def get_device_name(device): + if device.virtual_chassis: + name = f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + name = device.name + else: + name = str(device.device_type) + if device.devicebay_count: + name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) + + return name + + +def get_device_description(device): + return '{} ({}) — {} {} ({}U) {} {}'.format( + device.name, + device.device_role, + device.device_type.manufacturer.name, + device.device_type.model, + device.device_type.u_height, + device.asset_tag or '', + device.serial or '' + ) + + +class RackElevationSVG: + """ + Use this class to render a rack elevation as an SVG image. + + :param rack: A NetBox Rack instance + :param unit_width: Rendered unit width, in pixels + :param unit_height: Rendered unit height, in pixels + :param legend_width: Legend width, in pixels (where the unit labels appear) + :param margin_width: Margin width, in pixels (where reservations appear) + :param user: User instance. If specified, only devices viewable by this user will be fully displayed. + :param include_images: If true, the SVG document will embed front/rear device face images, where available + :param base_url: Base URL for links within the SVG document. If none, links will be relative. + :param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight + """ + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, + include_images=True, base_url=None, highlight_params=None): + self.rack = rack + self.include_images = include_images + self.base_url = base_url.rstrip('/') if base_url is not None else '' + + # Set drawing dimensions + config = get_config() + self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH + self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT + self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH + + # Determine the subset of devices within this rack that are viewable by the user, if any + permitted_devices = self.rack.devices + if user is not None: + permitted_devices = permitted_devices.restrict(user, 'view') + self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + + # Determine device(s) to highlight within the elevation (if any) + self.highlight_devices = [] + if highlight_params: + q = Q() + for k, v in highlight_params: + q |= Q(**{k: v}) + try: + self.highlight_devices = permitted_devices.filter(q) + except FieldError: + pass + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = LinearGradient( + start=(0, 0), + end=(0, 25), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(45, 0, 0)', + gradientUnits='userSpaceOnUse' + ) + gradient.add_stop_color(offset='0%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color='#f7f7f7') + gradient.add_stop_color(offset='50%', color=color) + gradient.add_stop_color(offset='100%', color=color) + + drawing.defs.add(gradient) + + def _setup_drawing(self): + width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2 + height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 + drawing = svgwrite.Drawing(size=(width, height)) + + # Add the stylesheet + with open(f'{settings.STATIC_ROOT}/rack_elevation.css') as css_file: + drawing.defs.add(drawing.style(css_file.read())) + + # Add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') + RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') + RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + + return drawing + + def _get_device_coords(self, position, height): + """ + Return the X, Y coordinates of the top left corner for a device in the specified rack unit. + """ + x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH + y = RACK_ELEVATION_BORDER_WIDTH + if self.rack.desc_units: + y += int((position - 1) * self.unit_height) + else: + y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + + return x, y + + def _draw_device(self, device, coords, size, color=None, image=None): + name = get_device_name(device) + description = get_device_description(device) + text_color = f'#{foreground_color(color)}' if color else '#000000' + text_coords = ( + coords[0] + size[0] / 2, + coords[1] + size[1] / 2 + ) + + # Determine whether highlighting is in use, and if so, whether to shade this device + is_shaded = self.highlight_devices and device not in self.highlight_devices + css_extra = ' shaded' if is_shaded else '' + + # Create hyperlink element + link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank') + link.set_desc(description) + + # Add rect element to hyperlink + if color: + link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) + else: + link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) + link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) + + # Embed device type image if provided + if self.include_images and image: + url = f'{self.base_url}{image.url}' if image.url.startswith('/') else image.url + image = Image( + href=url, + insert=coords, + size=size, + class_=f'device-image{css_extra}' + ) + image.fit(scale='slice') + link.add(image) + link.add( + Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', + class_=f'device-image-label{css_extra}') + ) + link.add( + Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}') + ) + + self.drawing.add(link) + + def draw_device_front(self, device, coords, size): + """ + Draw the front (mounted) face of a device. + """ + color = device.device_role.color + image = device.device_type.front_image + self._draw_device(device, coords, size, color=color, image=image) + + def draw_device_rear(self, device, coords, size): + """ + Draw the rear (opposite) face of a device. + """ + image = device.device_type.rear_image + self._draw_device(device, coords, size, image=image) + + def draw_border(self): + """ + Draw a border around the collection of rack units. + """ + border_width = RACK_ELEVATION_BORDER_WIDTH + border_offset = RACK_ELEVATION_BORDER_WIDTH / 2 + frame = Rect( + insert=(self.legend_width + border_offset, border_offset), + size=(self.unit_width + border_width, self.rack.u_height * self.unit_height + border_width), + class_='rack' + ) + self.drawing.add(frame) + + def draw_legend(self): + """ + Draw the rack unit labels along the lefthand side of the elevation. + """ + for ru in range(0, self.rack.u_height): + start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH + position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + self.drawing.add( + Text(str(unit), position_coordinates, class_='unit') + ) + + def draw_margin(self): + """ + Draw any rack reservations in the right-hand margin alongside the rack elevation. + """ + for reservation in self.rack.reservations.all(): + for segment in array_to_ranges(reservation.units): + u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0] + coords = self._get_device_coords(segment[0], u_height) + coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) + size = ( + self.margin_width, + u_height * self.unit_height + ) + link = Hyperlink( + href='{}{}'.format(self.base_url, reservation.get_absolute_url()), + target='_blank' + ) + link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') + link.add( + Rect(coords, size, class_='reservation') + ) + self.drawing.add(link) + + def draw_background(self, face): + """ + Draw the rack unit placeholders which form the "background" of the rack elevation. + """ + x_offset = RACK_ELEVATION_BORDER_WIDTH + self.legend_width + url_string = '{}?{}&position={{}}'.format( + reverse('dcim:device_add'), + urlencode({ + 'site': self.rack.site.pk, + 'location': self.rack.location.pk if self.rack.location else '', + 'rack': self.rack.pk, + 'face': face, + }) + ) + + for ru in range(0, self.rack.u_height): + unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height + text_coords = ( + x_offset + self.unit_width / 2, + y_offset + self.unit_height / 2 + ) + + link = Hyperlink(href=url_string.format(unit), target='_blank') + link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) + link.add(Text('add device', insert=text_coords, class_='add-device')) + + self.drawing.add(link) + + def draw_face(self, face, opposite=False): + """ + Draw any occupied rack units for the specified rack face. + """ + for unit in self.rack.get_rack_units(face=face, expand_devices=False): + + # Loop through all units in the elevation + device = unit['device'] + height = unit.get('height', decimal.Decimal(1.0)) + + device_coords = self._get_device_coords(unit['id'], height) + device_size = ( + self.unit_width, + int(self.unit_height * height) + ) + + # Draw the device + if device and device.pk in self.permitted_device_ids: + if device.face == face and not opposite: + self.draw_device_front(device, device_coords, device_size) + else: + self.draw_device_rear(device, device_coords, device_size) + + elif device: + # Devices which the user does not have permission to view are rendered only as unavailable space + self.drawing.add(Rect(device_coords, device_size, class_='blocked')) + + def render(self, face): + """ + Return an SVG document representing a rack elevation. + """ + + # Initialize the drawing + self.drawing = self._setup_drawing() + + # Draw the empty rack, legend, and margin + self.draw_legend() + self.draw_background(face) + self.draw_margin() + + # Draw the rack face + self.draw_face(face) + + # Draw the rack border last + self.draw_border() + + return self.drawing diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 4fd1b3266..e5410e42a 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -1,56 +1,109 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from django.utils.safestring import mark_safe from dcim.models import Cable from netbox.tables import NetBoxTable, columns from tenancy.tables import TenancyColumnsMixin -from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT +from .template_code import CABLE_LENGTH __all__ = ( 'CableTable', ) +class CableTerminationsColumn(tables.Column): + """ + Args: + cable_end: Which side of the cable to report on (A or B) + attr: The CableTermination attribute to return for each instance (returns the termination object by default) + """ + def __init__(self, cable_end, attr='termination', *args, **kwargs): + self.cable_end = cable_end + self.attr = attr + super().__init__(accessor=Accessor('terminations'), *args, **kwargs) + + def _get_terminations(self, manager): + terminations = set() + for cabletermination in manager.all(): + if cabletermination.cable_end == self.cable_end: + if termination := getattr(cabletermination, self.attr, None): + terminations.add(termination) + + return terminations + + def render(self, value): + links = [ + f'{term}' for term in self._get_terminations(value) + ] + return mark_safe('
'.join(links) or '—') + + def value(self, value): + return ','.join([str(t) for t in self._get_terminations(value)]) + + # # Cables # class CableTable(TenancyColumnsMixin, NetBoxTable): - termination_a_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_a'), + a_terminations = CableTerminationsColumn( + cable_end='A', orderable=False, - verbose_name='Side A' - ) - rack_a = tables.Column( - accessor=Accessor('termination_a__device__rack'), - orderable=False, - linkify=True, - verbose_name='Rack A' - ) - termination_a = tables.Column( - accessor=Accessor('termination_a'), - orderable=False, - linkify=True, verbose_name='Termination A' ) - termination_b_parent = tables.TemplateColumn( - template_code=CABLE_TERMINATION_PARENT, - accessor=Accessor('termination_b'), + b_terminations = CableTerminationsColumn( + cable_end='B', orderable=False, - verbose_name='Side B' + verbose_name='Termination B' ) - rack_b = tables.Column( - accessor=Accessor('termination_b__device__rack'), + device_a = CableTerminationsColumn( + cable_end='A', + attr='_device', + orderable=False, + verbose_name='Device A' + ) + device_b = CableTerminationsColumn( + cable_end='B', + attr='_device', + orderable=False, + verbose_name='Device B' + ) + location_a = CableTerminationsColumn( + cable_end='A', + attr='_location', + orderable=False, + verbose_name='Location A' + ) + location_b = CableTerminationsColumn( + cable_end='B', + attr='_location', + orderable=False, + verbose_name='Location B' + ) + rack_a = CableTerminationsColumn( + cable_end='A', + attr='_rack', + orderable=False, + verbose_name='Rack A' + ) + rack_b = CableTerminationsColumn( + cable_end='B', + attr='_rack', orderable=False, - linkify=True, verbose_name='Rack B' ) - termination_b = tables.Column( - accessor=Accessor('termination_b'), + site_a = CableTerminationsColumn( + cable_end='A', + attr='_site', orderable=False, - linkify=True, - verbose_name='Termination B' + verbose_name='Site A' + ) + site_b = CableTerminationsColumn( + cable_end='B', + attr='_site', + orderable=False, + verbose_name='Site B' ) status = columns.ChoiceFieldColumn() length = columns.TemplateColumn( @@ -65,10 +118,10 @@ class CableTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cable fields = ( - 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', - 'status', 'type', 'tenant', 'tenant_group', 'color', 'length', 'tags', 'created', 'last_updated', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'device_a', 'device_b', 'rack_a', 'rack_b', + 'location_a', 'location_b', 'site_a', 'site_b', 'status', 'type', 'tenant', 'tenant_group', 'color', + 'length', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', + 'pk', 'id', 'label', 'a_terminations', 'b_terminations', 'status', 'type', ) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 710921beb..c1515a15f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -274,17 +274,17 @@ class CableTerminationTable(NetBoxTable): verbose_name='Cable Color' ) link_peer = columns.TemplateColumn( - accessor='_link_peer', + accessor='link_peers', template_code=LINKTERMINATION, orderable=False, - verbose_name='Link Peer' + verbose_name='Link Peers' ) mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): connection = columns.TemplateColumn( - accessor='_path__last_node', + accessor='_path__destinations', template_code=LINKTERMINATION, verbose_name='Connection', orderable=False @@ -518,10 +518,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', - 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', - 'tagged_vlans', 'created', 'last_updated', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', + 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index 2da9daee7..3ed4d8c08 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -172,7 +172,7 @@ class InterfaceTemplateTable(ComponentTemplateTable): class Meta(ComponentTemplateTable.Meta): model = InterfaceTemplate - fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'actions') + fields = ('pk', 'name', 'label', 'mgmt_only', 'type', 'description', 'poe_mode', 'poe_type', 'actions') empty_text = "None" diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 1945199a3..5dc2aa611 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -125,6 +125,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable): site = tables.Column( linkify=True ) + status = columns.ChoiceFieldColumn() rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, @@ -148,7 +149,7 @@ class LocationTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', 'slug', 'contacts', - 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'status', 'tenant', 'tenant_group', 'rack_count', 'device_count', 'description', + 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') + default_columns = ('pk', 'name', 'site', 'status', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index b9ddf6f43..3403f9392 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,11 +1,13 @@ LINKTERMINATION = """ -{% if value %} - {% if value.parent_object %} - {{ value.parent_object }} +{% for termination in value %} + {% if termination.parent_object %} + {{ termination.parent_object }} {% endif %} - {{ value }} -{% endif %} + {{ termination }}{% if not forloop.last %},{% endif %} +{% empty %} + {{ ''|placeholder }} +{% endfor %} """ CABLE_LENGTH = """ @@ -13,16 +15,6 @@ CABLE_LENGTH = """ {% if record.length %}{{ record.length|simplify_decimal }} {{ record.length_unit }}{% endif %} """ -CABLE_TERMINATION_PARENT = """ -{% if value.device %} - {{ value.device }} -{% elif value.circuit %} - {{ value.circuit }} -{% elif value.power_panel %} - {{ value.power_panel }} -{% endif %} -""" - DEVICE_LINK = """ {{ record.name|default:'Unnamed device' }} @@ -113,7 +105,7 @@ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ CONSOLEPORT_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -133,9 +125,9 @@ CONSOLEPORT_BUTTONS = """ {% else %} @@ -145,7 +137,7 @@ CONSOLEPORT_BUTTONS = """ CONSOLESERVERPORT_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -165,9 +157,9 @@ CONSOLESERVERPORT_BUTTONS = """ {% else %} @@ -177,7 +169,7 @@ CONSOLESERVERPORT_BUTTONS = """ POWERPORT_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -197,8 +189,8 @@ POWERPORT_BUTTONS = """ {% else %} @@ -208,7 +200,7 @@ POWERPORT_BUTTONS = """ POWEROUTLET_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -224,7 +216,7 @@ POWEROUTLET_BUTTONS = """ {% if not record.mark_connected %} - + {% else %} @@ -244,7 +236,7 @@ INTERFACE_BUTTONS = """
  • IP Address
  • {% endif %} {% if perms.dcim.add_inventoryitem %} -
  • Inventory Item
  • +
  • Inventory Item
  • {% endif %} @@ -274,17 +266,17 @@ INTERFACE_BUTTONS = """ {% else %} {% endif %} {% elif record.is_wireless and perms.wireless.add_wirelesslink %} - + {% endif %} @@ -292,7 +284,7 @@ INTERFACE_BUTTONS = """ FRONTPORT_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -313,12 +305,12 @@ FRONTPORT_BUTTONS = """ {% else %} @@ -329,7 +321,7 @@ FRONTPORT_BUTTONS = """ REARPORT_BUTTONS = """ {% if perms.dcim.add_inventoryitem %} - + {% endif %} @@ -350,12 +342,12 @@ REARPORT_BUTTONS = """ {% else %} @@ -385,7 +377,7 @@ MODULEBAY_BUTTONS = """ {% else %} - + {% endif %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 22537abe0..a78a98ae5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -7,6 +7,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.api.serializers import GenericObjectSerializer from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices @@ -45,7 +46,7 @@ class Mixins: device=peer_device, name='Peer Termination' ) - cable = Cable(termination_a=obj, termination_b=peer_obj, label='Cable 1') + cable = Cable(a_terminations=[obj], b_terminations=[peer_obj], label='Cable 1') cable.save() self.add_permissions(f'dcim.view_{self.model._meta.model_name}') @@ -55,9 +56,9 @@ class Mixins: self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) segment1 = response.data[0] - self.assertEqual(segment1[0]['name'], obj.name) + self.assertEqual(segment1[0][0]['name'], obj.name) self.assertEqual(segment1[1]['label'], cable.label) - self.assertEqual(segment1[2]['name'], peer_obj.name) + self.assertEqual(segment1[2][0]['name'], peer_obj.name) class RegionTest(APIViewTestCases.APIViewTestCase): @@ -197,13 +198,13 @@ class LocationTest(APIViewTestCases.APIViewTestCase): Site.objects.bulk_create(sites) parent_locations = ( - Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1'), - Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2'), + Location.objects.create(site=sites[0], name='Parent Location 1', slug='parent-location-1', status=LocationStatusChoices.STATUS_ACTIVE), + Location.objects.create(site=sites[1], name='Parent Location 2', slug='parent-location-2', status=LocationStatusChoices.STATUS_ACTIVE), ) - Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0]) - Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0]) + Location.objects.create(site=sites[0], name='Location 1', slug='location-1', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 2', slug='location-2', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) + Location.objects.create(site=sites[0], name='Location 3', slug='location-3', parent=parent_locations[0], status=LocationStatusChoices.STATUS_ACTIVE) cls.create_data = [ { @@ -211,18 +212,21 @@ class LocationTest(APIViewTestCases.APIViewTestCase): 'slug': 'test-location-4', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 5', 'slug': 'test-location-5', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, { 'name': 'Test Location 6', 'slug': 'test-location-6', 'site': sites[1].pk, 'parent': parent_locations[1].pk, + 'status': LocationStatusChoices.STATUS_PLANNED, }, ] @@ -327,15 +331,15 @@ class RackTest(APIViewTestCases.APIViewTestCase): # Retrieve all units response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 42) + self.assertEqual(response.data['count'], 84) # Search for specific units response = self.client.get(f'{url}?q=3', **self.header) - self.assertEqual(response.data['count'], 13) + self.assertEqual(response.data['count'], 26) response = self.client.get(f'{url}?q=U3', **self.header) - self.assertEqual(response.data['count'], 11) + self.assertEqual(response.data['count'], 22) response = self.client.get(f'{url}?q=U10', **self.header) - self.assertEqual(response.data['count'], 1) + self.assertEqual(response.data['count'], 2) def test_get_rack_elevation_svg(self): """ @@ -1507,6 +1511,8 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'speed': 1000000, 'duplex': 'full', 'vrf': vrfs[0].pk, + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, }, @@ -1859,6 +1865,17 @@ class CableTest(APIViewTestCases.APIViewTestCase): # TODO: Allow updating cable terminations test_update_object = None + def model_to_dict(self, *args, **kwargs): + data = super().model_to_dict(*args, **kwargs) + + # Serialize termination objects + if 'a_terminations' in data: + data['a_terminations'] = GenericObjectSerializer(data['a_terminations'], many=True).data + if 'b_terminations' in data: + data['b_terminations'] = GenericObjectSerializer(data['b_terminations'], many=True).data + + return data + @classmethod def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') @@ -1879,33 +1896,45 @@ class CableTest(APIViewTestCases.APIViewTestCase): Interface.objects.bulk_create(interfaces) cables = ( - Cable(termination_a=interfaces[0], termination_b=interfaces[10], label='Cable 1'), - Cable(termination_a=interfaces[1], termination_b=interfaces[11], label='Cable 2'), - Cable(termination_a=interfaces[2], termination_b=interfaces[12], label='Cable 3'), + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[10]], label='Cable 1'), + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[11]], label='Cable 2'), + Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[12]], label='Cable 3'), ) for cable in cables: cable.save() cls.create_data = [ { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[4].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[14].pk, + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[4].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[14].pk, + }], 'label': 'Cable 4', }, { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[5].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[15].pk, + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[5].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[15].pk, + }], 'label': 'Cable 5', }, { - 'termination_a_type': 'dcim.interface', - 'termination_a_id': interfaces[6].pk, - 'termination_b_type': 'dcim.interface', - 'termination_b_id': interfaces[16].pk, + 'a_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[6].pk, + }], + 'b_terminations': [{ + 'object_type': 'dcim.interface', + 'object_id': interfaces[16].pk, + }], 'label': 'Cable 6', }, ] @@ -1931,7 +1960,7 @@ class ConnectedDeviceTest(APITestCase): self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface3 = Interface.objects.create(device=self.device1, name='eth1') # Not connected - cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) cable.save() @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index 6849df012..cfbbbc63b 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -1,9 +1,9 @@ -from django.contrib.contenttypes.models import ContentType from django.test import TestCase from circuits.models import * from dcim.choices import LinkStatusChoices from dcim.models import * +from dcim.svg import CableTraceSVG from dcim.utils import object_to_path_node @@ -33,40 +33,24 @@ class CablePathTestCase(TestCase): circuit_type = CircuitType.objects.create(name='Circuit Type', slug='circuit-type') cls.circuit = Circuit.objects.create(provider=provider, type=circuit_type, cid='Circuit 1') - def assertPathExists(self, origin, destination, path=None, is_active=None, msg=None): + def assertPathExists(self, nodes, **kwargs): """ Assert that a CablePath from origin to destination with a specific intermediate path exists. - :param origin: Originating endpoint - :param destination: Terminating endpoint, or None - :param path: Sequence of objects comprising the intermediate path (optional) + :param nodes: Iterable of steps, with each step being either a single node or a list of nodes :param is_active: Boolean indicating whether the end-to-end path is complete and active (optional) - :param msg: Custom failure message (optional) :return: The matching CablePath (if any) """ - kwargs = { - 'origin_type': ContentType.objects.get_for_model(origin), - 'origin_id': origin.pk, - } - if destination is not None: - kwargs['destination_type'] = ContentType.objects.get_for_model(destination) - kwargs['destination_id'] = destination.pk - else: - kwargs['destination_type__isnull'] = True - kwargs['destination_id__isnull'] = True - if path is not None: - kwargs['path'] = [object_to_path_node(obj) for obj in path] - if is_active is not None: - kwargs['is_active'] = is_active - if msg is None: - if destination is not None: - msg = f"Missing path from {origin} to {destination}" + path = [] + for step in nodes: + if type(step) in (list, tuple): + path.append([object_to_path_node(node) for node in step]) else: - msg = f"Missing partial path originating from {origin}" + path.append([object_to_path_node(step)]) - cablepath = CablePath.objects.filter(**kwargs).first() - self.assertIsNotNone(cablepath, msg=msg) + cablepath = CablePath.objects.filter(path=path, **kwargs).first() + self.assertIsNotNone(cablepath, msg='CablePath not found') return cablepath @@ -101,18 +85,20 @@ class CablePathTestCase(TestCase): interface2 = Interface.objects.create(device=self.device, name='Interface 2') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=interface2) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[interface2] + ) cable1.save() + path1 = self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1,), + (interface1, cable1, interface2), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable1,), + (interface2, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -121,6 +107,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsSet(interface2, path2) + # Test SVG generation + CableTraceSVG(interface1).render() + # Delete cable 1 cable1.delete() @@ -135,18 +124,20 @@ class CablePathTestCase(TestCase): consoleserverport1 = ConsoleServerPort.objects.create(device=self.device, name='Console Server Port 1') # Create cable 1 - cable1 = Cable(termination_a=consoleport1, termination_b=consoleserverport1) + cable1 = Cable( + a_terminations=[consoleport1], + b_terminations=[consoleserverport1] + ) cable1.save() + path1 = self.assertPathExists( - origin=consoleport1, - destination=consoleserverport1, - path=(cable1,), + (consoleport1, cable1, consoleserverport1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=consoleserverport1, - destination=consoleport1, - path=(cable1,), + (consoleserverport1, cable1, consoleport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -155,6 +146,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(consoleport1, path1) self.assertPathIsSet(consoleserverport1, path2) + # Test SVG generation + CableTraceSVG(consoleport1).render() + # Delete cable 1 cable1.delete() @@ -169,18 +163,20 @@ class CablePathTestCase(TestCase): poweroutlet1 = PowerOutlet.objects.create(device=self.device, name='Power Outlet 1') # Create cable 1 - cable1 = Cable(termination_a=powerport1, termination_b=poweroutlet1) + cable1 = Cable( + a_terminations=[powerport1], + b_terminations=[poweroutlet1] + ) cable1.save() + path1 = self.assertPathExists( - origin=powerport1, - destination=poweroutlet1, - path=(cable1,), + (powerport1, cable1, poweroutlet1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=poweroutlet1, - destination=powerport1, - path=(cable1,), + (poweroutlet1, cable1, powerport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -189,6 +185,9 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(powerport1, path1) self.assertPathIsSet(poweroutlet1, path2) + # Test SVG generation + CableTraceSVG(powerport1).render() + # Delete cable 1 cable1.delete() @@ -203,18 +202,20 @@ class CablePathTestCase(TestCase): powerfeed1 = PowerFeed.objects.create(power_panel=self.powerpanel, name='Power Feed 1') # Create cable 1 - cable1 = Cable(termination_a=powerport1, termination_b=powerfeed1) + cable1 = Cable( + a_terminations=[powerport1], + b_terminations=[powerfeed1] + ) cable1.save() + path1 = self.assertPathExists( - origin=powerport1, - destination=powerfeed1, - path=(cable1,), + (powerport1, cable1, powerfeed1), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=powerfeed1, - destination=powerport1, - path=(cable1,), + (powerfeed1, cable1, powerport1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -223,12 +224,118 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(powerport1, path1) self.assertPathIsSet(powerfeed1, path2) + # Test SVG generation + CableTraceSVG(powerport1).render() + # Delete cable 1 cable1.delete() # Check that all CablePaths have been deleted self.assertEqual(CablePath.objects.count(), 0) + def test_120_single_interface_to_multi_interface(self): + """ + [IF1] --C1-- [IF2] + [IF3] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[interface2, interface3] + ) + cable1.save() + + path1 = self.assertPathExists( + (interface1, cable1, (interface2, interface3)), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ((interface2, interface3), cable1, interface1), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + self.assertPathIsSet(interface3, path2) + + # Test SVG generation + CableTraceSVG(interface1).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + self.assertPathIsNotSet(interface1) + self.assertPathIsNotSet(interface2) + self.assertPathIsNotSet(interface3) + + def test_121_multi_interface_to_multi_interface(self): + """ + [IF1] --C1-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[interface3, interface4] + ) + cable1.save() + + path1 = self.assertPathExists( + ((interface1, interface2), cable1, (interface3, interface4)), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ((interface3, interface4), cable1, (interface1, interface2)), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsSet(interface3, path2) + self.assertPathIsSet(interface4, path2) + + # Test SVG generation + CableTraceSVG(interface1).render() + + # Delete cable 1 + cable1.delete() + + # Check that all CablePaths have been deleted + self.assertEqual(CablePath.objects.count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsNotSet(interface1) + self.assertPathIsNotSet(interface2) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + def test_201_single_path_via_pass_through(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] @@ -241,29 +348,31 @@ class CablePathTestCase(TestCase): ) # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1), - is_active=False + (interface1, cable1, frontport1, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 2 - cable2 = Cable(termination_a=rearport1, termination_b=interface2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -271,10 +380,8 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1), - is_active=False + (interface1, cable1, frontport1, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) interface1.refresh_from_db() @@ -282,7 +389,65 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsNotSet(interface2) - def test_202_multiple_paths_via_pass_through(self): + def test_202_single_path_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[frontport1] + ) + cable1.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + + # Create cable 2 + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface3, interface4] + ) + cable2.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ([interface3, interface4], cable2, rearport1, frontport1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, frontport1, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + + def test_203_multiple_paths_via_pass_through(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C5-- [IF4] @@ -307,80 +472,71 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1_1, rearport1), - is_active=False + (interface1, cable1, frontport1_1, rearport1), + is_complete=False ) self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_2, rearport1), - is_active=False + (interface2, cable2, frontport1_2, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cable 3 - cable3 = Cable(termination_a=rearport1, termination_b=rearport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable3.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), - is_active=False + (interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), + is_complete=False ) self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), - is_active=False + (interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) # Create cables 4-5 - cable4 = Cable(termination_a=frontport2_1, termination_b=interface3) + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[interface3] + ) cable4.save() - cable5 = Cable(termination_a=frontport2_2, termination_b=interface4) + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[interface4] + ) cable5.save() path1 = self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, - cable4, - ), + (interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, interface3), + is_complete=True, is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, - cable5, - ), + (interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, interface4), + is_complete=True, is_active=True ) path3 = self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, - cable1 - ), + (interface3, cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, interface1), + is_complete=True, is_active=True ) path4 = self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, - cable2 - ), + (interface4, cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, interface2), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -389,8 +545,8 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) interface1.refresh_from_db() interface2.refresh_from_db() interface3.refresh_from_db() @@ -400,7 +556,130 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface3, path3) self.assertPathIsSet(interface4, path4) - def test_203_multiple_paths_via_nested_pass_throughs(self): + def test_204_multiple_paths_via_pass_through_with_breakouts(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF4] + [IF2] [IF5] + [IF3] --C2-- [FP1:2] [FP2:2] --C5-- [IF6] + [IF4] [IF7] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + interface5 = Interface.objects.create(device=self.device, name='Interface 5') + interface6 = Interface.objects.create(device=self.device, name='Interface 6') + interface7 = Interface.objects.create(device=self.device, name='Interface 7') + interface8 = Interface.objects.create(device=self.device, name='Interface 8') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[frontport1_1] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface3, interface4], + b_terminations=[frontport1_2] + ) + cable2.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1), + is_complete=False + ) + self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.save() + self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1), + is_complete=False + ) + self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cables 4-5 + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[interface5, interface6] + ) + cable4.save() + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[interface7, interface8] + ) + cable5.save() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, [interface5, interface6]), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + ([interface3, interface4], cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, [interface7, interface8]), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + ([interface5, interface6], cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + ([interface7, interface8], cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + interface5.refresh_from_db() + interface6.refresh_from_db() + interface7.refresh_from_db() + interface8.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsSet(interface3, path2) + self.assertPathIsSet(interface4, path2) + self.assertPathIsSet(interface5, path3) + self.assertPathIsSet(interface6, path3) + self.assertPathIsSet(interface7, path4) + self.assertPathIsSet(interface8, path4) + + def test_205_multiple_paths_via_nested_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3] --C5-- [RP4] [FP4:1] --C6-- [IF3] [IF2] --C2-- [FP1:2] [FP4:2] --C7-- [IF4] @@ -433,64 +712,77 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2, 6-7 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable6 = Cable(termination_a=interface3, termination_b=frontport4_1) + cable6 = Cable( + a_terminations=[interface3], + b_terminations=[frontport4_1] + ) cable6.save() - cable7 = Cable(termination_a=interface4, termination_b=frontport4_2) + cable7 = Cable( + a_terminations=[interface4], + b_terminations=[frontport4_2] + ) cable7.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3 and 5 - cable3 = Cable(termination_a=rearport1, termination_b=frontport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[frontport2] + ) cable3.save() - cable5 = Cable(termination_a=rearport4, termination_b=frontport3) + cable5 = Cable( + a_terminations=[rearport4], + b_terminations=[frontport3] + ) cable5.save() self.assertEqual(CablePath.objects.count(), 4) # Four (longer) partial paths; one from each interface # Create cable 4 - cable4 = Cable(termination_a=rearport2, termination_b=rearport3) + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[rearport3] + ) cable4.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3, cable5, rearport4, frontport4_1, - cable6 + ( + interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, cable4, rearport3, + frontport3, cable5, rearport4, frontport4_1, cable6, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3, cable5, rearport4, frontport4_2, - cable7 + ( + interface2, cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, cable4, rearport3, + frontport3, cable5, rearport4, frontport4_2, cable7, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable6, frontport4_1, rearport4, cable5, frontport3, rearport3, - cable4, rearport2, frontport2, cable3, rearport1, frontport1_1, - cable1 + ( + interface3, cable6, frontport4_1, rearport4, cable5, frontport3, rearport3, cable4, rearport2, + frontport2, cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable7, frontport4_2, rearport4, cable5, frontport3, rearport3, - cable4, rearport2, frontport2, cable3, rearport1, frontport1_2, - cable2 + ( + interface4, cable7, frontport4_2, rearport4, cable5, frontport3, rearport3, cable4, rearport2, + frontport2, cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -499,10 +791,10 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_204_multiple_paths_via_multiple_pass_throughs(self): + def test_206_multiple_paths_via_multiple_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [FP3:1] [RP3] --C6-- [RP4] [FP4:1] --C7-- [IF3] [IF2] --C2-- [FP1:2] [FP2:1] --C5-- [FP3:1] [FP4:2] --C8-- [IF4] @@ -541,63 +833,83 @@ class CablePathTestCase(TestCase): ) # Create cables 1-3, 6-8 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable3 = Cable(termination_a=rearport1, termination_b=rearport2) + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable3.save() - cable6 = Cable(termination_a=rearport3, termination_b=rearport4) + cable6 = Cable( + a_terminations=[rearport3], + b_terminations=[rearport4] + ) cable6.save() - cable7 = Cable(termination_a=interface3, termination_b=frontport4_1) + cable7 = Cable( + a_terminations=[interface3], + b_terminations=[frontport4_1] + ) cable7.save() - cable8 = Cable(termination_a=interface4, termination_b=frontport4_2) + cable8 = Cable( + a_terminations=[interface4], + b_terminations=[frontport4_2] + ) cable8.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 4 and 5 - cable4 = Cable(termination_a=frontport2_1, termination_b=frontport3_1) + cable4 = Cable( + a_terminations=[frontport2_1], + b_terminations=[frontport3_1] + ) cable4.save() - cable5 = Cable(termination_a=frontport2_2, termination_b=frontport3_2) + cable5 = Cable( + a_terminations=[frontport2_2], + b_terminations=[frontport3_2] + ) cable5.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, + ( + interface1, cable1, frontport1_1, rearport1, cable3, rearport2, frontport2_1, cable4, frontport3_1, rearport3, cable6, rearport4, frontport4_1, - cable7 + cable7, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, + ( + interface2, cable2, frontport1_2, rearport1, cable3, rearport2, frontport2_2, cable5, frontport3_2, rearport3, cable6, rearport4, frontport4_2, - cable8 + cable8, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1, + ( + interface3, cable7, frontport4_1, rearport4, cable6, rearport3, frontport3_1, cable4, frontport2_1, rearport2, cable3, rearport1, frontport1_1, - cable1 + cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2, + ( + interface4, cable8, frontport4_2, rearport4, cable6, rearport3, frontport3_2, cable5, frontport2_2, rearport2, cable3, rearport1, frontport1_2, - cable2 + cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -606,10 +918,10 @@ class CablePathTestCase(TestCase): cable5.delete() # Check for two complete paths (IF1 <--> IF2) and two partial (IF3 <--> IF4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 2) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 2) - def test_205_multiple_paths_via_patched_pass_throughs(self): + def test_207_multiple_paths_via_patched_pass_throughs(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [FP2] [RP2] --C4-- [RP3] [FP3:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP3:2] --C6-- [IF4] @@ -638,55 +950,69 @@ class CablePathTestCase(TestCase): ) # Create cables 1-2, 5-6 - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable5 = Cable(termination_a=interface3, termination_b=frontport3_1) # IF3 -> FP3:1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport3_1] + ) cable5.save() - cable6 = Cable(termination_a=interface4, termination_b=frontport3_2) # IF4 -> FP3:2 + cable6 = Cable( + a_terminations=[interface4], + b_terminations=[frontport3_2] + ) cable6.save() self.assertEqual(CablePath.objects.count(), 4) # Four partial paths; one from each interface # Create cables 3-4 - cable3 = Cable(termination_a=rearport1, termination_b=frontport2) # RP1 -> FP2 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[frontport2] + ) cable3.save() - cable4 = Cable(termination_a=rearport2, termination_b=rearport3) # RP2 -> RP3 + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[rearport3] + ) cable4.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3_1, cable5 + ( + interface1, cable1, frontport1_1, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_1, cable5, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, - cable4, rearport3, frontport3_2, cable6 + ( + interface2, cable2, frontport1_2, rearport1, cable3, frontport2, rearport2, + cable4, rearport3, frontport3_2, cable6, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable5, frontport3_1, rearport3, cable4, rearport2, frontport2, - cable3, rearport1, frontport1_1, cable1 + ( + interface3, cable5, frontport3_1, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable6, frontport3_2, rearport3, cable4, rearport2, frontport2, - cable3, rearport1, frontport1_2, cable2 + ( + interface4, cable6, frontport3_2, rearport3, cable4, rearport2, frontport2, + cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -695,10 +1021,10 @@ class CablePathTestCase(TestCase): cable3.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_206_unidirectional_split_paths(self): + def test_208_unidirectional_split_paths(self): """ [IF1] --C1-- [RP1] [FP1:1] --C2-- [IF2] [FP1:2] --C3-- [IF3] @@ -715,31 +1041,37 @@ class CablePathTestCase(TestCase): ) # Create cables 1 - cable1 = Cable(termination_a=interface1, termination_b=rearport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[rearport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, rearport1), - is_active=False + (interface1, cable1, rearport1), + is_complete=False, + is_split=True ) self.assertEqual(CablePath.objects.count(), 1) # Create cables 2-3 - cable2 = Cable(termination_a=interface2, termination_b=frontport1_1) + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_1] + ) cable2.save() - cable3 = Cable(termination_a=interface3, termination_b=frontport1_2) + cable3 = Cable( + a_terminations=[interface3], + b_terminations=[frontport1_2] + ) cable3.save() self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, frontport1_1, rearport1, cable1), + (interface2, cable2, frontport1_1, rearport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=(cable3, frontport1_2, rearport1, cable1), + (interface3, cable3, frontport1_2, rearport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 3) @@ -749,20 +1081,16 @@ class CablePathTestCase(TestCase): # Check that the partial path was deleted and the two complete paths are now partial self.assertPathExists( - origin=interface2, - destination=None, - path=(cable2, frontport1_1, rearport1), - is_active=False + (interface2, cable2, frontport1_1, rearport1), + is_complete=False ) self.assertPathExists( - origin=interface3, - destination=None, - path=(cable3, frontport1_2, rearport1), - is_active=False + (interface3, cable3, frontport1_2, rearport1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 2) - def test_207_rearport_without_frontport(self): + def test_209_rearport_without_frontport(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] """ @@ -774,19 +1102,23 @@ class CablePathTestCase(TestCase): ) # Create cables - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() - cable2 = Cable(termination_a=rearport1, termination_b=rearport2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable2.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1, cable2, rearport2), - is_active=False + (interface1, cable1, frontport1, rearport1, cable2, rearport2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) - def test_208_circuittermination(self): + def test_210_interface_to_circuittermination(self): """ [IF1] --C1-- [CT1] """ @@ -794,15 +1126,16 @@ class CablePathTestCase(TestCase): circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() # Check for incomplete path self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, circuittermination1), - is_active=False + (interface1, cable1, circuittermination1), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) @@ -812,7 +1145,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_209_circuit_to_interface(self): + def test_211_interface_to_interface_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --C2-- [IF2] """ @@ -821,15 +1154,16 @@ class CablePathTestCase(TestCase): circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() # Check for partial path from interface1 self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, circuittermination1), - is_active=False + (interface1, cable1, circuittermination1), + is_complete=False ) # Create CT2 @@ -837,27 +1171,26 @@ class CablePathTestCase(TestCase): # Check for partial path to site self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) # Create cable 2 - cable2 = Cable(termination_a=circuittermination2, termination_b=interface2) + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[interface2] + ) cable2.save() # Check for complete path in each direction self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, circuittermination1, circuittermination2, cable2), + (interface1, cable1, circuittermination1, circuittermination2, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, circuittermination2, circuittermination1, cable1), + (interface2, cable2, circuittermination2, circuittermination1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -865,9 +1198,7 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -876,7 +1207,76 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsNotSet(interface2) - def test_210_circuit_to_site(self): + def test_212_interface_to_interface_via_circuit_with_breakouts(self): + """ + [IF1] --C1-- [CT1] [CT2] --C2-- [IF3] + [IF2] [IF4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A') + + # Create cable 1 + cable1 = Cable( + a_terminations=[interface1, interface2], + b_terminations=[circuittermination1] + ) + cable1.save() + + # Check for partial path from interface1 + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1), + is_complete=False + ) + + # Create CT2 + circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') + + # Check for partial path to site + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, self.site), + is_active=True + ) + + # Create cable 2 + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[interface3, interface4] + ) + cable2.save() + + # Check for complete path in each direction + self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, cable2, [interface3, interface4]), + is_complete=True, + is_active=True + ) + self.assertPathExists( + ([interface3, interface4], cable2, circuittermination2, circuittermination1, cable1, [interface1, interface2]), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + path1 = self.assertPathExists( + ([interface1, interface2], cable1, circuittermination1, circuittermination2, self.site), + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 1) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path1) + self.assertPathIsNotSet(interface3) + self.assertPathIsNotSet(interface4) + + def test_213_interface_to_site_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --> [Site2] """ @@ -886,12 +1286,13 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=site2, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, site2), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -902,7 +1303,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_211_circuit_to_providernetwork(self): + def test_214_interface_to_providernetwork_via_circuit(self): """ [IF1] --C1-- [CT1] [CT2] --> [PN1] """ @@ -912,12 +1313,13 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z') # Create cable 1 - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=providernetwork, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, providernetwork), is_active=True ) self.assertEqual(CablePath.objects.count(), 1) @@ -928,7 +1330,7 @@ class CablePathTestCase(TestCase): interface1.refresh_from_db() self.assertPathIsNotSet(interface1) - def test_212_multiple_paths_via_circuit(self): + def test_215_multiple_paths_via_circuit(self): """ [IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3] [IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4] @@ -955,52 +1357,66 @@ class CablePathTestCase(TestCase): circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z') # Create cables - cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1] + ) cable1.save() - cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2 + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_2] + ) cable2.save() - cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[circuittermination1] + ) cable3.save() - cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2 + cable4 = Cable( + a_terminations=[rearport2], + b_terminations=[circuittermination2] + ) cable4.save() - cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1 + cable5 = Cable( + a_terminations=[interface3], + b_terminations=[frontport2_1] + ) cable5.save() - cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2 + cable6 = Cable( + a_terminations=[interface4], + b_terminations=[frontport2_2] + ) cable6.save() self.assertPathExists( - origin=interface1, - destination=interface3, - path=( - cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, - cable4, rearport2, frontport2_1, cable5 + ( + interface1, cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_1, cable5, interface3, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface4, - path=( - cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, - cable4, rearport2, frontport2_2, cable6 + ( + interface2, cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2, + cable4, rearport2, frontport2_2, cable6, interface4, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface3, - destination=interface1, - path=( - cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, - cable3, rearport1, frontport1_1, cable1 + ( + interface3, cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface4, - destination=interface2, - path=( - cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, - cable3, rearport1, frontport1_2, cable2 + ( + interface4, cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1, + cable3, rearport1, frontport1_2, cable2, interface2, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 4) @@ -1010,10 +1426,10 @@ class CablePathTestCase(TestCase): cable4.delete() # Check for four partial paths; one from each interface - self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4) - self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0) + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) - def test_213_multiple_circuits_to_interface(self): + def test_216_interface_to_interface_via_multiple_circuits(self): """ [IF1] --C1-- [CT1] [CT2] --C2-- [CT3] [CT4] --C3-- [IF2] """ @@ -1026,30 +1442,37 @@ class CablePathTestCase(TestCase): circuittermination4 = CircuitTermination.objects.create(circuit=circuit2, site=self.site, term_side='Z') # Create cables - cable1 = Cable(termination_a=interface1, termination_b=circuittermination1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[circuittermination1] + ) cable1.save() - cable2 = Cable(termination_a=circuittermination2, termination_b=circuittermination3) + cable2 = Cable( + a_terminations=[circuittermination2], + b_terminations=[circuittermination3] + ) cable2.save() - cable3 = Cable(termination_a=circuittermination4, termination_b=interface2) + cable3 = Cable( + a_terminations=[circuittermination4], + b_terminations=[interface2] + ) cable3.save() # Check for paths self.assertPathExists( - origin=interface1, - destination=interface2, - path=( - cable1, circuittermination1, circuittermination2, cable2, circuittermination3, circuittermination4, - cable3 + ( + interface1, cable1, circuittermination1, circuittermination2, cable2, circuittermination3, + circuittermination4, cable3, interface2, ), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=( - cable3, circuittermination4, circuittermination3, cable2, circuittermination2, circuittermination1, - cable1 + ( + interface2, cable3, circuittermination4, circuittermination3, cable2, circuittermination2, + circuittermination1, cable1, interface1, ), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1057,15 +1480,11 @@ class CablePathTestCase(TestCase): # Delete cable 2 cable2.delete() path1 = self.assertPathExists( - origin=interface1, - destination=self.site, - path=(cable1, circuittermination1, circuittermination2), + (interface1, cable1, circuittermination1, circuittermination2, self.site), is_active=True ) path2 = self.assertPathExists( - origin=interface2, - destination=self.site, - path=(cable3, circuittermination4, circuittermination3), + (interface2, cable3, circuittermination4, circuittermination3, self.site), is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1074,9 +1493,210 @@ class CablePathTestCase(TestCase): self.assertPathIsSet(interface1, path1) self.assertPathIsSet(interface2, path2) + def test_217_interface_to_interface_via_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [RP3] [FP3] --C3-- [IF2] + [FP2] [RP2] [RP4] [FP4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + rearport3 = RearPort.objects.create(device=self.device, name='Rear Port 3', positions=1) + rearport4 = RearPort.objects.create(device=self.device, name='Rear Port 4', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + frontport3 = FrontPort.objects.create( + device=self.device, name='Front Port 3', rear_port=rearport3, rear_port_position=1 + ) + frontport4 = FrontPort.objects.create( + device=self.device, name='Front Port 4', rear_port=rearport4, rear_port_position=1 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport2] + ) + cable1.save() + cable3 = Cable( + a_terminations=[interface2], + b_terminations=[frontport3, frontport4] + ) + cable3.save() + self.assertPathExists( + (interface1, cable1, (frontport1, frontport2), (rearport1, rearport2)), + is_complete=False + ) + self.assertPathExists( + (interface2, cable3, (frontport3, frontport4), (rearport3, rearport4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 2 + cable2 = Cable( + a_terminations=[rearport1, rearport2], + b_terminations=[rearport3, rearport4] + ) + cable2.save() + path1 = self.assertPathExists( + ( + interface1, cable1, (frontport1, frontport2), (rearport1, rearport2), cable2, + (rearport3, rearport4), (frontport3, frontport4), cable3, interface2 + ), + is_complete=True + ) + path2 = self.assertPathExists( + ( + interface2, cable3, (frontport3, frontport4), (rearport3, rearport4), cable2, + (rearport1, rearport2), (frontport1, frontport2), cable1, interface1 + ), + is_complete=True + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Delete cable 2 + cable2.delete() + + # Check for two partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 2) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + + def test_218_interfaces_to_interfaces_via_multiposition_rear_ports(self): + """ + [IF1] --C1-- [FP1:1] [RP1] --C3-- [RP2] [FP2:1] --C4-- [IF3] + [FP1:2] [FP2:2] + [IF2] --C2-- [FP1:3] [FP2:3] --C5-- [IF4] + [FP1:4] [FP2:4] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + interface3 = Interface.objects.create(device=self.device, name='Interface 3') + interface4 = Interface.objects.create(device=self.device, name='Interface 4') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4) + frontport1_1 = FrontPort.objects.create( + device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1 + ) + frontport1_2 = FrontPort.objects.create( + device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2 + ) + frontport1_3 = FrontPort.objects.create( + device=self.device, name='Front Port 1:3', rear_port=rearport1, rear_port_position=3 + ) + frontport1_4 = FrontPort.objects.create( + device=self.device, name='Front Port 1:4', rear_port=rearport1, rear_port_position=4 + ) + frontport2_1 = FrontPort.objects.create( + device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1 + ) + frontport2_2 = FrontPort.objects.create( + device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2 + ) + frontport2_3 = FrontPort.objects.create( + device=self.device, name='Front Port 2:3', rear_port=rearport2, rear_port_position=3 + ) + frontport2_4 = FrontPort.objects.create( + device=self.device, name='Front Port 2:4', rear_port=rearport2, rear_port_position=4 + ) + + # Create cables 1-2 + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1_1, frontport1_2] + ) + cable1.save() + cable2 = Cable( + a_terminations=[interface2], + b_terminations=[frontport1_3, frontport1_4] + ) + cable2.save() + self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1), + is_complete=False + ) + self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cable 3 + cable3 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) + cable3.save() + self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1, cable3, rearport2, (frontport2_1, frontport2_2)), + is_complete=False + ) + self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1, cable3, rearport2, (frontport2_3, frontport2_4)), + is_complete=False + ) + self.assertEqual(CablePath.objects.count(), 2) + + # Create cables 4-5 + cable4 = Cable( + a_terminations=[frontport2_1, frontport2_2], + b_terminations=[interface3] + ) + cable4.save() + cable5 = Cable( + a_terminations=[frontport2_3, frontport2_4], + b_terminations=[interface4] + ) + cable5.save() + path1 = self.assertPathExists( + (interface1, cable1, (frontport1_1, frontport1_2), rearport1, cable3, rearport2, (frontport2_1, frontport2_2), cable4, interface3), + is_complete=True, + is_active=True + ) + path2 = self.assertPathExists( + (interface2, cable2, (frontport1_3, frontport1_4), rearport1, cable3, rearport2, (frontport2_3, frontport2_4), cable5, interface4), + is_complete=True, + is_active=True + ) + path3 = self.assertPathExists( + (interface3, cable4, (frontport2_1, frontport2_2), rearport2, cable3, rearport1, (frontport1_1, frontport1_2), cable1, interface1), + is_complete=True, + is_active=True + ) + path4 = self.assertPathExists( + (interface4, cable5, (frontport2_3, frontport2_4), rearport2, cable3, rearport1, (frontport1_3, frontport1_4), cable2, interface2), + is_complete=True, + is_active=True + ) + self.assertEqual(CablePath.objects.count(), 4) + + # Delete cable 3 + cable3.delete() + + # Check for four partial paths; one from each interface + self.assertEqual(CablePath.objects.filter(is_complete=False).count(), 4) + self.assertEqual(CablePath.objects.filter(is_complete=True).count(), 0) + interface1.refresh_from_db() + interface2.refresh_from_db() + interface3.refresh_from_db() + interface4.refresh_from_db() + self.assertPathIsSet(interface1, path1) + self.assertPathIsSet(interface2, path2) + self.assertPathIsSet(interface3, path3) + self.assertPathIsSet(interface4, path4) + def test_301_create_path_via_existing_cable(self): """ - [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] + [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] """ interface1 = Interface.objects.create(device=self.device, name='Interface 1') interface2 = Interface.objects.create(device=self.device, name='Interface 2') @@ -1090,34 +1710,39 @@ class CablePathTestCase(TestCase): ) # Create cable 2 - cable2 = Cable(termination_a=rearport1, termination_b=rearport2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[rearport2] + ) cable2.save() self.assertEqual(CablePath.objects.count(), 0) # Create cable1 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() self.assertPathExists( - origin=interface1, - destination=None, - path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2), - is_active=False + (interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2), + is_complete=False ) self.assertEqual(CablePath.objects.count(), 1) # Create cable 3 - cable3 = Cable(termination_a=frontport2, termination_b=interface2) + cable3 = Cable( + a_terminations=[frontport2], + b_terminations=[interface2] + ) cable3.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3), + (interface1, cable1, frontport1, rearport1, cable2, rearport2, frontport2, cable3, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1), + (interface2, cable3, frontport2, rearport2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) @@ -1134,26 +1759,31 @@ class CablePathTestCase(TestCase): ) # Create cables 1 and 2 - cable1 = Cable(termination_a=interface1, termination_b=frontport1) + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1] + ) cable1.save() - cable2 = Cable(termination_a=rearport1, termination_b=interface2) + cable2 = Cable( + a_terminations=[rearport1], + b_terminations=[interface2] + ) cable2.save() self.assertEqual(CablePath.objects.filter(is_active=True).count(), 2) self.assertEqual(CablePath.objects.count(), 2) # Change cable 2's status to "planned" + cable2 = Cable.objects.get(pk=cable2.pk) # Rebuild object to ditch A/B terminations set earlier cable2.status = LinkStatusChoices.STATUS_PLANNED cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=False ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=False ) self.assertEqual(CablePath.objects.count(), 2) @@ -1163,15 +1793,13 @@ class CablePathTestCase(TestCase): cable2.status = LinkStatusChoices.STATUS_CONNECTED cable2.save() self.assertPathExists( - origin=interface1, - destination=interface2, - path=(cable1, frontport1, rearport1, cable2), + (interface1, cable1, frontport1, rearport1, cable2, interface2), + is_complete=True, is_active=True ) self.assertPathExists( - origin=interface2, - destination=interface1, - path=(cable2, rearport1, frontport1, cable1), + (interface2, cable2, rearport1, frontport1, cable1, interface1), + is_complete=True, is_active=True ) self.assertEqual(CablePath.objects.count(), 2) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index cf0f397df..fbc0addb8 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -265,9 +265,9 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): location.save() locations = ( - Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], description='A'), - Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], description='B'), - Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], description='C'), + Location(name='Location 1', slug='location-1', site=sites[0], parent=parent_locations[0], status=LocationStatusChoices.STATUS_PLANNED, description='A'), + Location(name='Location 2', slug='location-2', site=sites[1], parent=parent_locations[1], status=LocationStatusChoices.STATUS_STAGING, description='B'), + Location(name='Location 3', slug='location-3', site=sites[2], parent=parent_locations[2], status=LocationStatusChoices.STATUS_DECOMMISSIONING, description='C'), ) for location in locations: location.save() @@ -280,6 +280,10 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [LocationStatusChoices.STATUS_PLANNED, LocationStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1085,8 +1089,8 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): DeviceType.objects.bulk_create(device_types) InterfaceTemplate.objects.bulk_create(( - InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True), - InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False), + InterfaceTemplate(device_type=device_types[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True, poe_mode=InterfacePoEModeChoices.MODE_PD, poe_type=InterfacePoETypeChoices.TYPE_1_8023AF), + InterfaceTemplate(device_type=device_types[1], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_GBIC, mgmt_only=False, poe_mode=InterfacePoEModeChoices.MODE_PSE, poe_type=InterfacePoETypeChoices.TYPE_2_8023AT), InterfaceTemplate(device_type=device_types[2], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_SFP, mgmt_only=False), )) @@ -1109,6 +1113,14 @@ class InterfaceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_poe_type(self): + params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class FrontPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPortTemplate.objects.all() @@ -1948,8 +1960,8 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): ConsolePort.objects.bulk_create(console_ports) # Cables - Cable(termination_a=console_ports[0], termination_b=console_server_ports[0]).save() - Cable(termination_a=console_ports[1], termination_b=console_server_ports[1]).save() + Cable(a_terminations=[console_ports[0]], b_terminations=[console_server_ports[0]]).save() + Cable(a_terminations=[console_ports[1]], b_terminations=[console_server_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2095,8 +2107,8 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): ConsoleServerPort.objects.bulk_create(console_server_ports) # Cables - Cable(termination_a=console_server_ports[0], termination_b=console_ports[0]).save() - Cable(termination_a=console_server_ports[1], termination_b=console_ports[1]).save() + Cable(a_terminations=[console_server_ports[0]], b_terminations=[console_ports[0]]).save() + Cable(a_terminations=[console_server_ports[1]], b_terminations=[console_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2242,8 +2254,8 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): PowerPort.objects.bulk_create(power_ports) # Cables - Cable(termination_a=power_ports[0], termination_b=power_outlets[0]).save() - Cable(termination_a=power_ports[1], termination_b=power_outlets[1]).save() + Cable(a_terminations=[power_ports[0]], b_terminations=[power_outlets[0]]).save() + Cable(a_terminations=[power_ports[1]], b_terminations=[power_outlets[1]]).save() # Third port is not connected def test_name(self): @@ -2397,8 +2409,8 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): PowerOutlet.objects.bulk_create(power_outlets) # Cables - Cable(termination_a=power_outlets[0], termination_b=power_ports[0]).save() - Cable(termination_a=power_outlets[1], termination_b=power_ports[1]).save() + Cable(a_terminations=[power_outlets[0]], b_terminations=[power_ports[0]]).save() + Cable(a_terminations=[power_outlets[1]], b_terminations=[power_ports[1]]).save() # Third port is not connected def test_name(self): @@ -2547,20 +2559,115 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], module=modules[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), - Interface(device=devices[1], module=modules[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), - Interface(device=devices[2], module=modules[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), - Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), - Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), - Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), - Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20), + Interface( + device=devices[0], + module=modules[0], + name='Interface 1', + label='A', + type=InterfaceTypeChoices.TYPE_1GE_SFP, + enabled=True, + mgmt_only=True, + mtu=100, + mode=InterfaceModeChoices.MODE_ACCESS, + mac_address='00-00-00-00-00-01', + description='First', + vrf=vrfs[0], + speed=1000000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[1], + module=modules[1], + name='Interface 2', + label='B', + type=InterfaceTypeChoices.TYPE_1GE_GBIC, + enabled=True, + mgmt_only=True, + mtu=200, + mode=InterfaceModeChoices.MODE_TAGGED, + mac_address='00-00-00-00-00-02', + description='Second', + vrf=vrfs[1], + speed=1000000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_1_8023AF + ), + Interface( + device=devices[2], + module=modules[2], + name='Interface 3', + label='C', + type=InterfaceTypeChoices.TYPE_1GE_FIXED, + enabled=False, + mgmt_only=False, + mtu=300, + mode=InterfaceModeChoices.MODE_TAGGED_ALL, + mac_address='00-00-00-00-00-03', + description='Third', + vrf=vrfs[2], + speed=100000, + duplex='half', + poe_mode=InterfacePoEModeChoices.MODE_PSE, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 4', + label='D', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40, + speed=100000, + duplex='full', + poe_mode=InterfacePoEModeChoices.MODE_PD, + poe_type=InterfacePoETypeChoices.TYPE_2_8023AT + ), + Interface( + device=devices[3], + name='Interface 5', + label='E', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=True, + mgmt_only=True, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 6', + label='F', + type=InterfaceTypeChoices.TYPE_OTHER, + enabled=False, + mgmt_only=False, + tx_power=40 + ), + Interface( + device=devices[3], + name='Interface 7', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_AP, + rf_channel=WirelessChannelChoices.CHANNEL_24G_1, + rf_channel_frequency=2412, + rf_channel_width=22 + ), + Interface( + device=devices[3], + name='Interface 8', + type=InterfaceTypeChoices.TYPE_80211AC, + rf_role=WirelessRoleChoices.ROLE_STATION, + rf_channel=WirelessChannelChoices.CHANNEL_5G_32, + rf_channel_frequency=5160, + rf_channel_width=20 + ), ) Interface.objects.bulk_create(interfaces) # Cables - Cable(termination_a=interfaces[0], termination_b=interfaces[3]).save() - Cable(termination_a=interfaces[1], termination_b=interfaces[4]).save() + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]]).save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]]).save() # Third pair is not connected def test_name(self): @@ -2601,6 +2708,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mgmt_only': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_poe_mode(self): + params = {'poe_mode': [InterfacePoEModeChoices.MODE_PD, InterfacePoEModeChoices.MODE_PSE]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_poe_type(self): + params = {'poe_type': [InterfacePoETypeChoices.TYPE_1_8023AF, InterfacePoETypeChoices.TYPE_2_8023AT]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mode(self): params = {'mode': InterfaceModeChoices.MODE_ACCESS} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2827,8 +2942,8 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): FrontPort.objects.bulk_create(front_ports) # Cables - Cable(termination_a=front_ports[0], termination_b=front_ports[3]).save() - Cable(termination_a=front_ports[1], termination_b=front_ports[4]).save() + Cable(a_terminations=[front_ports[0]], b_terminations=[front_ports[3]]).save() + Cable(a_terminations=[front_ports[1]], b_terminations=[front_ports[4]]).save() # Third port is not connected def test_name(self): @@ -2973,8 +3088,8 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): RearPort.objects.bulk_create(rear_ports) # Cables - Cable(termination_a=rear_ports[0], termination_b=rear_ports[3]).save() - Cable(termination_a=rear_ports[1], termination_b=rear_ports[4]).save() + Cable(a_terminations=[rear_ports[0]], b_terminations=[rear_ports[3]]).save() + Cable(a_terminations=[rear_ports[1]], b_terminations=[rear_ports[4]]).save() # Third port is not connected def test_name(self): @@ -3558,6 +3673,21 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', site=sites[0], slug='location-1'), + Location(name='Location 2', site=sites[1], slug='location-1'), + Location(name='Location 3', site=sites[2], slug='location-1'), + ) + for location in locations: + location.save() + + racks = ( + Rack(name='Rack 1', site=sites[0], location=locations[0]), + Rack(name='Rack 2', site=sites[1], location=locations[1]), + Rack(name='Rack 3', site=sites[2], location=locations[2]), + ) + Rack.objects.bulk_create(racks) + tenants = ( Tenant(name='Tenant 1', slug='tenant-1'), Tenant(name='Tenant 2', slug='tenant-2'), @@ -3565,24 +3695,17 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): ) Tenant.objects.bulk_create(tenants) - racks = ( - Rack(name='Rack 1', site=sites[0]), - Rack(name='Rack 2', site=sites[1]), - Rack(name='Rack 3', site=sites[2]), - ) - Rack.objects.bulk_create(racks) - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=1), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], position=2), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=1), - Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], position=2), - Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=1), - Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], position=2), + Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=1), + Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[0], rack=racks[0], location=locations[0], position=2), + Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=1), + Device(name='Device 4', device_type=device_type, device_role=device_role, site=sites[1], rack=racks[1], location=locations[1], position=2), + Device(name='Device 5', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=1), + Device(name='Device 6', device_type=device_type, device_role=device_role, site=sites[2], rack=racks[2], location=locations[2], position=2), ) Device.objects.bulk_create(devices) @@ -3606,13 +3729,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1') # Cables - Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() - Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() - Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[2]], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[3]], b_terminations=[interfaces[4]], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[5]], b_terminations=[interfaces[6]], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[7]], b_terminations=[interfaces[8]], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save() + Cable(a_terminations=[interfaces[9]], b_terminations=[interfaces[10]], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(a_terminations=[interfaces[11]], b_terminations=[interfaces[0]], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save() + Cable(a_terminations=[console_port], b_terminations=[console_server_port], label='Cable 7').save() def test_label(self): params = {'label': ['Cable 1', 'Cable 2']} @@ -3654,6 +3777,13 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'rack': [racks[0].name, racks[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'location': [locations[0].name, locations[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_site(self): site = Site.objects.all()[:2] params = {'site_id': [site[0].pk, site[1].pk]} @@ -3675,7 +3805,10 @@ class CableTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_termination_ids(self): - interface_ids = Cable.objects.values_list('termination_a_id', flat=True)[:3] + interface_ids = CableTermination.objects.filter( + cable__in=Cable.objects.all()[:3], + cable_end='A' + ).values_list('termination_id', flat=True) params = { 'termination_a_type': 'dcim.interface', 'termination_a_id': list(interface_ids), @@ -3819,8 +3952,8 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): PowerPort(device=device, name='Power Port 2'), ] PowerPort.objects.bulk_create(power_ports) - Cable(termination_a=power_feeds[0], termination_b=power_ports[0]).save() - Cable(termination_a=power_feeds[1], termination_b=power_ports[1]).save() + Cable(a_terminations=[power_feeds[0]], b_terminations=[power_ports[0]]).save() + Cable(a_terminations=[power_feeds[1]], b_terminations=[power_ports[1]]).save() def test_name(self): params = {'name': ['Power Feed 1', 'Power Feed 2']} diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 03438a441..0e02b0de5 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -5,6 +5,7 @@ from circuits.models import * from dcim.choices import * from dcim.models import * from tenancy.models import Tenant +from utilities.utils import drange class LocationTestCase(TestCase): @@ -74,148 +75,142 @@ class RackTestCase(TestCase): def setUp(self): - self.site1 = Site.objects.create( - name='TestSite1', - slug='test-site-1' + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), ) - self.site2 = Site.objects.create( - name='TestSite2', - slug='test-site-2' + Site.objects.bulk_create(sites) + + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - self.location1 = Location.objects.create( - name='TestGroup1', - slug='test-group-1', - site=self.site1 - ) - self.location2 = Location.objects.create( - name='TestGroup2', - slug='test-group-2', - site=self.site2 - ) - self.rack = Rack.objects.create( - name='TestRack1', + for location in locations: + location.save() + + Rack.objects.create( + name='Rack 1', facility_id='A101', - site=self.site1, - location=self.location1, + site=sites[0], + location=locations[0], u_height=42 ) - self.manufacturer = Manufacturer.objects.create( - name='Acme', - slug='acme' + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', u_height=1), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=0), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3', u_height=0.5), ) + DeviceType.objects.bulk_create(device_types) - self.device_type = { - 'ff2048': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='FrameForwarder 2048', - slug='ff2048' - ), - 'cc5000': DeviceType.objects.create( - manufacturer=self.manufacturer, - model='CurrentCatapult 5000', - slug='cc5000', - u_height=0 - ), - } - self.role = { - 'Server': DeviceRole.objects.create( - name='Server', - slug='server', - ), - 'Switch': DeviceRole.objects.create( - name='Switch', - slug='switch', - ), - 'Console Server': DeviceRole.objects.create( - name='Console Server', - slug='console-server', - ), - 'PDU': DeviceRole.objects.create( - name='PDU', - slug='pdu', - ), - - } + DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') def test_rack_device_outside_height(self): - - rack1 = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42 - ) - rack1.save() + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( - name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=rack1, + name='Device 1', + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, position=43, face=DeviceFaceChoices.FACE_FRONT, ) device1.save() with self.assertRaises(ValidationError): - rack1.clean() + rack.clean() def test_location_site(self): + site1 = Site.objects.get(name='Site 1') + location2 = Location.objects.get(name='Location 2') - rack_invalid_location = Rack( - name='TestRack2', - facility_id='A102', - site=self.site1, - u_height=42, - location=self.location2 + rack2 = Rack( + name='Rack 2', + site=site1, + location=location2, + u_height=42 ) - rack_invalid_location.save() + rack2.save() with self.assertRaises(ValidationError): - rack_invalid_location.clean() + rack2.clean() def test_mount_single_device(self): + site = Site.objects.first() + rack = Rack.objects.first() device1 = Device( name='TestSwitch1', - device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), - device_role=DeviceRole.objects.get(slug='switch'), - site=self.site1, - rack=self.rack, - position=10, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first(), + site=site, + rack=rack, + position=10.0, face=DeviceFaceChoices.FACE_REAR, ) device1.save() # Validate rack height - self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) + self.assertEqual(list(rack.units), list(drange(42.5, 0.5, -0.5))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) - self.assertEqual(rack1_inventory_front[-10]['device'], device1) - del rack1_inventory_front[-10] - for u in rack1_inventory_front: + rack1_inventory_front = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) + } + self.assertEqual(rack1_inventory_front[10.0]['device'], device1) + self.assertEqual(rack1_inventory_front[10.5]['device'], device1) + del rack1_inventory_front[10.0] + del rack1_inventory_front[10.5] + for u in rack1_inventory_front.values(): self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) - self.assertEqual(rack1_inventory_rear[-10]['device'], device1) - del rack1_inventory_rear[-10] - for u in rack1_inventory_rear: + rack1_inventory_rear = { + u['id']: u for u in rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) + } + self.assertEqual(rack1_inventory_rear[10.0]['device'], device1) + self.assertEqual(rack1_inventory_rear[10.5]['device'], device1) + del rack1_inventory_rear[10.0] + del rack1_inventory_rear[10.5] + for u in rack1_inventory_rear.values(): self.assertIsNone(u['device']) def test_mount_zero_ru(self): - pdu = Device.objects.create( - name='TestPDU', - device_role=self.role.get('PDU'), - device_type=self.device_type.get('cc5000'), - site=self.site1, - rack=self.rack, - position=None, - face='', - ) - self.assertTrue(pdu) + """ + Check that a 0RU device can be mounted in a rack with no face/position. + """ + site = Site.objects.first() + rack = Rack.objects.first() + + Device( + name='Device 1', + device_role=DeviceRole.objects.first(), + device_type=DeviceType.objects.first(), + site=site, + rack=rack + ).save() + + def test_mount_half_u_devices(self): + """ + Check that two 0.5U devices can be mounted in the same rack unit. + """ + rack = Rack.objects.first() + attrs = { + 'device_type': DeviceType.objects.get(u_height=0.5), + 'device_role': DeviceRole.objects.first(), + 'site': Site.objects.first(), + 'rack': rack, + 'face': DeviceFaceChoices.FACE_FRONT, + } + + Device(name='Device 1', position=1, **attrs).save() + Device(name='Device 2', position=1.5, **attrs).save() + + self.assertEqual(len(rack.get_available_units()), rack.u_height * 2 - 3) def test_change_rack_site(self): """ @@ -224,19 +219,16 @@ class RackTestCase(TestCase): site_a = Site.objects.create(name='Site A', slug='site-a') site_b = Site.objects.create(name='Site B', slug='site-b') - manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' - ) - device_role = DeviceRole.objects.create( - name='Device Role 1', slug='device-role-1', color='ff0000' - ) - # Create Rack1 in Site A rack1 = Rack.objects.create(site=site_a, name='Rack 1') # Create Device1 in Rack1 - device1 = Device.objects.create(site=site_a, rack=rack1, device_type=device_type, device_role=device_role) + device1 = Device.objects.create( + site=site_a, + rack=rack1, + device_type=DeviceType.objects.first(), + device_role=DeviceRole.objects.first() + ) # Move Rack1 to Site B rack1.site = site_b @@ -465,7 +457,7 @@ class CableTestCase(TestCase): self.interface1 = Interface.objects.create(device=self.device1, name='eth0') self.interface2 = Interface.objects.create(device=self.device2, name='eth0') self.interface3 = Interface.objects.create(device=self.device2, name='eth1') - self.cable = Cable(termination_a=self.interface1, termination_b=self.interface2) + self.cable = Cable(a_terminations=[self.interface1], b_terminations=[self.interface2]) self.cable.save() self.power_port1 = PowerPort.objects.create(device=self.device2, name='psu1') @@ -501,12 +493,14 @@ class CableTestCase(TestCase): """ When a new Cable is created, it must be cached on either termination point. """ - interface1 = Interface.objects.get(pk=self.interface1.pk) - interface2 = Interface.objects.get(pk=self.interface2.pk) - self.assertEqual(self.cable.termination_a, interface1) - self.assertEqual(interface1._link_peer, interface2) - self.assertEqual(self.cable.termination_b, interface2) - self.assertEqual(interface2._link_peer, interface1) + self.interface1.refresh_from_db() + self.interface2.refresh_from_db() + self.assertEqual(self.interface1.cable, self.cable) + self.assertEqual(self.interface2.cable, self.cable) + self.assertEqual(self.interface1.cable_end, 'A') + self.assertEqual(self.interface2.cable_end, 'B') + self.assertEqual(self.interface1.link_peers, [self.interface2]) + self.assertEqual(self.interface2.link_peers, [self.interface1]) def test_cable_deletion(self): """ @@ -518,50 +512,33 @@ class CableTestCase(TestCase): self.assertNotEqual(str(self.cable), '#None') interface1 = Interface.objects.get(pk=self.interface1.pk) self.assertIsNone(interface1.cable) - self.assertIsNone(interface1._link_peer) + self.assertListEqual(interface1.link_peers, []) interface2 = Interface.objects.get(pk=self.interface2.pk) self.assertIsNone(interface2.cable) - self.assertIsNone(interface2._link_peer) + self.assertListEqual(interface2.link_peers, []) - def test_cabletermination_deletion(self): + def test_cable_validates_same_parent_object(self): """ - When a CableTermination object is deleted, its attached Cable (if any) must also be deleted. + The clean method should ensure that all terminations at either end of a Cable belong to the same parent object. """ - self.interface1.delete() - cable = Cable.objects.filter(pk=self.cable.pk).first() - self.assertIsNone(cable) + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) + with self.assertRaises(ValidationError): + cable.clean() + + def test_cable_validates_same_type(self): + """ + The clean method should ensure that all terminations at either end of a Cable are of the same type. + """ + cable = Cable(a_terminations=[self.front_port1, self.rear_port1], b_terminations=[self.interface1]) + with self.assertRaises(ValidationError): + cable.clean() def test_cable_validates_compatible_types(self): """ The clean method should have a check to ensure only compatible port types can be connected by a cable """ - # An interface cannot be connected to a power port - cable = Cable(termination_a=self.interface1, termination_b=self.power_port1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_cannot_have_the_same_terminination_on_both_ends(self): - """ - A cable cannot be made with the same A and B side terminations - """ - cable = Cable(termination_a=self.interface1, termination_b=self.interface1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_front_port_cannot_connect_to_corresponding_rear_port(self): - """ - A cable cannot connect a front port to its corresponding rear port - """ - cable = Cable(termination_a=self.front_port1, termination_b=self.rear_port1) - with self.assertRaises(ValidationError): - cable.clean() - - def test_cable_cannot_terminate_to_an_existing_connection(self): - """ - Either side of a cable cannot be terminated when that side already has a connection - """ - # Try to create a cable with the same interface terminations - cable = Cable(termination_a=self.interface2, termination_b=self.interface1) + # An interface cannot be connected to a power port, for example + cable = Cable(a_terminations=[self.interface1], b_terminations=[self.power_port1]) with self.assertRaises(ValidationError): cable.clean() @@ -569,45 +546,16 @@ class CableTestCase(TestCase): """ Neither side of a cable can be terminated to a CircuitTermination which is attached to a ProviderNetwork """ - cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + cable = Cable(a_terminations=[self.interface3], b_terminations=[self.circuittermination3]) with self.assertRaises(ValidationError): cable.clean() - def test_rearport_connections(self): - """ - Test various combinations of RearPort connections. - """ - # Connecting a single-position RearPort to a multi-position RearPort is ok - Cable(termination_a=self.rear_port1, termination_b=self.rear_port2).full_clean() - - # Connecting a single-position RearPort to an Interface is ok - Cable(termination_a=self.rear_port1, termination_b=self.interface3).full_clean() - - # Connecting a single-position RearPort to a CircuitTermination is ok - Cable(termination_a=self.rear_port1, termination_b=self.circuittermination1).full_clean() - - # Connecting a multi-position RearPort to another RearPort with the same number of positions is ok - Cable(termination_a=self.rear_port3, termination_b=self.rear_port4).full_clean() - - # Connecting a multi-position RearPort to an Interface is ok - Cable(termination_a=self.rear_port2, termination_b=self.interface3).full_clean() - - # Connecting a multi-position RearPort to a CircuitTermination is ok - Cable(termination_a=self.rear_port2, termination_b=self.circuittermination1).full_clean() - - # Connecting a two-position RearPort to a three-position RearPort is NOT ok - with self.assertRaises( - ValidationError, - msg='Connecting a 2-position RearPort to a 3-position RearPort should fail' - ): - Cable(termination_a=self.rear_port2, termination_b=self.rear_port3).full_clean() - def test_cable_cannot_terminate_to_a_virtual_interface(self): """ A cable cannot terminate to a virtual interface """ virtual_interface = Interface(device=self.device1, name="V1", type=InterfaceTypeChoices.TYPE_VIRTUAL) - cable = Cable(termination_a=self.interface2, termination_b=virtual_interface) + cable = Cable(a_terminations=[self.interface2], b_terminations=[virtual_interface]) with self.assertRaises(ValidationError): cable.clean() @@ -616,6 +564,6 @@ class CableTestCase(TestCase): A cable cannot terminate to a wireless interface """ wireless_interface = Interface(device=self.device1, name="W1", type=InterfaceTypeChoices.TYPE_80211A) - cable = Cable(termination_a=self.interface2, termination_b=wireless_interface) + cable = Cable(a_terminations=[self.interface2], b_terminations=[wireless_interface]) with self.assertRaises(ValidationError): cable.clean() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index e17f94682..a25267166 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -12,6 +12,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF +from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN @@ -175,9 +176,9 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1') locations = ( - Location(name='Location 1', slug='location-1', site=site, tenant=tenant), - Location(name='Location 2', slug='location-2', site=site, tenant=tenant), - Location(name='Location 3', slug='location-3', site=site, tenant=tenant), + Location(name='Location 1', slug='location-1', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 2', slug='location-2', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), + Location(name='Location 3', slug='location-3', site=site, status=LocationStatusChoices.STATUS_ACTIVE, tenant=tenant), ) for location in locations: location.save() @@ -188,16 +189,17 @@ class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): 'name': 'Location X', 'slug': 'location-x', 'site': site.pk, + 'status': LocationStatusChoices.STATUS_PLANNED, 'tenant': tenant.pk, 'description': 'A new location', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,tenant,name,slug,description", - "Site 1,Tenant 1,Location 4,location-4,Fourth location", - "Site 1,Tenant 1,Location 5,location-5,Fifth location", - "Site 1,Tenant 1,Location 6,location-6,Sixth location", + "site,tenant,name,slug,status,description", + "Site 1,Tenant 1,Location 4,location-4,planned,Fourth location", + "Site 1,Tenant 1,Location 5,location-5,planned,Fifth location", + "Site 1,Tenant 1,Location 6,location-6,planned,Sixth location", ) cls.bulk_edit_data = { @@ -1960,7 +1962,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=consoleport.device, name='Console Server Port 1' ) - Cable(termination_a=consoleport, termination_b=consoleserverport).save() + Cable(a_terminations=[consoleport], b_terminations=[consoleserverport]).save() response = self.client.get(reverse('dcim:consoleport_trace', kwargs={'pk': consoleport.pk})) self.assertHttpStatus(response, 200) @@ -2016,7 +2018,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=consoleserverport.device, name='Console Port 1' ) - Cable(termination_a=consoleserverport, termination_b=consoleport).save() + Cable(a_terminations=[consoleserverport], b_terminations=[consoleport]).save() response = self.client.get(reverse('dcim:consoleserverport_trace', kwargs={'pk': consoleserverport.pk})) self.assertHttpStatus(response, 200) @@ -2078,7 +2080,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=powerport.device, name='Power Outlet 1' ) - Cable(termination_a=powerport, termination_b=poweroutlet).save() + Cable(a_terminations=[powerport], b_terminations=[poweroutlet]).save() response = self.client.get(reverse('dcim:powerport_trace', kwargs={'pk': powerport.pk})) self.assertHttpStatus(response, 200) @@ -2143,7 +2145,7 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase): def test_trace(self): poweroutlet = PowerOutlet.objects.first() powerport = PowerPort.objects.first() - Cable(termination_a=poweroutlet, termination_b=powerport).save() + Cable(a_terminations=[poweroutlet], b_terminations=[powerport]).save() response = self.client.get(reverse('dcim:poweroutlet_trace', kwargs={'pk': poweroutlet.pk})) self.assertHttpStatus(response, 200) @@ -2204,6 +2206,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], @@ -2225,6 +2229,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', + 'poe_mode': InterfacePoEModeChoices.MODE_PSE, + 'poe_type': InterfacePoETypeChoices.TYPE_1_8023AF, 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], @@ -2244,6 +2250,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', + 'poe_mode': InterfacePoEModeChoices.MODE_PD, + 'poe_type': InterfacePoETypeChoices.TYPE_2_8023AT, 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, 'untagged_vlan': vlans[0].pk, @@ -2252,16 +2260,16 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.csv_data = ( - f"device,name,type,vrf.pk", - f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", - f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", + f"device,name,type,vrf.pk,poe_mode,poe_type", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk},pse,type1-ieee802.3af", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_trace(self): interface1, interface2 = Interface.objects.all()[:2] - Cable(termination_a=interface1, termination_b=interface2).save() + Cable(a_terminations=[interface1], b_terminations=[interface2]).save() response = self.client.get(reverse('dcim:interface_trace', kwargs={'pk': interface1.pk})) self.assertHttpStatus(response, 200) @@ -2332,7 +2340,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=frontport.device, name='Interface 1' ) - Cable(termination_a=frontport, termination_b=interface).save() + Cable(a_terminations=[frontport], b_terminations=[interface]).save() response = self.client.get(reverse('dcim:frontport_trace', kwargs={'pk': frontport.pk})) self.assertHttpStatus(response, 200) @@ -2390,7 +2398,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase): device=rearport.device, name='Interface 1' ) - Cable(termination_a=rearport, termination_b=interface).save() + Cable(a_terminations=[rearport], b_terminations=[interface]).save() response = self.client.get(reverse('dcim:rearport_trace', kwargs={'pk': rearport.pk})) self.assertHttpStatus(response, 200) @@ -2623,19 +2631,18 @@ class CableTestCase( ) Interface.objects.bulk_create(interfaces) - Cable(termination_a=interfaces[0], termination_b=interfaces[3], type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=interfaces[1], termination_b=interfaces[4], type=CableTypeChoices.TYPE_CAT6).save() - Cable(termination_a=interfaces[2], termination_b=interfaces[5], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[0]], b_terminations=[interfaces[3]], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[1]], b_terminations=[interfaces[4]], type=CableTypeChoices.TYPE_CAT6).save() + Cable(a_terminations=[interfaces[2]], b_terminations=[interfaces[5]], type=CableTypeChoices.TYPE_CAT6).save() tags = create_tags('Alpha', 'Bravo', 'Charlie') interface_ct = ContentType.objects.get_for_model(Interface) cls.form_data = { + # TODO: Revisit this limitation # Changing terminations not supported when editing an existing Cable - 'termination_a_type': interface_ct.pk, - 'termination_a_id': interfaces[0].pk, - 'termination_b_type': interface_ct.pk, - 'termination_b_id': interfaces[3].pk, + 'a_terminations': [interfaces[0].pk], + 'b_terminations': [interfaces[3].pk], 'type': CableTypeChoices.TYPE_CAT6, 'status': LinkStatusChoices.STATUS_PLANNED, 'label': 'Label', @@ -2661,6 +2668,17 @@ class CableTestCase( 'length_unit': CableLengthUnitChoices.UNIT_METER, } + def model_to_dict(self, *args, **kwargs): + data = super().model_to_dict(*args, **kwargs) + + # Serialize termination objects + if 'a_terminations' in data: + data['a_terminations'] = [obj.pk for obj in data['a_terminations']] + if 'b_terminations' in data: + data['b_terminations'] = [obj.pk for obj in data['b_terminations']] + + return data + class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VirtualChassis @@ -2857,7 +2875,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase): device=device, name='Power Port 1' ) - Cable(termination_a=powerfeed, termination_b=powerport).save() + Cable(a_terminations=[powerfeed], b_terminations=[powerport]).save() response = self.client.get(reverse('dcim:powerfeed_trace', kwargs={'pk': powerfeed.pk})) self.assertHttpStatus(response, 200) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index f00bd73e1..c11a92a99 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -295,7 +295,6 @@ urlpatterns = [ path('console-ports//delete/', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), path('console-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleport_changelog', kwargs={'model': ConsolePort}), path('console-ports//trace/', views.PathTraceView.as_view(), name='consoleport_trace', kwargs={'model': ConsolePort}), - path('console-ports//connect//', views.CableCreateView.as_view(), name='consoleport_connect', kwargs={'termination_a_type': ConsolePort}), path('devices/console-ports/add/', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), # Console server ports @@ -311,7 +310,6 @@ urlpatterns = [ path('console-server-ports//delete/', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), path('console-server-ports//changelog/', ObjectChangeLogView.as_view(), name='consoleserverport_changelog', kwargs={'model': ConsoleServerPort}), path('console-server-ports//trace/', views.PathTraceView.as_view(), name='consoleserverport_trace', kwargs={'model': ConsoleServerPort}), - path('console-server-ports//connect//', views.CableCreateView.as_view(), name='consoleserverport_connect', kwargs={'termination_a_type': ConsoleServerPort}), path('devices/console-server-ports/add/', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), # Power ports @@ -327,7 +325,6 @@ urlpatterns = [ path('power-ports//delete/', views.PowerPortDeleteView.as_view(), name='powerport_delete'), path('power-ports//changelog/', ObjectChangeLogView.as_view(), name='powerport_changelog', kwargs={'model': PowerPort}), path('power-ports//trace/', views.PathTraceView.as_view(), name='powerport_trace', kwargs={'model': PowerPort}), - path('power-ports//connect//', views.CableCreateView.as_view(), name='powerport_connect', kwargs={'termination_a_type': PowerPort}), path('devices/power-ports/add/', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), # Power outlets @@ -343,7 +340,6 @@ urlpatterns = [ path('power-outlets//delete/', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), path('power-outlets//changelog/', ObjectChangeLogView.as_view(), name='poweroutlet_changelog', kwargs={'model': PowerOutlet}), path('power-outlets//trace/', views.PathTraceView.as_view(), name='poweroutlet_trace', kwargs={'model': PowerOutlet}), - path('power-outlets//connect//', views.CableCreateView.as_view(), name='poweroutlet_connect', kwargs={'termination_a_type': PowerOutlet}), path('devices/power-outlets/add/', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), # Interfaces @@ -359,7 +355,6 @@ urlpatterns = [ path('interfaces//delete/', views.InterfaceDeleteView.as_view(), name='interface_delete'), path('interfaces//changelog/', ObjectChangeLogView.as_view(), name='interface_changelog', kwargs={'model': Interface}), path('interfaces//trace/', views.PathTraceView.as_view(), name='interface_trace', kwargs={'model': Interface}), - path('interfaces//connect//', views.CableCreateView.as_view(), name='interface_connect', kwargs={'termination_a_type': Interface}), path('devices/interfaces/add/', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), # Front ports @@ -375,7 +370,6 @@ urlpatterns = [ path('front-ports//delete/', views.FrontPortDeleteView.as_view(), name='frontport_delete'), path('front-ports//changelog/', ObjectChangeLogView.as_view(), name='frontport_changelog', kwargs={'model': FrontPort}), path('front-ports//trace/', views.PathTraceView.as_view(), name='frontport_trace', kwargs={'model': FrontPort}), - path('front-ports//connect//', views.CableCreateView.as_view(), name='frontport_connect', kwargs={'termination_a_type': FrontPort}), # path('devices/front-ports/add/', views.DeviceBulkAddFrontPortView.as_view(), name='device_bulk_add_frontport'), # Rear ports @@ -391,7 +385,6 @@ urlpatterns = [ path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports//changelog/', ObjectChangeLogView.as_view(), name='rearport_changelog', kwargs={'model': RearPort}), path('rear-ports//trace/', views.PathTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - path('rear-ports//connect//', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}), path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Module bays @@ -448,6 +441,7 @@ urlpatterns = [ # Cables path('cables/', views.CableListView.as_view(), name='cable_list'), + path('cables/add/', views.CableEditView.as_view(), name='cable_add'), path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'), path('cables/edit/', views.CableBulkEditView.as_view(), name='cable_bulk_edit'), path('cables/delete/', views.CableBulkDeleteView.as_view(), name='cable_bulk_delete'), @@ -501,6 +495,5 @@ urlpatterns = [ path('power-feeds//trace/', views.PathTraceView.as_view(), name='powerfeed_trace', kwargs={'model': PowerFeed}), path('power-feeds//changelog/', ObjectChangeLogView.as_view(), name='powerfeed_changelog', kwargs={'model': PowerFeed}), path('power-feeds//journal/', ObjectJournalView.as_view(), name='powerfeed_journal', kwargs={'model': PowerFeed}), - path('power-feeds//connect//', views.CableCreateView.as_view(), name='powerfeed_connect', kwargs={'termination_a_type': PowerFeed}), ] diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py index ec3a44603..eadd2da96 100644 --- a/netbox/dcim/utils.py +++ b/netbox/dcim/utils.py @@ -1,3 +1,5 @@ +import itertools + from django.contrib.contenttypes.models import ContentType from django.db import transaction @@ -22,34 +24,37 @@ def object_to_path_node(obj): def path_node_to_object(repr): """ - Given the string representation of a path node, return the corresponding instance. + Given the string representation of a path node, return the corresponding instance. If the object no longer + exists, return None. """ ct_id, object_id = decompile_path_node(repr) ct = ContentType.objects.get_for_id(ct_id) - return ct.model_class().objects.get(pk=object_id) + return ct.model_class().objects.filter(pk=object_id).first() -def create_cablepath(node): +def create_cablepath(terminations): """ - Create CablePaths for all paths originating from the specified node. + Create CablePaths for all paths originating from the specified set of nodes. + + :param terminations: Iterable of CableTermination objects """ from dcim.models import CablePath - cp = CablePath.from_origin(node) + cp = CablePath.from_origin(terminations) if cp: cp.save() -def rebuild_paths(obj): +def rebuild_paths(terminations): """ - Rebuild all CablePaths which traverse the specified node + Rebuild all CablePaths which traverse the specified nodes. """ from dcim.models import CablePath - cable_paths = CablePath.objects.filter(path__contains=obj) + for obj in terminations: + cable_paths = CablePath.objects.filter(_nodes__contains=obj) - with transaction.atomic(): - for cp in cable_paths: - cp.delete() - if cp.origin: - create_cablepath(cp.origin) + with transaction.atomic(): + for cp in cable_paths: + cp.delete() + create_cablepath(cp.origins) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 6e77d4396..c807176d4 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.paginator import EmptyPage, PageNotAnInteger @@ -12,7 +10,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from django.views.generic import View -from circuits.models import Circuit +from circuits.models import Circuit, CircuitTermination from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, Service, VLAN, VLANGroup from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable @@ -28,6 +26,18 @@ from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES from .models import * +CABLE_TERMINATION_TYPES = { + 'dcim.consoleport': ConsolePort, + 'dcim.consoleserverport': ConsoleServerPort, + 'dcim.powerport': PowerPort, + 'dcim.poweroutlet': PowerOutlet, + 'dcim.interface': Interface, + 'dcim.frontport': FrontPort, + 'dcim.rearport': RearPort, + 'dcim.powerfeed': PowerFeed, + 'circuits.circuittermination': CircuitTermination, +} + class DeviceComponentsView(generic.ObjectChildrenView): queryset = Device.objects.all() @@ -312,7 +322,7 @@ class SiteListView(generic.ObjectListView): class SiteView(generic.ObjectView): - queryset = Site.objects.prefetch_related('region', 'tenant__group') + queryset = Site.objects.prefetch_related('tenant__group') def get_extra_context(self, request, instance): stats = { @@ -347,7 +357,7 @@ class SiteView(generic.ObjectView): site=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') asns = ASN.objects.restrict(request.user, 'view').filter(sites=instance) asn_count = asns.count() @@ -379,14 +389,14 @@ class SiteBulkImportView(generic.BulkImportView): class SiteBulkEditView(generic.BulkEditView): - queryset = Site.objects.prefetch_related('region', 'tenant') + queryset = Site.objects.all() filterset = filtersets.SiteFilterSet table = tables.SiteTable form = forms.SiteBulkEditForm class SiteBulkDeleteView(generic.BulkDeleteView): - queryset = Site.objects.prefetch_related('region', 'tenant') + queryset = Site.objects.all() filterset = filtersets.SiteFilterSet table = tables.SiteTable @@ -442,7 +452,7 @@ class LocationView(generic.ObjectView): location=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') return { 'rack_count': rack_count, @@ -561,7 +571,7 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackListView(generic.ObjectListView): required_prerequisites = [Site] - queryset = Rack.objects.prefetch_related('devices__device_type').annotate( + queryset = Rack.objects.annotate( device_count=count_related(Device, 'rack') ) filterset = filtersets.RackFilterSet @@ -620,7 +630,7 @@ class RackView(generic.ObjectView): rack=instance, position__isnull=True, parent_bay__isnull=True - ).prefetch_related('device_type__manufacturer') + ).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role') peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) @@ -638,6 +648,11 @@ class RackView(generic.ObjectView): device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count() + # Determine any additional parameters to pass when embedding the rack elevations + svg_extra = '&'.join([ + f'highlight=id:{pk}' for pk in request.GET.getlist('device') + ]) + return { 'device_count': device_count, 'reservations': reservations, @@ -645,6 +660,7 @@ class RackView(generic.ObjectView): 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, + 'svg_extra': svg_extra, } @@ -665,14 +681,14 @@ class RackBulkImportView(generic.BulkImportView): class RackBulkEditView(generic.BulkEditView): - queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + queryset = Rack.objects.all() filterset = filtersets.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm class RackBulkDeleteView(generic.BulkDeleteView): - queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') + queryset = Rack.objects.all() filterset = filtersets.RackFilterSet table = tables.RackTable @@ -689,7 +705,7 @@ class RackReservationListView(generic.ObjectListView): class RackReservationView(generic.ObjectView): - queryset = RackReservation.objects.prefetch_related('rack') + queryset = RackReservation.objects.all() class RackReservationEditView(generic.ObjectEditView): @@ -725,14 +741,14 @@ class RackReservationImportView(generic.BulkImportView): class RackReservationBulkEditView(generic.BulkEditView): - queryset = RackReservation.objects.prefetch_related('rack', 'user') + queryset = RackReservation.objects.all() filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable form = forms.RackReservationBulkEditForm class RackReservationBulkDeleteView(generic.BulkDeleteView): - queryset = RackReservation.objects.prefetch_related('rack', 'user') + queryset = RackReservation.objects.all() filterset = filtersets.RackReservationFilterSet table = tables.RackReservationTable @@ -814,7 +830,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): # class DeviceTypeListView(generic.ObjectListView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -823,7 +839,7 @@ class DeviceTypeListView(generic.ObjectListView): class DeviceTypeView(generic.ObjectView): - queryset = DeviceType.objects.prefetch_related('manufacturer') + queryset = DeviceType.objects.all() def get_extra_context(self, request, instance): instance_count = Device.objects.restrict(request.user).filter(device_type=instance).count() @@ -928,18 +944,18 @@ class DeviceTypeImportView(generic.ObjectImportView): ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm - related_object_forms = OrderedDict(( - ('console-ports', forms.ConsolePortTemplateImportForm), - ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), - ('power-ports', forms.PowerPortTemplateImportForm), - ('power-outlets', forms.PowerOutletTemplateImportForm), - ('interfaces', forms.InterfaceTemplateImportForm), - ('rear-ports', forms.RearPortTemplateImportForm), - ('front-ports', forms.FrontPortTemplateImportForm), - ('module-bays', forms.ModuleBayTemplateImportForm), - ('device-bays', forms.DeviceBayTemplateImportForm), - ('inventory-items', forms.InventoryItemTemplateImportForm), - )) + related_object_forms = { + 'console-ports': forms.ConsolePortTemplateImportForm, + 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, + 'power-ports': forms.PowerPortTemplateImportForm, + 'power-outlets': forms.PowerOutletTemplateImportForm, + 'interfaces': forms.InterfaceTemplateImportForm, + 'rear-ports': forms.RearPortTemplateImportForm, + 'front-ports': forms.FrontPortTemplateImportForm, + 'module-bays': forms.ModuleBayTemplateImportForm, + 'device-bays': forms.DeviceBayTemplateImportForm, + 'inventory-items': forms.InventoryItemTemplateImportForm, + } def prep_related_object_data(self, parent, data): data.update({'device_type': parent}) @@ -947,7 +963,7 @@ class DeviceTypeImportView(generic.ObjectImportView): class DeviceTypeBulkEditView(generic.BulkEditView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -956,7 +972,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView): - queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( + queryset = DeviceType.objects.annotate( instance_count=count_related(Device, 'device_type') ) filterset = filtersets.DeviceTypeFilterSet @@ -968,7 +984,7 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView): # class ModuleTypeListView(generic.ObjectListView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -977,7 +993,7 @@ class ModuleTypeListView(generic.ObjectListView): class ModuleTypeView(generic.ObjectView): - queryset = ModuleType.objects.prefetch_related('manufacturer') + queryset = ModuleType.objects.all() def get_extra_context(self, request, instance): instance_count = Module.objects.restrict(request.user).filter(module_type=instance).count() @@ -1058,15 +1074,15 @@ class ModuleTypeImportView(generic.ObjectImportView): ] queryset = ModuleType.objects.all() model_form = forms.ModuleTypeImportForm - related_object_forms = OrderedDict(( - ('console-ports', forms.ConsolePortTemplateImportForm), - ('console-server-ports', forms.ConsoleServerPortTemplateImportForm), - ('power-ports', forms.PowerPortTemplateImportForm), - ('power-outlets', forms.PowerOutletTemplateImportForm), - ('interfaces', forms.InterfaceTemplateImportForm), - ('rear-ports', forms.RearPortTemplateImportForm), - ('front-ports', forms.FrontPortTemplateImportForm), - )) + related_object_forms = { + 'console-ports': forms.ConsolePortTemplateImportForm, + 'console-server-ports': forms.ConsoleServerPortTemplateImportForm, + 'power-ports': forms.PowerPortTemplateImportForm, + 'power-outlets': forms.PowerOutletTemplateImportForm, + 'interfaces': forms.InterfaceTemplateImportForm, + 'rear-ports': forms.RearPortTemplateImportForm, + 'front-ports': forms.FrontPortTemplateImportForm, + } def prep_related_object_data(self, parent, data): data.update({'module_type': parent}) @@ -1074,7 +1090,7 @@ class ModuleTypeImportView(generic.ObjectImportView): class ModuleTypeBulkEditView(generic.BulkEditView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -1083,7 +1099,7 @@ class ModuleTypeBulkEditView(generic.BulkEditView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView): - queryset = ModuleType.objects.prefetch_related('manufacturer').annotate( + queryset = ModuleType.objects.annotate( instance_count=count_related(Module, 'module_type') ) filterset = filtersets.ModuleTypeFilterSet @@ -1594,9 +1610,7 @@ class DeviceListView(generic.ObjectListView): class DeviceView(generic.ObjectView): - queryset = Device.objects.prefetch_related( - 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' - ) + queryset = Device.objects.all() def get_extra_context(self, request, instance): # VirtualChassis members @@ -1710,7 +1724,7 @@ class DeviceLLDPNeighborsView(generic.ObjectView): def get_extra_context(self, request, instance): interfaces = instance.vc_interfaces().restrict(request.user, 'view').prefetch_related( - '_path__destination' + '_path' ).exclude( type__in=NONCONNECTABLE_IFACE_TYPES ) @@ -1773,14 +1787,14 @@ class ChildDeviceBulkImportView(generic.BulkImportView): class DeviceBulkEditView(generic.BulkEditView): - queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('device_type__manufacturer') filterset = filtersets.DeviceFilterSet table = tables.DeviceTable form = forms.DeviceBulkEditForm class DeviceBulkDeleteView(generic.BulkDeleteView): - queryset = Device.objects.prefetch_related('tenant', 'site', 'rack', 'device_role', 'device_type__manufacturer') + queryset = Device.objects.prefetch_related('device_type__manufacturer') filterset = filtersets.DeviceFilterSet table = tables.DeviceTable @@ -1796,7 +1810,7 @@ class DeviceBulkRenameView(generic.BulkRenameView): # class ModuleListView(generic.ObjectListView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet filterset_form = forms.ModuleFilterForm table = tables.ModuleTable @@ -1822,14 +1836,14 @@ class ModuleBulkImportView(generic.BulkImportView): class ModuleBulkEditView(generic.BulkEditView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet table = tables.ModuleTable form = forms.ModuleBulkEditForm class ModuleBulkDeleteView(generic.BulkDeleteView): - queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer') + queryset = Module.objects.prefetch_related('module_type__manufacturer') filterset = filtersets.ModuleFilterSet table = tables.ModuleTable @@ -2555,7 +2569,7 @@ class InventoryItemBulkImportView(generic.BulkImportView): class InventoryItemBulkEditView(generic.BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') + queryset = InventoryItem.objects.all() filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -2566,7 +2580,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') + queryset = InventoryItem.objects.all() table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' @@ -2744,7 +2758,10 @@ class DeviceBulkAddInventoryItemView(generic.BulkComponentCreateView): # class CableListView(generic.ObjectListView): - queryset = Cable.objects.all() + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet filterset_form = forms.CableFilterForm table = tables.CableTable @@ -2777,7 +2794,7 @@ class PathTraceView(generic.ObjectView): # Otherwise, find all CablePaths which traverse the specified object else: - related_paths = CablePath.objects.filter(path__contains=instance).prefetch_related('origin') + related_paths = CablePath.objects.filter(_nodes__contains=instance) # Check for specification of a particular path (when tracing pass-through ports) try: path_id = int(request.GET.get('cablepath_id')) @@ -2798,8 +2815,8 @@ class PathTraceView(generic.ObjectView): total_length, is_definitive = path.get_total_length() if path else (None, False) # Determine the path to the SVG trace image - api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace" - svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg" + api_viewname = f"{path.origin_type.app_label}-api:{path.origin_type.model}-trace" + svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origins[0].pk})}?render=svg" return { 'path': path, @@ -2810,77 +2827,38 @@ class PathTraceView(generic.ObjectView): } -class CableCreateView(generic.ObjectEditView): +class CableEditView(generic.ObjectEditView): queryset = Cable.objects.all() - template_name = 'dcim/cable_connect.html' + template_name = 'dcim/cable_edit.html' def dispatch(self, request, *args, **kwargs): - # Set the form class based on the type of component being connected - self.form = { - '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, - }[kwargs.get('termination_b_type')] + # If creating a new Cable, initialize the form class using URL query params + if 'pk' not in kwargs: + self.form = forms.get_cable_form( + a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')), + b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type')) + ) return super().dispatch(request, *args, **kwargs) def get_object(self, **kwargs): - # Always return a new instance - return self.queryset.model() + """ + Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView + doesn't currently provide a hook for dynamic class resolution. + """ + obj = super().get_object(**kwargs) - def alter_object(self, obj, request, url_args, url_kwargs): - termination_a_type = url_kwargs.get('termination_a_type') - termination_a_id = url_kwargs.get('termination_a_id') - termination_b_type_name = url_kwargs.get('termination_b_type') - self.termination_b_type = ContentType.objects.get(model=termination_b_type_name.replace('-', '')) - - # Initialize Cable termination attributes - obj.termination_a = termination_a_type.objects.get(pk=termination_a_id) - obj.termination_b_type = self.termination_b_type + if obj.pk: + # TODO: Optimize this logic + termination_a = obj.terminations.filter(cable_end='A').first() + a_type = termination_a.termination._meta.model if termination_a else None + termination_b = obj.terminations.filter(cable_end='B').first() + b_type = termination_b.termination._meta.model if termination_b else None + self.form = forms.get_cable_form(a_type, b_type) return obj - def get(self, request, *args, **kwargs): - obj = self.get_object(**kwargs) - obj = self.alter_object(obj, 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} - - # Set initial site and rack based on side A termination (if not already set) - termination_a_site = getattr(obj.termination_a.parent_object, 'site', None) - if termination_a_site and 'termination_b_region' not in initial_data: - initial_data['termination_b_region'] = termination_a_site.region - if termination_a_site and 'termination_b_site_group' not in initial_data: - initial_data['termination_b_site_group'] = termination_a_site.group - if 'termination_b_site' not in initial_data: - initial_data['termination_b_site'] = termination_a_site - if 'termination_b_rack' not in initial_data: - initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None) - - form = self.form(instance=obj, initial=initial_data) - - return render(request, self.template_name, { - 'obj': obj, - 'obj_type': Cable._meta.verbose_name, - 'termination_b_type': self.termination_b_type.name, - 'form': form, - 'return_url': self.get_return_url(request, obj), - }) - - -class CableEditView(generic.ObjectEditView): - queryset = Cable.objects.all() - form = forms.CableForm - template_name = 'dcim/cable_edit.html' - class CableDeleteView(generic.ObjectDeleteView): queryset = Cable.objects.all() @@ -2893,14 +2871,20 @@ class CableBulkImportView(generic.BulkImportView): class CableBulkEditView(generic.BulkEditView): - queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet table = tables.CableTable form = forms.CableBulkEditForm class CableBulkDeleteView(generic.BulkDeleteView): - queryset = Cable.objects.prefetch_related('termination_a', 'termination_b') + queryset = Cable.objects.prefetch_related( + 'terminations__termination', 'terminations___device', 'terminations___rack', 'terminations___location', + 'terminations___site', + ) filterset = filtersets.CableFilterSet table = tables.CableTable @@ -2956,7 +2940,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): # class VirtualChassisListView(generic.ObjectListView): - queryset = VirtualChassis.objects.prefetch_related('master').annotate( + queryset = VirtualChassis.objects.annotate( member_count=count_related(Device, 'virtual_chassis') ) table = tables.VirtualChassisTable @@ -3183,9 +3167,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView): # class PowerPanelListView(generic.ObjectListView): - queryset = PowerPanel.objects.prefetch_related( - 'site', 'location' - ).annotate( + queryset = PowerPanel.objects.annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filtersets.PowerPanelFilterSet @@ -3194,10 +3176,10 @@ class PowerPanelListView(generic.ObjectListView): class PowerPanelView(generic.ObjectView): - queryset = PowerPanel.objects.prefetch_related('site', 'location') + queryset = PowerPanel.objects.all() def get_extra_context(self, request, instance): - power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack') + power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance) powerfeed_table = tables.PowerFeedTable( data=power_feeds, orderable=False @@ -3227,16 +3209,14 @@ class PowerPanelBulkImportView(generic.BulkImportView): class PowerPanelBulkEditView(generic.BulkEditView): - queryset = PowerPanel.objects.prefetch_related('site', 'location') + queryset = PowerPanel.objects.all() filterset = filtersets.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm class PowerPanelBulkDeleteView(generic.BulkDeleteView): - queryset = PowerPanel.objects.prefetch_related( - 'site', 'location' - ).annotate( + queryset = PowerPanel.objects.annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filtersets.PowerPanelFilterSet @@ -3255,7 +3235,7 @@ class PowerFeedListView(generic.ObjectListView): class PowerFeedView(generic.ObjectView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() class PowerFeedEditView(generic.ObjectEditView): @@ -3274,7 +3254,7 @@ class PowerFeedBulkImportView(generic.BulkImportView): class PowerFeedBulkEditView(generic.BulkEditView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable form = forms.PowerFeedBulkEditForm @@ -3285,6 +3265,6 @@ class PowerFeedBulkDisconnectView(BulkDisconnectView): class PowerFeedBulkDeleteView(generic.BulkDeleteView): - queryset = PowerFeed.objects.prefetch_related('power_panel', 'rack') + queryset = PowerFeed.objects.all() filterset = filtersets.PowerFeedFilterSet table = tables.PowerFeedTable diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 4acde31ab..44dfe7cbc 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers from extras import choices, models -from netbox.api import ChoiceField, WritableNestedSerializer -from netbox.api.serializers import NestedTagSerializer +from netbox.api.fields import ChoiceField +from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer from users.api.nested_serializers import NestedUserSerializer __all__ = [ diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a4e3c6609..69792e88c 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,15 +5,15 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer, - NestedSiteSerializer, NestedSiteGroupSerializer, + NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedLocationSerializer, NestedPlatformSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import BaseModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer @@ -85,13 +85,14 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) data_type = serializers.SerializerMethodField() + ui_visibility = ChoiceField(choices=CustomFieldVisibilityChoices, required=False) class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'description', - 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', 'choices', 'created', 'last_updated', + 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', + 'description', 'required', 'filter_logic', 'ui_visibility', 'default', 'weight', 'validation_minimum', + 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', ] def get_data_type(self, obj): @@ -272,6 +273,12 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + locations = SerializedPKRelatedField( + queryset=Location.objects.all(), + serializer=NestedLocationSerializer, + required=False, + many=True + ) device_types = SerializedPKRelatedField( queryset=DeviceType.objects.all(), serializer=NestedDeviceTypeSerializer, @@ -331,8 +338,8 @@ class ConfigContextSerializer(ValidatedModelSerializer): model = ConfigContext fields = [ 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', - 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', - 'tenants', 'tags', 'data', 'created', 'last_updated', + 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', + 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', ] diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index dd6a5aeff..bcad6b77c 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 688f3c7ab..82c68c86d 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -138,7 +138,7 @@ class JournalEntryViewSet(NetBoxModelViewSet): class ConfigContextViewSet(NetBoxModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'locations', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filtersets.ConfigContextFilterSet diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index f14368d3d..123fd2cd4 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -47,6 +47,19 @@ class CustomFieldFilterLogicChoices(ChoiceSet): ) +class CustomFieldVisibilityChoices(ChoiceSet): + + VISIBILITY_READ_WRITE = 'read-write' + VISIBILITY_READ_ONLY = 'read-only' + VISIBILITY_HIDDEN = 'hidden' + + CHOICES = ( + (VISIBILITY_READ_WRITE, 'Read/Write'), + (VISIBILITY_READ_ONLY, 'Read-only'), + (VISIBILITY_HIDDEN, 'Hidden'), + ) + + # # CustomLinks # diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bb8d16c42..df0af3541 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from tenancy.models import Tenant, TenantGroup from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter @@ -71,7 +71,10 @@ class CustomFieldFilterSet(BaseFilterSet): class Meta: model = CustomField - fields = ['id', 'name', 'required', 'filter_logic', 'weight', 'description'] + fields = [ + 'id', 'content_types', 'name', 'group_name', 'required', 'filter_logic', 'ui_visibility', 'weight', + 'description', + ] def search(self, queryset, name, value): if not value.strip(): @@ -79,6 +82,7 @@ class CustomFieldFilterSet(BaseFilterSet): return queryset.filter( Q(name__icontains=value) | Q(label__icontains=value) | + Q(group_name__icontains=value) | Q(description__icontains=value) ) @@ -260,6 +264,17 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet): to_field_name='slug', label='Site (slug)', ) + location_id = django_filters.ModelMultipleChoiceFilter( + field_name='locations', + queryset=Location.objects.all(), + label='Location', + ) + location = django_filters.ModelMultipleChoiceFilter( + field_name='locations__slug', + queryset=Location.objects.all(), + to_field_name='slug', + label='Location (slug)', + ) device_type_id = django_filters.ModelMultipleChoiceFilter( field_name='device_types', queryset=DeviceType.objects.all(), diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index e16f8aeac..b1d8a6c21 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -24,6 +24,9 @@ class CustomFieldBulkEditForm(BulkEditForm): queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput ) + group_name = forms.CharField( + required=False + ) description = forms.CharField( required=False ) @@ -34,8 +37,15 @@ class CustomFieldBulkEditForm(BulkEditForm): weight = forms.IntegerField( required=False ) + ui_visibility = forms.ChoiceField( + label="UI visibility", + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + initial='', + widget=StaticSelect() + ) - nullable_fields = ('description',) + nullable_fields = ('group_name', 'description',) class CustomLinkBulkEditForm(BulkEditForm): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 878b83c7c..d9148a5c3 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -42,9 +42,9 @@ class CustomFieldCSVForm(CSVModelForm): class Meta: model = CustomField fields = ( - 'name', 'label', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', + 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', - 'validation_regex', + 'validation_regex', 'ui_visibility', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index bb8028eec..7574f4f2b 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from extras.models import * +from extras.choices import CustomFieldVisibilityChoices __all__ = ( 'CustomFieldsMixin', @@ -18,6 +19,7 @@ class CustomFieldsMixin: def __init__(self, *args, **kwargs): self.custom_fields = {} + self.custom_field_groups = {} super().__init__(*args, **kwargs) @@ -42,8 +44,21 @@ class CustomFieldsMixin: Append form fields for all CustomFields assigned to this object type. """ for customfield in self._get_custom_fields(self._get_content_type()): + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) + if customfield.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY: + self.fields[field_name].disabled = True + if self.fields[field_name].help_text: + self.fields[field_name].help_text += '
    ' + self.fields[field_name].help_text += ' ' \ + 'Field is set to read-only.' + # Annotate the field in the list of CustomField form fields self.custom_fields[field_name] = customfield + if customfield.group_name not in self.custom_field_groups: + self.custom_field_groups[customfield.group_name] = [] + self.custom_field_groups[customfield.group_name].append(field_name) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 71bcfd4c2..526d47013 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -3,7 +3,7 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -32,7 +32,7 @@ __all__ = ( class CustomFieldFilterForm(FilterForm): fieldsets = ( (None, ('q',)), - ('Attributes', ('type', 'content_type_id', 'weight', 'required')), + ('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')), ) content_type_id = ContentTypeMultipleChoiceField( queryset=ContentType.objects.all(), @@ -45,6 +45,9 @@ class CustomFieldFilterForm(FilterForm): required=False, label=_('Field type') ) + group_name = forms.CharField( + required=False + ) weight = forms.IntegerField( required=False ) @@ -54,6 +57,12 @@ class CustomFieldFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + ui_visibility = forms.ChoiceField( + choices=add_blank_choice(CustomFieldVisibilityChoices), + required=False, + label=_('UI visibility'), + widget=StaticSelect() + ) class CustomLinkFilterForm(FilterForm): @@ -163,7 +172,7 @@ class TagFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm): fieldsets = ( (None, ('q', 'tag_id')), - ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), ('Device', ('device_type_id', 'platform_id', 'role_id')), ('Cluster', ('cluster_type_id', 'cluster_group_id', 'cluster_id')), ('Tenant', ('tenant_group_id', 'tenant_id')) @@ -183,6 +192,11 @@ class ConfigContextFilterForm(FilterForm): required=False, label=_('Sites') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Locations') + ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index 82575de21..bea1fbcc1 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -1,7 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType -from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup +from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.choices import * from extras.models import * from extras.utils import FeatureQuery @@ -40,8 +40,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): ) fieldsets = ( - ('Custom Field', ('content_types', 'name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), - ('Behavior', ('filter_logic',)), + ('Custom Field', ( + 'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'weight', 'required', 'description', + )), + ('Behavior', ('filter_logic', 'ui_visibility')), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) @@ -56,6 +58,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): widgets = { 'type': StaticSelect(), 'filter_logic': StaticSelect(), + 'ui_visibility': StaticSelect(), } @@ -164,6 +167,10 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Site.objects.all(), required=False ) + locations = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False + ) device_types = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False @@ -200,15 +207,22 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Tag.objects.all(), required=False ) - data = JSONField( - label='' + data = JSONField() + + fieldsets = ( + ('Config Context', ('name', 'weight', 'description', 'data', 'is_active')), + ('Assignment', ( + 'regions', 'site_groups', 'sites', 'locations', 'device_types', 'roles', 'platforms', 'cluster_types', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + )), ) class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'data', 'is_active', 'regions', 'site_groups', 'sites', 'locations', + 'roles', 'device_types', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', + 'tenants', 'tags', ) diff --git a/netbox/extras/migrations/0074_customfield_extensions.py b/netbox/extras/migrations/0074_customfield_extensions.py new file mode 100644 index 000000000..6ca8b958f --- /dev/null +++ b/netbox/extras/migrations/0074_customfield_extensions.py @@ -0,0 +1,27 @@ +# Generated by Django 4.0.4 on 2022-04-15 17:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0073_journalentry_tags_custom_fields'), + ] + + operations = [ + migrations.AlterModelOptions( + name='customfield', + options={'ordering': ['group_name', 'weight', 'name']}, + ), + migrations.AddField( + model_name='customfield', + name='group_name', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='customfield', + name='ui_visibility', + field=models.CharField(default='read-write', max_length=50), + ), + ] diff --git a/netbox/extras/migrations/0075_configcontext_locations.py b/netbox/extras/migrations/0075_configcontext_locations.py new file mode 100644 index 000000000..853aec4f7 --- /dev/null +++ b/netbox/extras/migrations/0075_configcontext_locations.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.5 on 2022-06-22 19:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0156_location_status'), + ('extras', '0074_customfield_extensions'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='locations', + field=models.ManyToManyField(blank=True, related_name='+', to='dcim.location'), + ), + ] diff --git a/netbox/extras/migrations/0076_tag_slug_unicode.py b/netbox/extras/migrations/0076_tag_slug_unicode.py new file mode 100644 index 000000000..3f4922963 --- /dev/null +++ b/netbox/extras/migrations/0076_tag_slug_unicode.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.6 on 2022-07-14 15:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0075_configcontext_locations'), + ] + + operations = [ + migrations.AlterField( + model_name='tag', + name='slug', + field=models.SlugField(allow_unicode=True, max_length=100, unique=True), + ), + ] diff --git a/netbox/extras/migrations/0077_customlink_extend_text_and_url.py b/netbox/extras/migrations/0077_customlink_extend_text_and_url.py new file mode 100644 index 000000000..c08948aa6 --- /dev/null +++ b/netbox/extras/migrations/0077_customlink_extend_text_and_url.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0076_tag_slug_unicode'), + ] + + operations = [ + migrations.AlterField( + model_name='customlink', + name='link_text', + field=models.TextField(), + ), + migrations.AlterField( + model_name='customlink', + name='link_url', + field=models.TextField(), + ), + ] diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 0dc5d57db..30fb07069 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.core.validators import ValidationError from django.db import models from django.urls import reverse @@ -55,6 +53,11 @@ class ConfigContext(WebhooksMixin, ChangeLoggedModel): related_name='+', blank=True ) + locations = models.ManyToManyField( + to='dcim.Location', + related_name='+', + blank=True + ) device_types = models.ManyToManyField( to='dcim.DeviceType', related_name='+', @@ -138,11 +141,10 @@ class ConfigContextModel(models.Model): def get_config_context(self): """ + Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs. Return the rendered configuration context for a device or VM. """ - - # Compile all config data, overwriting lower-weight values with higher-weight values where a collision occurs - data = OrderedDict() + data = {} if not hasattr(self, 'config_context_data'): # The annotation is not available, so we fall back to manually querying for the config context objects diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 55b7a9f03..426565231 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -79,6 +79,11 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text='Name of the field as displayed to users (if not provided, ' 'the field\'s name will be used)' ) + group_name = models.CharField( + max_length=50, + blank=True, + help_text="Custom fields within the same group will be displayed together" + ) description = models.CharField( max_length=200, blank=True @@ -131,10 +136,17 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) + ui_visibility = models.CharField( + max_length=50, + choices=CustomFieldVisibilityChoices, + default=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, + verbose_name='UI visibility', + help_text='Specifies the visibility of custom field in the UI' + ) objects = CustomFieldManager() class Meta: - ordering = ['weight', 'name'] + ordering = ['group_name', 'weight', 'name'] def __str__(self): return self.label or self.name.replace('_', ' ').capitalize() diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index e614a1258..4873a1f9e 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -204,12 +204,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): enabled = models.BooleanField( default=True ) - link_text = models.CharField( - max_length=500, + link_text = models.TextField( help_text="Jinja2 template code for link text" ) - link_url = models.CharField( - max_length=500, + link_url = models.TextField( verbose_name='Link URL', help_text="Jinja2 template code for link URL" ) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 21727d3d4..2b97af0fb 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -19,8 +19,9 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Device type assignment is relevant only for Devices + # Device type and location assignment is relevant only for Devices device_type = getattr(obj, 'device_type', None) + location = getattr(obj, 'location', None) # Get assigned cluster, group, and type (if any) cluster = getattr(obj, 'cluster', None) @@ -42,6 +43,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): Q(regions__in=regions) | Q(regions=None), Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(locations=location) | Q(locations=None), Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), @@ -114,6 +116,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + base_query.add((Q(locations=OuterRef('location')) | Q(locations=None)), Q.AND) base_query.add((Q(device_types=OuterRef('device_type')) | Q(device_types=None)), Q.AND) base_query.add((Q(roles=OuterRef('device_role')) | Q(roles=None)), Q.AND) base_query.add((Q(sites=OuterRef('site')) | Q(sites=None)), Q.AND) diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index 07fd4cc24..e1437c00e 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -28,3 +28,4 @@ registry = Registry() registry['model_features'] = { feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES } +registry['denormalized_fields'] = collections.defaultdict(list) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index 0a8a8d89b..43d916aff 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -3,7 +3,6 @@ import inspect import logging import pkgutil import traceback -from collections import OrderedDict from django.conf import settings from django.utils import timezone @@ -114,7 +113,7 @@ class Report(object): def __init__(self): - self._results = OrderedDict() + self._results = {} self.active_test = None self.failed = False @@ -125,13 +124,13 @@ class Report(object): for method in dir(self): if method.startswith('test_') and callable(getattr(self, method)): test_methods.append(method) - self._results[method] = OrderedDict([ - ('success', 0), - ('info', 0), - ('warning', 0), - ('failure', 0), - ('log', []), - ]) + self._results[method] = { + 'success': 0, + 'info': 0, + 'warning': 0, + 'failure': 0, + 'log': [], + } if not test_methods: raise Exception("A report must contain at least one test method.") self.test_methods = test_methods diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index cee264878..6e4478304 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -6,7 +6,6 @@ import pkgutil import sys import traceback import threading -from collections import OrderedDict import yaml from django import forms @@ -496,7 +495,7 @@ def get_scripts(use_names=False): Return a dict of dicts mapping all scripts to their modules. Set use_names to True to use each module's human- defined name in place of the actual module name. """ - scripts = OrderedDict() + scripts = {} # Iterate through all modules within the scripts path. These are the user-created files in which reports are # defined. for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]): @@ -510,7 +509,7 @@ def get_scripts(use_names=False): if use_names and hasattr(module, 'name'): module_name = module.name - module_scripts = OrderedDict() + module_scripts = {} script_order = getattr(module, "script_order", ()) ordered_scripts = [cls for cls in script_order if is_script(cls)] unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order] diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index a13054d56..2fa13f98a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -28,14 +28,15 @@ class CustomFieldTable(NetBoxTable): ) content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() + ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") class Meta(NetBoxTable.Meta): model = CustomField fields = ( - 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', 'created', 'last_updated', + 'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'weight', 'default', + 'description', 'filter_logic', 'ui_visibility', 'choices', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') + default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description') # @@ -166,8 +167,9 @@ class ConfigContextTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( - 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', 'platforms', - 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated', + 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'locations', 'roles', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index d963bd25a..a73eb3fb4 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django import template from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe @@ -50,7 +48,7 @@ def custom_links(context, obj): 'perms': context['perms'], # django.contrib.auth.context_processors.auth } template_code = '' - group_names = OrderedDict() + group_names = {} for cl in custom_links: diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index aa9d724a4..9f9483bbb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -7,9 +7,8 @@ from django.test import TestCase from circuits.models import Provider from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup -from extras.choices import ( - CustomFieldTypeChoices, CustomFieldFilterLogicChoices, JournalEntryKindChoices, ObjectChangeActionChoices, -) +from dcim.models import Location +from extras.choices import * from extras.filtersets import * from extras.models import * from ipam.models import IPAddress @@ -32,21 +31,24 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): type=CustomFieldTypeChoices.TYPE_TEXT, required=True, weight=100, - filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE ), CustomField( name='Custom Field 2', type=CustomFieldTypeChoices.TYPE_INTEGER, required=False, weight=200, - filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_READ_ONLY ), CustomField( name='Custom Field 3', type=CustomFieldTypeChoices.TYPE_BOOLEAN, required=False, weight=300, - filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED + filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED, + ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN ), ) CustomField.objects.bulk_create(custom_fields) @@ -76,6 +78,10 @@ class CustomFieldTestCase(TestCase, BaseFilterSetTests): params = {'filter_logic': CustomFieldFilterLogicChoices.FILTER_LOOSE} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_ui_visibility(self): + params = {'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + class WebhookTestCase(TestCase, BaseFilterSetTests): queryset = Webhook.objects.all() @@ -431,9 +437,9 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): def setUpTestData(cls): regions = ( - Region(name='Test Region 1', slug='test-region-1'), - Region(name='Test Region 2', slug='test-region-2'), - Region(name='Test Region 3', slug='test-region-3'), + Region(name='Region 1', slug='region-1'), + Region(name='Region 2', slug='region-2'), + Region(name='Region 3', slug='region-3'), ) for r in regions: r.save() @@ -447,12 +453,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1'), - Site(name='Test Site 2', slug='test-site-2'), - Site(name='Test Site 3', slug='test-site-3'), + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), + Location(name='Location 3', slug='location-3', site=sites[2]), + ) + for location in locations: + location.save() + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), @@ -523,6 +537,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): c.regions.set([regions[i]]) c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.locations.set([locations[i]]) c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -564,6 +579,13 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_location(self): + locations = Location.objects.all()[:2] + params = {'location_id': [locations[0].pk, locations[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'location': [locations[0].slug, locations[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device_type(self): device_types = DeviceType.objects.all()[:2] params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 17138d42b..4929690e7 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -29,7 +29,8 @@ class ConfigContextTest(TestCase): self.devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') self.region = Region.objects.create(name="Region") self.sitegroup = SiteGroup.objects.create(name="Site Group") - self.site = Site.objects.create(name='Site-1', slug='site-1', region=self.region, group=self.sitegroup) + self.site = Site.objects.create(name='Site 1', slug='site-1', region=self.region, group=self.sitegroup) + self.location = Location.objects.create(name='Location 1', slug='location-1', site=self.site) self.platform = Platform.objects.create(name="Platform") self.tenantgroup = TenantGroup.objects.create(name="Tenant Group") self.tenant = Tenant.objects.create(name="Tenant", group=self.tenantgroup) @@ -40,7 +41,8 @@ class ConfigContextTest(TestCase): name='Device 1', device_type=self.devicetype, device_role=self.devicerole, - site=self.site + site=self.site, + location=self.location ) def test_higher_weight_wins(self): @@ -144,15 +146,6 @@ class ConfigContextTest(TestCase): self.assertEqual(self.device.get_config_context(), annotated_queryset[0].get_config_context()) def test_annotation_same_as_get_for_object_device_relations(self): - - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={ - "site": 1 - } - ) - site_context.sites.add(self.site) region_context = ConfigContext.objects.create( name="region", weight=100, @@ -169,6 +162,22 @@ class ConfigContextTest(TestCase): } ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={ + "site": 1 + } + ) + site_context.sites.add(self.site) + location_context = ConfigContext.objects.create( + name="location", + weight=100, + data={ + "location": 1 + } + ) + location_context.locations.add(self.location) platform_context = ConfigContext.objects.create( name="platform", weight=100, @@ -205,6 +214,7 @@ class ConfigContextTest(TestCase): device = Device.objects.create( name="Device 2", site=self.site, + location=self.location, tenant=self.tenant, platform=self.platform, device_role=self.devicerole, @@ -220,13 +230,6 @@ class ConfigContextTest(TestCase): cluster_group = ClusterGroup.objects.create(name="Cluster Group") cluster = Cluster.objects.create(name="Cluster", group=cluster_group, type=cluster_type) - site_context = ConfigContext.objects.create( - name="site", - weight=100, - data={"site": 1} - ) - site_context.sites.add(self.site) - region_context = ConfigContext.objects.create( name="region", weight=100, @@ -241,6 +244,13 @@ class ConfigContextTest(TestCase): ) sitegroup_context.site_groups.add(self.sitegroup) + site_context = ConfigContext.objects.create( + name="site", + weight=100, + data={"site": 1} + ) + site_context.sites.add(self.site) + platform_context = ConfigContext.objects.create( name="platform", weight=100, diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 1cfc4b3cc..936213cbf 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -36,14 +36,15 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'default': None, 'weight': 200, 'required': True, + 'ui_visibility': CustomFieldVisibilityChoices.VISIBILITY_READ_WRITE, } cls.csv_data = ( - 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex', - 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3}', - 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,', - 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,', - 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,', + 'name,label,type,content_types,object_type,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex,ui_visibility', + 'field4,Field 4,text,dcim.site,,100,exact,,,,[a-z]{3},read-write', + 'field5,Field 5,integer,dcim.site,,100,exact,,1,100,,read-write', + 'field6,Field 6,select,dcim.site,,100,exact,"A,B,C",,,,read-write', + 'field7,Field 7,object,dcim.site,dcim.region,100,exact,,,,,read-write', ) cls.bulk_edit_data = { diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 9825d10de..5b589c181 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -281,6 +281,7 @@ class ConfigContextView(generic.ObjectView): ('Regions', instance.regions.all), ('Site Groups', instance.site_groups.all), ('Sites', instance.sites.all), + ('Locations', instance.locations.all), ('Device Types', instance.device_types.all), ('Roles', instance.roles.all), ('Platforms', instance.platforms.all), @@ -311,7 +312,6 @@ class ConfigContextView(generic.ObjectView): class ConfigContextEditView(generic.ObjectEditView): queryset = ConfigContext.objects.all() form = forms.ConfigContextForm - template_name = 'extras/configcontext_edit.html' class ConfigContextBulkEditView(generic.BulkEditView): @@ -492,14 +492,14 @@ class JournalEntryDeleteView(generic.ObjectDeleteView): class JournalEntryBulkEditView(generic.BulkEditView): - queryset = JournalEntry.objects.prefetch_related('created_by') + queryset = JournalEntry.objects.all() filterset = filtersets.JournalEntryFilterSet table = tables.JournalEntryTable form = forms.JournalEntryBulkEditForm class JournalEntryBulkDeleteView(generic.BulkDeleteView): - queryset = JournalEntry.objects.prefetch_related('created_by') + queryset = JournalEntry.objects.all() filterset = filtersets.JournalEntryFilterSet table = tables.JournalEntryTable diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index d8739cb55..bef90a245 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,6 +1,5 @@ import hashlib import hmac -from collections import defaultdict from django.contrib.contenttypes.models import ContentType from django.utils import timezone @@ -27,10 +26,18 @@ def serialize_for_webhook(instance): def get_snapshots(instance, action): - return { + snapshots = { 'prechange': getattr(instance, '_prechange_snapshot', None), - 'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, + 'postchange': None, } + if action != ObjectChangeActionChoices.ACTION_DELETE: + # Use model's serialize_object() method if defined; fall back to serialize_object() utility function + if hasattr(instance, 'serialize_object'): + snapshots['postchange'] = instance.serialize_object() + else: + snapshots['postchange'] = serialize_object(instance) + + return snapshots def generate_signature(request_body, secret): diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 5f9e09049..7809e84f8 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -1,7 +1,8 @@ from rest_framework import serializers from ipam import models -from netbox.api import WritableNestedSerializer +from ipam.models.l2vpn import L2VPNTermination, L2VPN +from netbox.api.serializers import WritableNestedSerializer __all__ = [ 'NestedAggregateSerializer', @@ -10,6 +11,8 @@ __all__ = [ 'NestedFHRPGroupAssignmentSerializer', 'NestedIPAddressSerializer', 'NestedIPRangeSerializer', + 'NestedL2VPNSerializer', + 'NestedL2VPNTerminationSerializer', 'NestedPrefixSerializer', 'NestedRIRSerializer', 'NestedRoleSerializer', @@ -190,3 +193,28 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] + +# +# L2VPN +# + + +class NestedL2VPNSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type' + ] + + +class NestedL2VPNTerminationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn' + ] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4462ed697..91a81d3b2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,5 +1,3 @@ -from collections import OrderedDict - from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers @@ -8,7 +6,7 @@ from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerial from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES from ipam.models import * -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.api.nested_serializers import NestedTenantSerializer @@ -20,6 +18,9 @@ from .nested_serializers import * # # ASNs # +from .nested_serializers import NestedL2VPNSerializer +from ..models.l2vpn import L2VPNTermination, L2VPN + class ASNSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:asn-detail') @@ -205,13 +206,14 @@ class VLANSerializer(NetBoxModelSerializer): tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=VLANStatusChoices, required=False) role = NestedRoleSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VLAN fields = [ - 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', 'prefix_count', + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', + 'l2vpn_termination', 'tags', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] @@ -223,13 +225,13 @@ class AvailableVLANSerializer(serializers.Serializer): group = NestedVLANGroupSerializer(read_only=True) def to_representation(self, instance): - return OrderedDict([ - ('vid', instance), - ('group', NestedVLANGroupSerializer( + return { + 'vid': instance, + 'group': NestedVLANGroupSerializer( self.context['group'], context={'request': self.context['request']} - ).data), - ]) + ).data, + } class CreateAvailableVLANSerializer(NetBoxModelSerializer): @@ -314,11 +316,11 @@ class AvailablePrefixSerializer(serializers.Serializer): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data else: vrf = None - return OrderedDict([ - ('family', instance.version), - ('prefix', str(instance)), - ('vrf', vrf), - ]) + return { + 'family': instance.version, + 'prefix': str(instance), + 'vrf': vrf, + } # @@ -361,7 +363,7 @@ class IPAddressSerializer(NetBoxModelSerializer): ) assigned_object = serializers.SerializerMethodField(read_only=True) nat_inside = NestedIPAddressSerializer(required=False, allow_null=True) - nat_outside = NestedIPAddressSerializer(required=False, read_only=True) + nat_outside = NestedIPAddressSerializer(many=True, read_only=True) class Meta: model = IPAddress @@ -370,7 +372,6 @@ class IPAddressSerializer(NetBoxModelSerializer): 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] - read_only_fields = ['family', 'nat_outside'] @swagger_serializer_method(serializer_or_field=serializers.DictField) def get_assigned_object(self, obj): @@ -394,11 +395,11 @@ class AvailableIPSerializer(serializers.Serializer): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data else: vrf = None - return OrderedDict([ - ('family', self.context['parent'].family), - ('address', f"{instance}/{self.context['parent'].mask_length}"), - ('vrf', vrf), - ]) + return { + 'family': self.context['parent'].family, + 'address': f"{instance}/{self.context['parent'].mask_length}", + 'vrf': vrf, + } # @@ -435,3 +436,54 @@ class ServiceSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] + +# +# L2VPN +# + + +class L2VPNSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpn-detail') + type = ChoiceField(choices=L2VPNTypeChoices, required=False) + import_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + export_targets = SerializedPKRelatedField( + queryset=RouteTarget.objects.all(), + serializer=NestedRouteTargetSerializer, + required=False, + many=True + ) + tenant = NestedTenantSerializer(required=False, allow_null=True) + + class Meta: + model = L2VPN + fields = [ + 'id', 'url', 'display', 'identifier', 'name', 'slug', 'type', 'import_targets', 'export_targets', + 'description', 'tenant', 'tags', 'custom_fields', 'created', 'last_updated' + ] + + +class L2VPNTerminationSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:l2vpntermination-detail') + l2vpn = NestedL2VPNSerializer() + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = L2VPNTermination + fields = [ + 'id', 'url', 'display', 'l2vpn', 'assigned_object_type', 'assigned_object_id', + 'assigned_object', 'tags', 'custom_fields', 'created', 'last_updated' + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object, prefix=NESTED_SERIALIZER_PREFIX) + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 99e039eff..1e077c087 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from netbox.api import NetBoxRouter -from ipam.models import IPRange, Prefix +from netbox.api.routers import NetBoxRouter from . import views @@ -45,6 +44,10 @@ router.register('vlans', views.VLANViewSet) router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) +# L2VPN +router.register('l2vpns', views.L2VPNViewSet) +router.register('l2vpn-terminations', views.L2VPNTerminationViewSet) + app_name = 'ipam-api' urlpatterns = [ diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index dcddec580..0407c6d39 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -18,6 +18,7 @@ from netbox.config import get_config from utilities.constants import ADVISORY_LOCK_KEYS from utilities.utils import count_related from . import serializers +from ipam.models import L2VPN, L2VPNTermination class IPAMRootView(APIRootView): @@ -157,6 +158,18 @@ class ServiceViewSet(NetBoxModelViewSet): filterset_class = filtersets.ServiceFilterSet +class L2VPNViewSet(NetBoxModelViewSet): + queryset = L2VPN.objects.prefetch_related('import_targets', 'export_targets', 'tenant', 'tags') + serializer_class = serializers.L2VPNSerializer + filterset_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationViewSet(NetBoxModelViewSet): + queryset = L2VPNTermination.objects.prefetch_related('assigned_object') + serializer_class = serializers.L2VPNTerminationSerializer + filterset_class = filtersets.L2VPNTerminationFilterSet + + # # Views # diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index a364d3c6a..298baa643 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -170,3 +170,52 @@ class ServiceProtocolChoices(ChoiceSet): (PROTOCOL_UDP, 'UDP'), (PROTOCOL_SCTP, 'SCTP'), ) + + +class L2VPNTypeChoices(ChoiceSet): + TYPE_VPLS = 'vpls' + TYPE_VPWS = 'vpws' + TYPE_EPL = 'epl' + TYPE_EVPL = 'evpl' + TYPE_EPLAN = 'ep-lan' + TYPE_EVPLAN = 'evp-lan' + TYPE_EPTREE = 'ep-tree' + TYPE_EVPTREE = 'evp-tree' + TYPE_VXLAN = 'vxlan' + TYPE_VXLAN_EVPN = 'vxlan-evpn' + TYPE_MPLS_EVPN = 'mpls-evpn' + TYPE_PBB_EVPN = 'pbb-evpn' + + CHOICES = ( + ('VPLS', ( + (TYPE_VPWS, 'VPWS'), + (TYPE_VPLS, 'VPLS'), + )), + ('VXLAN', ( + (TYPE_VXLAN, 'VXLAN'), + (TYPE_VXLAN_EVPN, 'VXLAN-EVPN'), + )), + ('L2VPN E-VPN', ( + (TYPE_MPLS_EVPN, 'MPLS EVPN'), + (TYPE_PBB_EVPN, 'PBB EVPN'), + )), + ('E-Line', ( + (TYPE_EPL, 'EPL'), + (TYPE_EVPL, 'EVPL'), + )), + ('E-LAN', ( + (TYPE_EPLAN, 'Ethernet Private LAN'), + (TYPE_EVPLAN, 'Ethernet Virtual Private LAN'), + )), + ('E-Tree', ( + (TYPE_EPTREE, 'Ethernet Private Tree'), + (TYPE_EVPTREE, 'Ethernet Virtual Private Tree'), + )), + ) + + P2P = ( + TYPE_VPWS, + TYPE_EPL, + TYPE_EPLAN, + TYPE_EPTREE + ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index ab88dfc1a..cb121515d 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -90,3 +90,9 @@ VLANGROUP_SCOPE_TYPES = ( # 16-bit port number SERVICE_PORT_MIN = 1 SERVICE_PORT_MAX = 65535 + +L2VPN_ASSIGNMENT_MODELS = Q( + Q(app_label='dcim', model='interface') | + Q(app_label='ipam', model='vlan') | + Q(app_label='virtualization', model='vminterface') +) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index d9cf6eefc..49ec15fc1 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -23,6 +23,8 @@ __all__ = ( 'FHRPGroupFilterSet', 'IPAddressFilterSet', 'IPRangeFilterSet', + 'L2VPNFilterSet', + 'L2VPNTerminationFilterSet', 'PrefixFilterSet', 'RIRFilterSet', 'RoleFilterSet', @@ -922,3 +924,169 @@ class ServiceFilterSet(NetBoxModelFilterSet): return queryset qs_filter = Q(name__icontains=value) | Q(description__icontains=value) return queryset.filter(qs_filter) + + +# +# L2VPN +# + +class L2VPNFilterSet(NetBoxModelFilterSet, TenancyFilterSet): + type = django_filters.MultipleChoiceFilter( + choices=L2VPNTypeChoices, + null_value=None + ) + import_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets', + queryset=RouteTarget.objects.all(), + label='Import target', + ) + import_target = django_filters.ModelMultipleChoiceFilter( + field_name='import_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Import target (name)', + ) + export_target_id = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets', + queryset=RouteTarget.objects.all(), + label='Export target', + ) + export_target = django_filters.ModelMultipleChoiceFilter( + field_name='export_targets__name', + queryset=RouteTarget.objects.all(), + to_field_name='name', + label='Export target (name)', + ) + + class Meta: + model = L2VPN + fields = ['id', 'identifier', 'name', 'type', 'description'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(identifier=value) | Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) + + +class L2VPNTerminationFilterSet(NetBoxModelFilterSet): + l2vpn_id = django_filters.ModelMultipleChoiceFilter( + queryset=L2VPN.objects.all(), + label='L2VPN (ID)', + ) + l2vpn = django_filters.ModelMultipleChoiceFilter( + field_name='l2vpn__slug', + queryset=L2VPN.objects.all(), + to_field_name='slug', + label='L2VPN (slug)', + ) + region = MultiValueCharFilter( + method='filter_region', + field_name='slug', + label='Region (slug)', + ) + region_id = MultiValueNumberFilter( + method='filter_region', + field_name='pk', + label='Region (ID)', + ) + site = MultiValueCharFilter( + method='filter_site', + field_name='slug', + label='Site (slug)', + ) + site_id = MultiValueNumberFilter( + method='filter_site', + field_name='pk', + label='Site (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface__device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + virtual_machine = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine__name', + queryset=VirtualMachine.objects.all(), + to_field_name='name', + label='Virtual machine (name)', + ) + virtual_machine_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__virtual_machine', + queryset=VirtualMachine.objects.all(), + label='Virtual machine (ID)', + ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + field_name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + vminterface = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface__name', + queryset=VMInterface.objects.all(), + to_field_name='name', + label='VM interface (name)', + ) + vminterface_id = django_filters.ModelMultipleChoiceFilter( + field_name='vminterface', + queryset=VMInterface.objects.all(), + label='VM Interface (ID)', + ) + vlan = django_filters.ModelMultipleChoiceFilter( + field_name='vlan__name', + queryset=VLAN.objects.all(), + to_field_name='name', + label='VLAN (name)', + ) + vlan_vid = django_filters.NumberFilter( + field_name='vlan__vid', + label='VLAN number (1-4094)', + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + field_name='vlan', + queryset=VLAN.objects.all(), + label='VLAN (ID)', + ) + assigned_object_type = ContentTypeFilter() + + class Meta: + model = L2VPNTermination + fields = ('id', 'assigned_object_type_id') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(l2vpn__name__icontains=value) + return queryset.filter(qs_filter) + + def filter_site(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__{}__in'.format(name): value}) | + Q(**{'interface__device__site__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__{}__in'.format(name): value}) + ) + ) + return qs + + def filter_region(self, queryset, name, value): + qs = queryset.filter( + Q( + Q(**{'vlan__site__region__{}__in'.format(name): value}) | + Q(**{'interface__device__site__region__{}__in'.format(name): value}) | + Q(**{'vminterface__virtual_machine__site__region__{}__in'.format(name): value}) + ) + ) + return qs diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 66b4ba0fc..5f579b07f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -8,7 +8,7 @@ from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BulkEditNullBooleanSelect, DatePicker, DynamicModelChoiceField, NumericArrayField, StaticSelect, + add_blank_choice, BulkEditNullBooleanSelect, DynamicModelChoiceField, NumericArrayField, StaticSelect, DynamicModelMultipleChoiceField, ) @@ -18,6 +18,8 @@ __all__ = ( 'FHRPGroupBulkEditForm', 'IPAddressBulkEditForm', 'IPRangeBulkEditForm', + 'L2VPNBulkEditForm', + 'L2VPNTerminationBulkEditForm', 'PrefixBulkEditForm', 'RIRBulkEditForm', 'RoleBulkEditForm', @@ -440,3 +442,29 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm): class ServiceBulkEditForm(ServiceTemplateBulkEditForm): model = Service + + +class L2VPNBulkEditForm(NetBoxModelBulkEditForm): + type = forms.ChoiceField( + choices=add_blank_choice(L2VPNTypeChoices), + required=False, + widget=StaticSelect() + ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + + model = L2VPN + fieldsets = ( + (None, ('type', 'description', 'tenant')), + ) + nullable_fields = ('tenant', 'description',) + + +class L2VPNTerminationBulkEditForm(NetBoxModelBulkEditForm): + model = L2VPN diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index 17da242a0..880d2722f 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Site from ipam.choices import * @@ -16,6 +17,8 @@ __all__ = ( 'FHRPGroupCSVForm', 'IPAddressCSVForm', 'IPRangeCSVForm', + 'L2VPNCSVForm', + 'L2VPNTerminationCSVForm', 'PrefixCSVForm', 'RIRCSVForm', 'RoleCSVForm', @@ -425,3 +428,83 @@ class ServiceCSVForm(NetBoxModelCSVForm): class Meta: model = Service fields = ('device', 'virtual_machine', 'name', 'protocol', 'ports', 'description') + + +class L2VPNCSVForm(NetBoxModelCSVForm): + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + ) + type = CSVChoiceField( + choices=L2VPNTypeChoices, + help_text='L2VPN type' + ) + + class Meta: + model = L2VPN + fields = ('identifier', 'name', 'slug', 'type', 'description') + + +class L2VPNTerminationCSVForm(NetBoxModelCSVForm): + l2vpn = CSVModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + to_field_name='name', + label='L2VPN', + ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Parent device (for interface)' + ) + virtual_machine = CSVModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Parent virtual machine (for interface)' + ) + interface = CSVModelChoiceField( + queryset=Interface.objects.none(), # Can also refer to VMInterface + required=False, + to_field_name='name', + help_text='Assigned interface (device or VM)' + ) + vlan = CSVModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned VLAN' + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', 'device', 'virtual_machine', 'interface', 'vlan') + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + + # Limit interface queryset by device or VM + if data.get('device'): + self.fields['interface'].queryset = Interface.objects.filter( + **{f"device__{self.fields['device'].to_field_name}": data['device']} + ) + elif data.get('virtual_machine'): + self.fields['interface'].queryset = VMInterface.objects.filter( + **{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']} + ) + + def clean(self): + super().clean() + + if self.cleaned_data.get('device') and self.cleaned_data.get('virtual_machine'): + raise ValidationError('Cannot import device and VM interface terminations simultaneously.') + if not (self.cleaned_data.get('interface') or self.cleaned_data.get('vlan')): + raise ValidationError('Each termination must specify either an interface or a VLAN.') + if self.cleaned_data.get('interface') and self.cleaned_data.get('vlan'): + raise ValidationError('Cannot assign both an interface and a VLAN.') + + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vlan') diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 3d67d4d37..ecf63b49f 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -1,18 +1,19 @@ from django import forms +from django.contrib.contenttypes.models import ContentType +from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Location, Rack, Region, Site, SiteGroup, Device -from virtualization.models import VirtualMachine from ipam.choices import * from ipam.constants import * from ipam.models import * -from ipam.models import ASN from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm from utilities.forms import ( - add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, - TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + add_blank_choice, ContentTypeMultipleChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, APISelectMultiple, ) +from virtualization.models import VirtualMachine __all__ = ( 'AggregateFilterForm', @@ -20,6 +21,8 @@ __all__ = ( 'FHRPGroupFilterForm', 'IPAddressFilterForm', 'IPRangeFilterForm', + 'L2VPNFilterForm', + 'L2VPNTerminationFilterForm', 'PrefixFilterForm', 'RIRFilterForm', 'RoleFilterForm', @@ -475,3 +478,88 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm): class ServiceFilterForm(ServiceTemplateFilterForm): model = Service + + +class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): + model = L2VPN + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('type', 'import_target_id', 'export_target_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) + type = forms.ChoiceField( + choices=add_blank_choice(L2VPNTypeChoices), + required=False, + widget=StaticSelect() + ) + import_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Import targets') + ) + export_target_id = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False, + label=_('Export targets') + ) + tag = TagFilterField(model) + + +class L2VPNTerminationFilterForm(NetBoxModelFilterSetForm): + model = L2VPNTermination + fieldsets = ( + (None, ('l2vpn_id', )), + ('Assigned Object', ('assigned_object_type_id', 'region_id', 'site_id', 'device_id', 'virtual_machine_id', 'vlan_id')), + ) + l2vpn_id = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=False, + label='L2VPN' + ) + assigned_object_type_id = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.filter(L2VPN_ASSIGNMENT_MODELS), + required=False, + label=_('Assigned Object Type'), + limit_choices_to=L2VPN_ASSIGNMENT_MODELS + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False, + null_option='None', + query_params={ + 'region_id': '$region_id' + }, + label=_('Site') + ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Device') + ) + vlan_id = DynamicModelMultipleChoiceField( + queryset=VLAN.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('VLAN') + ) + virtual_machine_id = DynamicModelMultipleChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + null_option='None', + query_params={ + 'site_id': '$site_id' + }, + label=_('Virtual Machine') + ) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index d3421f22b..34bf739f4 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -1,5 +1,6 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.models import Tag @@ -7,9 +8,9 @@ from ipam.choices import * from ipam.constants import * from ipam.formfields import IPNetworkFormField from ipam.models import * -from ipam.models import ASN from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm +from tenancy.models import Tenant from utilities.exceptions import PermissionsViolation from utilities.forms import ( add_blank_choice, BootstrapMixin, ContentTypeChoiceField, DatePicker, DynamicModelChoiceField, @@ -26,6 +27,8 @@ __all__ = ( 'IPAddressBulkAddForm', 'IPAddressForm', 'IPRangeForm', + 'L2VPNForm', + 'L2VPNTerminationForm', 'PrefixForm', 'RIRForm', 'RoleForm', @@ -861,3 +864,118 @@ class ServiceCreateForm(ServiceForm): self.cleaned_data['description'] = service_template.description elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") + + +# +# L2VPN +# + + +class L2VPNForm(TenancyForm, NetBoxModelForm): + slug = SlugField() + import_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + export_targets = DynamicModelMultipleChoiceField( + queryset=RouteTarget.objects.all(), + required=False + ) + + fieldsets = ( + ('L2VPN', ('name', 'slug', 'type', 'identifier', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = L2VPN + fields = ( + 'name', 'slug', 'type', 'identifier', 'description', 'import_targets', 'export_targets', 'tenant', 'tags' + ) + widgets = { + 'type': StaticSelect(), + } + + +class L2VPNTerminationForm(NetBoxModelForm): + l2vpn = DynamicModelChoiceField( + queryset=L2VPN.objects.all(), + required=True, + query_params={}, + label='L2VPN', + fetch_trigger='open' + ) + device_vlan = DynamicModelChoiceField( + queryset=Device.objects.all(), + label="Available on Device", + required=False, + query_params={} + ) + vlan = DynamicModelChoiceField( + queryset=VLAN.objects.all(), + required=False, + query_params={ + 'available_on_device': '$device_vlan' + }, + label='VLAN' + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={} + ) + interface = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + query_params={} + ) + vminterface = DynamicModelChoiceField( + queryset=VMInterface.objects.all(), + required=False, + query_params={ + 'virtual_machine_id': '$virtual_machine' + }, + label='Interface' + ) + + class Meta: + model = L2VPNTermination + fields = ('l2vpn', ) + + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + + if instance: + if type(instance.assigned_object) is Interface: + initial['device'] = instance.assigned_object.parent + initial['interface'] = instance.assigned_object + elif type(instance.assigned_object) is VLAN: + initial['vlan'] = instance.assigned_object + elif type(instance.assigned_object) is VMInterface: + initial['vminterface'] = instance.assigned_object + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + interface = self.cleaned_data.get('interface') + vminterface = self.cleaned_data.get('vminterface') + vlan = self.cleaned_data.get('vlan') + + if not (interface or vminterface or vlan): + raise ValidationError('A termination must specify an interface or VLAN.') + if len([x for x in (interface, vminterface, vlan) if x]) > 1: + raise ValidationError('A termination can only have one terminating object (an interface or VLAN).') + + self.instance.assigned_object = interface or vminterface or vlan diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index f466c1857..5cd5e030e 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -17,6 +17,12 @@ class IPAMQuery(graphene.ObjectType): ip_range = ObjectField(IPRangeType) ip_range_list = ObjectListField(IPRangeType) + l2vpn = ObjectField(L2VPNType) + l2vpn_list = ObjectListField(L2VPNType) + + l2vpn_termination = ObjectField(L2VPNTerminationType) + l2vpn_termination_list = ObjectListField(L2VPNTerminationType) + prefix = ObjectField(PrefixType) prefix_list = ObjectListField(PrefixType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index ca206b4b8..5af2ca72a 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -11,6 +11,8 @@ __all__ = ( 'FHRPGroupAssignmentType', 'IPAddressType', 'IPRangeType', + 'L2VPNType', + 'L2VPNTerminationType', 'PrefixType', 'RIRType', 'RoleType', @@ -151,3 +153,17 @@ class VRFType(NetBoxObjectType): model = models.VRF fields = '__all__' filterset_class = filtersets.VRFFilterSet + + +class L2VPNType(NetBoxObjectType): + class Meta: + model = models.L2VPN + fields = '__all__' + filtersets_class = filtersets.L2VPNFilterSet + + +class L2VPNTerminationType(NetBoxObjectType): + class Meta: + model = models.L2VPNTermination + fields = '__all__' + filtersets_class = filtersets.L2VPNTerminationFilterSet diff --git a/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py new file mode 100644 index 000000000..63e93d137 --- /dev/null +++ b/netbox/ipam/migrations/0058_ipaddress_nat_inside_nonunique.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ] + + operations = [ + migrations.AlterField( + model_name='ipaddress', + name='nat_inside', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.ipaddress'), + ), + ] diff --git a/netbox/ipam/migrations/0059_l2vpn.py b/netbox/ipam/migrations/0059_l2vpn.py new file mode 100644 index 000000000..bd4761593 --- /dev/null +++ b/netbox/ipam/migrations/0059_l2vpn.py @@ -0,0 +1,60 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0075_configcontext_locations'), + ('contenttypes', '0002_remove_content_type_name'), + ('tenancy', '0007_contact_link'), + ('ipam', '0058_ipaddress_nat_inside_nonunique'), + ] + + operations = [ + migrations.CreateModel( + name='L2VPN', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField()), + ('type', models.CharField(max_length=50)), + ('identifier', models.BigIntegerField(blank=True, null=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('export_targets', models.ManyToManyField(blank=True, related_name='exporting_l2vpns', to='ipam.routetarget')), + ('import_targets', models.ManyToManyField(blank=True, related_name='importing_l2vpns', to='ipam.routetarget')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='l2vpns', to='tenancy.tenant')), + ], + options={ + 'verbose_name': 'L2VPN', + 'ordering': ('name', 'identifier'), + }, + ), + migrations.CreateModel( + name='L2VPNTermination', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)), + ('assigned_object_id', models.PositiveBigIntegerField()), + ('assigned_object_type', models.ForeignKey(limit_choices_to=models.Q(models.Q(models.Q(('app_label', 'dcim'), ('model', 'interface')), models.Q(('app_label', 'ipam'), ('model', 'vlan')), models.Q(('app_label', 'virtualization'), ('model', 'vminterface')), _connector='OR')), on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('l2vpn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='ipam.l2vpn')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'L2VPN termination', + 'ordering': ('l2vpn',), + }, + ), + migrations.AddConstraint( + model_name='l2vpntermination', + constraint=models.UniqueConstraint(fields=('assigned_object_type', 'assigned_object_id'), name='ipam_l2vpntermination_assigned_object'), + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ce09c482a..d13ee9076 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -2,6 +2,7 @@ from .fhrp import * from .vrfs import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * @@ -12,6 +13,8 @@ __all__ = ( 'IPRange', 'FHRPGroup', 'FHRPGroupAssignment', + 'L2VPN', + 'L2VPNTermination', 'Prefix', 'RIR', 'Role', diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 2a8d1bdcd..286251444 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -48,7 +48,7 @@ class FHRPGroup(NetBoxModel): related_query_name='fhrpgroup' ) - clone_fields = ('protocol', 'auth_type', 'auth_key') + clone_fields = ('protocol', 'auth_type', 'auth_key', 'description') class Meta: ordering = ['protocol', 'group_id', 'pk'] diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index ec054339c..2d3f4d291 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -179,9 +179,9 @@ class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): blank=True ) - clone_fields = [ + clone_fields = ( 'rir', 'tenant', 'date_added', 'description', - ] + ) class Meta: ordering = ('prefix', 'pk') # prefix may be non-unique @@ -368,9 +368,9 @@ class Prefix(GetAvailablePrefixesMixin, NetBoxModel): objects = PrefixQuerySet.as_manager() - clone_fields = [ + clone_fields = ( 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', - ] + ) class Meta: ordering = (F('vrf').asc(nulls_first=True), 'prefix', 'pk') # (vrf, prefix) may be non-unique @@ -616,9 +616,9 @@ class IPRange(NetBoxModel): blank=True ) - clone_fields = [ + clone_fields = ( 'vrf', 'tenant', 'status', 'role', 'description', - ] + ) class Meta: ordering = (F('vrf').asc(nulls_first=True), 'start_address', 'pk') # (vrf, start_address) may be non-unique @@ -821,7 +821,7 @@ class IPAddress(NetBoxModel): ct_field='assigned_object_type', fk_field='assigned_object_id' ) - nat_inside = models.OneToOneField( + nat_inside = models.ForeignKey( to='self', on_delete=models.SET_NULL, related_name='nat_outside', @@ -844,9 +844,9 @@ class IPAddress(NetBoxModel): objects = IPAddressManager() - clone_fields = [ - 'vrf', 'tenant', 'status', 'role', 'description', - ] + clone_fields = ( + 'vrf', 'tenant', 'status', 'role', 'dns_name', 'description', + ) class Meta: ordering = ('address', 'pk') # address may be non-unique @@ -865,6 +865,25 @@ class IPAddress(NetBoxModel): address__net_host=str(self.address.ip) ).exclude(pk=self.pk) + def get_next_available_ip(self): + """ + Return the next available IP address within this IP's network (if any) + """ + if self.address and self.address.broadcast: + start_ip = self.address.ip + 1 + end_ip = self.address.broadcast - 1 + if start_ip <= end_ip: + available_ips = netaddr.IPSet(netaddr.IPRange(start_ip, end_ip)) + available_ips -= netaddr.IPSet([ + address.ip for address in IPAddress.objects.filter( + vrf=self.vrf, + address__gt=self.address, + address__net_contained_or_equal=self.address.cidr + ).values_list('address', flat=True) + ]) + if available_ips: + return next(iter(available_ips)) + def clean(self): super().clean() @@ -915,6 +934,15 @@ class IPAddress(NetBoxModel): super().save(*args, **kwargs) + def clone(self): + attrs = super().clone() + + # Populate the address field with the next available IP (if any) + if next_available_ip := self.get_next_available_ip(): + attrs['address'] = f'{next_available_ip}/{self.address.prefixlen}' + + return attrs + def to_objectchange(self, action): objectchange = super().to_objectchange(action) objectchange.related_object = self.assigned_object diff --git a/netbox/ipam/models/l2vpn.py b/netbox/ipam/models/l2vpn.py new file mode 100644 index 000000000..0e948b18e --- /dev/null +++ b/netbox/ipam/models/l2vpn.py @@ -0,0 +1,141 @@ +from django.contrib.contenttypes.fields import GenericRelation, GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db import models +from django.urls import reverse + +from ipam.choices import L2VPNTypeChoices +from ipam.constants import L2VPN_ASSIGNMENT_MODELS +from netbox.models import NetBoxModel + +__all__ = ( + 'L2VPN', + 'L2VPNTermination', +) + + +class L2VPN(NetBoxModel): + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField() + type = models.CharField( + max_length=50, + choices=L2VPNTypeChoices + ) + identifier = models.BigIntegerField( + null=True, + blank=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_l2vpns', + blank=True, + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_l2vpns', + blank=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='l2vpns', + blank=True, + null=True + ) + contacts = GenericRelation( + to='tenancy.ContactAssignment' + ) + + clone_fields = ('type',) + + class Meta: + ordering = ('name', 'identifier') + verbose_name = 'L2VPN' + + def __str__(self): + if self.identifier: + return f'{self.name} ({self.identifier})' + return f'{self.name}' + + def get_absolute_url(self): + return reverse('ipam:l2vpn', args=[self.pk]) + + +class L2VPNTermination(NetBoxModel): + l2vpn = models.ForeignKey( + to='ipam.L2VPN', + on_delete=models.CASCADE, + related_name='terminations' + ) + assigned_object_type = models.ForeignKey( + to=ContentType, + limit_choices_to=L2VPN_ASSIGNMENT_MODELS, + on_delete=models.PROTECT, + related_name='+' + ) + assigned_object_id = models.PositiveBigIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' + ) + + clone_fields = ('l2vpn',) + + class Meta: + ordering = ('l2vpn',) + verbose_name = 'L2VPN termination' + constraints = ( + models.UniqueConstraint( + fields=('assigned_object_type', 'assigned_object_id'), + name='ipam_l2vpntermination_assigned_object' + ), + ) + + def __str__(self): + if self.pk is not None: + return f'{self.assigned_object} <> {self.l2vpn}' + return super().__str__() + + def get_absolute_url(self): + return reverse('ipam:l2vpntermination', args=[self.pk]) + + def clean(self): + # Only check is assigned_object is set. Required otherwise we have an Integrity Error thrown. + if self.assigned_object: + obj_id = self.assigned_object.pk + obj_type = ContentType.objects.get_for_model(self.assigned_object) + if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\ + exclude(pk=self.pk).count() > 0: + raise ValidationError(f'L2VPN Termination already assigned ({self.assigned_object})') + + # Only check if L2VPN is set and is of type P2P + if hasattr(self, 'l2vpn') and self.l2vpn.type in L2VPNTypeChoices.P2P: + terminations_count = L2VPNTermination.objects.filter(l2vpn=self.l2vpn).exclude(pk=self.pk).count() + if terminations_count >= 2: + l2vpn_type = self.l2vpn.get_type_display() + raise ValidationError( + f'{l2vpn_type} L2VPNs cannot have more than two terminations; found {terminations_count} already ' + f'defined.' + ) + + @property + def assigned_object_parent(self): + obj_type = ContentType.objects.get_for_model(self.assigned_object) + if obj_type.model == 'vminterface': + return self.assigned_object.virtual_machine + elif obj_type.model == 'interface': + return self.assigned_object.device + elif obj_type.model == 'vminterface': + return self.assigned_object.virtual_machine + return None + + @property + def assigned_object_site(self): + return self.assigned_object_parent.site diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7643a2617..f0e062721 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator @@ -8,6 +8,7 @@ from django.urls import reverse from dcim.models import Interface from ipam.choices import * from ipam.constants import * +from ipam.models import L2VPNTermination from ipam.querysets import VLANQuerySet from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -173,6 +174,13 @@ class VLAN(NetBoxModel): blank=True ) + l2vpn_terminations = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vlan' + ) + objects = VLANQuerySet.as_manager() clone_fields = [ @@ -227,3 +235,7 @@ class VLAN(NetBoxModel): Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index fc34b5488..a926bec3e 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -55,9 +55,9 @@ class VRF(NetBoxModel): blank=True ) - clone_fields = [ + clone_fields = ( 'tenant', 'enforce_unique', 'description', - ] + ) class Meta: ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique diff --git a/netbox/ipam/tables/__init__.py b/netbox/ipam/tables/__init__.py index 6f429e27d..3bde78af0 100644 --- a/netbox/ipam/tables/__init__.py +++ b/netbox/ipam/tables/__init__.py @@ -1,5 +1,6 @@ from .fhrp import * from .ip import * +from .l2vpn import * from .services import * from .vlans import * from .vrfs import * diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 087d0de73..20e63fe55 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -32,15 +32,6 @@ PREFIX_LINK = """ {{ record.prefix }} """ -PREFIXFLAT_LINK = """ -{% load helpers %} -{% if record.pk %} - {{ record.prefix }} -{% else %} - {{ record.prefix }} -{% endif %} -""" - IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} @@ -229,9 +220,9 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) - prefix_flat = tables.TemplateColumn( - template_code=PREFIXFLAT_LINK, - attrs={'td': {'class': 'text-nowrap'}}, + prefix_flat = tables.Column( + accessor=Accessor('prefix'), + linkify=True, verbose_name='Prefix (Flat)', ) depth = tables.Column( diff --git a/netbox/ipam/tables/l2vpn.py b/netbox/ipam/tables/l2vpn.py new file mode 100644 index 000000000..168c8ca89 --- /dev/null +++ b/netbox/ipam/tables/l2vpn.py @@ -0,0 +1,75 @@ +import django_tables2 as tables + +from ipam.models import L2VPN, L2VPNTermination +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin + +__all__ = ( + 'L2VPNTable', + 'L2VPNTerminationTable', +) + +L2VPN_TARGETS = """ +{% for rt in value.all %} + {{ rt }}{% if not forloop.last %}
    {% endif %} +{% endfor %} +""" + + +class L2VPNTable(TenancyColumnsMixin, NetBoxTable): + pk = columns.ToggleColumn() + name = tables.Column( + linkify=True + ) + import_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) + export_targets = columns.TemplateColumn( + template_code=L2VPN_TARGETS, + orderable=False + ) + + class Meta(NetBoxTable.Meta): + model = L2VPN + fields = ( + 'pk', 'name', 'slug', 'type', 'description', 'import_targets', 'export_targets', 'tenant', 'tenant_group', + 'actions', + ) + default_columns = ('pk', 'name', 'type', 'description', 'actions') + + +class L2VPNTerminationTable(NetBoxTable): + pk = columns.ToggleColumn() + l2vpn = tables.Column( + verbose_name='L2VPN', + linkify=True + ) + assigned_object_type = columns.ContentTypeColumn( + verbose_name='Object Type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object' + ) + assigned_object_parent = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object Parent' + ) + assigned_object_site = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object Site' + ) + + class Meta(NetBoxTable.Meta): + model = L2VPNTermination + fields = ( + 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site', + 'actions', + ) + default_columns = ( + 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions', + ) diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index d99de6d20..3fef04194 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -914,3 +914,96 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): 'ports': [6], }, ] + + +class L2VPNTest(APIViewTestCases.APIViewTestCase): + model = L2VPN + brief_fields = ['display', 'id', 'identifier', 'name', 'slug', 'type', 'url'] + create_data = [ + { + 'name': 'L2VPN 4', + 'slug': 'l2vpn-4', + 'type': 'vxlan', + 'identifier': 33343344 + }, + { + 'name': 'L2VPN 5', + 'slug': 'l2vpn-5', + 'type': 'vxlan', + 'identifier': 33343345 + }, + { + 'name': 'L2VPN 6', + 'slug': 'l2vpn-6', + 'type': 'vpws', + 'identifier': 33343346 + }, + ] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + +class L2VPNTerminationTest(APIViewTestCases.APIViewTestCase): + model = L2VPNTermination + brief_fields = ['display', 'id', 'l2vpn', 'url'] + + @classmethod + def setUpTestData(cls): + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + cls.create_data = [ + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[3].pk, + }, + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[4].pk, + }, + { + 'l2vpn': l2vpns[0].pk, + 'assigned_object_type': 'ipam.vlan', + 'assigned_object_id': vlans[5].pk, + }, + ] + + cls.bulk_update_data = { + 'l2vpn': l2vpns[2].pk + } diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index d98fe889e..081f6e11d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1,6 +1,8 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from netaddr import IPNetwork +from dcim.choices import InterfaceTypeChoices from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filtersets import * @@ -1463,3 +1465,159 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'virtual_machine': [vms[0].name, vms[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = L2VPN.objects.all() + filterset = L2VPNFilterSet + + @classmethod + def setUpTestData(cls): + + route_targets = ( + RouteTarget(name='1:1'), + RouteTarget(name='1:2'), + RouteTarget(name='1:3'), + RouteTarget(name='2:1'), + RouteTarget(name='2:2'), + RouteTarget(name='2:3'), + ) + RouteTarget.objects.bulk_create(route_targets) + + l2vpns = ( + L2VPN(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=65001), + L2VPN(name='L2VPN 2', type=L2VPNTypeChoices.TYPE_VPWS, identifier=65002), + L2VPN(name='L2VPN 3', type=L2VPNTypeChoices.TYPE_VPLS), + ) + L2VPN.objects.bulk_create(l2vpns) + l2vpns[0].import_targets.add(route_targets[0]) + l2vpns[1].import_targets.add(route_targets[1]) + l2vpns[2].import_targets.add(route_targets[2]) + l2vpns[0].export_targets.add(route_targets[3]) + l2vpns[1].export_targets.add(route_targets[4]) + l2vpns[2].export_targets.add(route_targets[5]) + + def test_name(self): + params = {'name': ['L2VPN 1', 'L2VPN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_identifier(self): + params = {'identifier': ['65001', '65002']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_type(self): + params = {'type': [L2VPNTypeChoices.TYPE_VXLAN, L2VPNTypeChoices.TYPE_VPWS]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_import_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['1:1', '1:2']) + params = {'import_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'import_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_export_targets(self): + route_targets = RouteTarget.objects.filter(name__in=['2:1', '2:2']) + params = {'export_target_id': [route_targets[0].pk, route_targets[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'export_target': [route_targets[0].name, route_targets[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class L2VPNTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = L2VPNTermination.objects.all() + filterset = L2VPNTerminationFilterSet + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + interfaces = ( + Interface(name='Interface 1', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 2', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(name='Interface 3', device=device, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(interfaces) + + vm = create_test_virtualmachine('Virtual Machine 1') + vminterfaces = ( + VMInterface(name='Interface 1', virtual_machine=vm), + VMInterface(name='Interface 2', virtual_machine=vm), + VMInterface(name='Interface 3', virtual_machine=vm), + ) + VMInterface.objects.bulk_create(vminterfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=101), + VLAN(name='VLAN 2', vid=102), + VLAN(name='VLAN 3', vid=103), + ) + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type='vxlan', identifier=65001), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type='vpws', identifier=65002), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type='vpls'), # No RD, + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vlans[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=interfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=interfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=interfaces[2]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vminterfaces[0]), + L2VPNTermination(l2vpn=l2vpns[1], assigned_object=vminterfaces[1]), + L2VPNTermination(l2vpn=l2vpns[2], assigned_object=vminterfaces[2]), + ) + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_l2vpn(self): + l2vpns = L2VPN.objects.all()[:2] + params = {'l2vpn_id': [l2vpns[0].pk, l2vpns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'l2vpn': [l2vpns[0].slug, l2vpns[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + + def test_content_type(self): + params = {'assigned_object_type_id': ContentType.objects.get(model='vlan').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_interface(self): + interfaces = Interface.objects.all()[:2] + params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vminterface(self): + vminterfaces = VMInterface.objects.all()[:2] + params = {'vminterface_id': [vminterfaces[0].pk, vminterfaces[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_vlan(self): + vlans = VLAN.objects.all()[:2] + params = {'vlan_id': [vlans[0].pk, vlans[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vlan': ['VLAN 1', 'VLAN 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + site = Site.objects.all().first() + params = {'site_id': [site.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'site': ['site-1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_device(self): + device = Device.objects.all().first() + params = {'device_id': [device.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'device': ['Device 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_virtual_machine(self): + virtual_machine = VirtualMachine.objects.all().first() + params = {'virtual_machine_id': [virtual_machine.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'virtual_machine': ['Virtual Machine 1']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 09bc95799..3bd7e8ccb 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -2,8 +2,9 @@ from netaddr import IPNetwork, IPSet from django.core.exceptions import ValidationError from django.test import TestCase, override_settings +from dcim.models import Interface, Device, DeviceRole, DeviceType, Manufacturer, Site from ipam.choices import IPAddressRoleChoices, PrefixStatusChoices -from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF +from ipam.models import Aggregate, IPAddress, IPRange, Prefix, RIR, VLAN, VLANGroup, VRF, L2VPN, L2VPNTermination class TestAggregate(TestCase): @@ -538,3 +539,76 @@ class TestVLANGroup(TestCase): VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup) self.assertEqual(vlangroup.get_next_available_vid(), 105) + + +class TestL2VPNTermination(TestCase): + + @classmethod + def setUpTestData(cls): + + site = Site.objects.create(name='Site 1') + manufacturer = Manufacturer.objects.create(name='Manufacturer 1') + device_type = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) + device_role = DeviceRole.objects.create(name='Switch') + device = Device.objects.create( + name='Device 1', + site=site, + device_type=device_type, + device_role=device_role, + status='active' + ) + + interfaces = ( + Interface(name='Interface 1', device=device, type='1000baset'), + Interface(name='Interface 2', device=device, type='1000baset'), + Interface(name='Interface 3', device=device, type='1000baset'), + Interface(name='Interface 4', device=device, type='1000baset'), + Interface(name='Interface 5', device=device, type='1000baset'), + ) + + Interface.objects.bulk_create(interfaces) + + vlans = ( + VLAN(name='VLAN 1', vid=651), + VLAN(name='VLAN 2', vid=652), + VLAN(name='VLAN 3', vid=653), + VLAN(name='VLAN 4', vid=654), + VLAN(name='VLAN 5', vid=655), + VLAN(name='VLAN 6', vid=656), + VLAN(name='VLAN 7', vid=657) + ) + + VLAN.objects.bulk_create(vlans) + + l2vpns = ( + L2VPN(name='L2VPN 1', type='vxlan', identifier=650001), + L2VPN(name='L2VPN 2', type='vpws', identifier=650002), + L2VPN(name='L2VPN 3', type='vpls'), # No RD + ) + L2VPN.objects.bulk_create(l2vpns) + + l2vpnterminations = ( + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpns[0], assigned_object=vlans[2]) + ) + + L2VPNTermination.objects.bulk_create(l2vpnterminations) + + def test_duplicate_interface_terminations(self): + device = Device.objects.first() + interface = Interface.objects.filter(device=device).first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=interface) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=interface) + + self.assertRaises(ValidationError, duplicate.clean) + + def test_duplicate_vlan_terminations(self): + vlan = Interface.objects.first() + l2vpn = L2VPN.objects.first() + + L2VPNTermination.objects.create(l2vpn=l2vpn, assigned_object=vlan) + duplicate = L2VPNTermination(l2vpn=l2vpn, assigned_object=vlan) + self.assertRaises(ValidationError, duplicate.clean) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 672cfbe08..27520229a 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -4,11 +4,11 @@ from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site, Interface from ipam.choices import * from ipam.models import * from tenancy.models import Tenant -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_test_device, create_tags class ASNTestCase(ViewTestCases.PrimaryObjectViewTestCase): @@ -746,3 +746,120 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): self.assertEqual(instance.protocol, service_template.protocol) self.assertEqual(instance.ports, service_template.ports) self.assertEqual(instance.description, service_template.description) + + +class L2VPNTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = L2VPN + csv_data = ( + 'name,slug,type,identifier', + 'L2VPN 5,l2vpn-5,vxlan,456', + 'L2VPN 6,l2vpn-6,vxlan,444', + ) + bulk_edit_data = { + 'description': 'New Description', + } + + @classmethod + def setUpTestData(cls): + rts = ( + RouteTarget(name='64534:123'), + RouteTarget(name='64534:321') + ) + RouteTarget.objects.bulk_create(rts) + + l2vpns = ( + L2VPN(name='L2VPN 1', slug='l2vpn-1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650001'), + L2VPN(name='L2VPN 2', slug='l2vpn-2', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650002'), + L2VPN(name='L2VPN 3', slug='l2vpn-3', type=L2VPNTypeChoices.TYPE_VXLAN, identifier='650003') + ) + + L2VPN.objects.bulk_create(l2vpns) + + cls.form_data = { + 'name': 'L2VPN 8', + 'slug': 'l2vpn-8', + 'type': L2VPNTypeChoices.TYPE_VXLAN, + 'identifier': 123, + 'description': 'Description', + 'import_targets': [rts[0].pk], + 'export_targets': [rts[1].pk] + } + + +class L2VPNTerminationTestCase( + ViewTestCases.GetObjectViewTestCase, + ViewTestCases.GetObjectChangelogViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkImportObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase, +): + + model = L2VPNTermination + + @classmethod + def setUpTestData(cls): + device = create_test_device('Device 1') + interface = Interface.objects.create(name='Interface 1', device=device, type='1000baset') + l2vpn = L2VPN.objects.create(name='L2VPN 1', type=L2VPNTypeChoices.TYPE_VXLAN, identifier=650001) + + vlans = ( + VLAN(name='Vlan 1', vid=1001), + VLAN(name='Vlan 2', vid=1002), + VLAN(name='Vlan 3', vid=1003), + VLAN(name='Vlan 4', vid=1004), + VLAN(name='Vlan 5', vid=1005), + VLAN(name='Vlan 6', vid=1006) + ) + VLAN.objects.bulk_create(vlans) + + terminations = ( + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[0]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[1]), + L2VPNTermination(l2vpn=l2vpn, assigned_object=vlans[2]) + ) + L2VPNTermination.objects.bulk_create(terminations) + + cls.form_data = { + 'l2vpn': l2vpn.pk, + 'device': device.pk, + 'interface': interface.pk, + } + + cls.csv_data = ( + "l2vpn,vlan", + "L2VPN 1,Vlan 4", + "L2VPN 1,Vlan 5", + "L2VPN 1,Vlan 6", + ) + + cls.bulk_edit_data = {} + + # + # Custom assertions + # + + # TODO: Remove this + def assertInstanceEqual(self, instance, data, exclude=None, api=False): + """ + Override parent + """ + if exclude is None: + exclude = [] + + fields = [k for k in data.keys() if k not in exclude] + model_dict = self.model_to_dict(instance, fields=fields, api=api) + + # Omit any dictionary keys which are not instance attributes or have been excluded + relevant_data = { + k: v for k, v in data.items() if hasattr(instance, k) and k not in exclude + } + + # Handle relations on the model + for k, v in model_dict.items(): + if isinstance(v, object) and hasattr(v, 'first'): + model_dict[k] = v.first().pk + + self.assertDictEqual(model_dict, relevant_data) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 3c7ed2d1f..d27209fd2 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -186,4 +186,26 @@ urlpatterns = [ path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), + # L2VPN + path('l2vpns/', views.L2VPNListView.as_view(), name='l2vpn_list'), + path('l2vpns/add/', views.L2VPNEditView.as_view(), name='l2vpn_add'), + path('l2vpns/import/', views.L2VPNBulkImportView.as_view(), name='l2vpn_import'), + path('l2vpns/edit/', views.L2VPNBulkEditView.as_view(), name='l2vpn_bulk_edit'), + path('l2vpns/delete/', views.L2VPNBulkDeleteView.as_view(), name='l2vpn_bulk_delete'), + path('l2vpns//', views.L2VPNView.as_view(), name='l2vpn'), + path('l2vpns//edit/', views.L2VPNEditView.as_view(), name='l2vpn_edit'), + path('l2vpns//delete/', views.L2VPNDeleteView.as_view(), name='l2vpn_delete'), + path('l2vpns//changelog/', ObjectChangeLogView.as_view(), name='l2vpn_changelog', kwargs={'model': L2VPN}), + path('l2vpns//journal/', ObjectJournalView.as_view(), name='l2vpn_journal', kwargs={'model': L2VPN}), + + path('l2vpn-terminations/', views.L2VPNTerminationListView.as_view(), name='l2vpntermination_list'), + path('l2vpn-terminations/add/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_add'), + path('l2vpn-terminations/import/', views.L2VPNTerminationBulkImportView.as_view(), name='l2vpntermination_import'), + path('l2vpn-terminations/edit/', views.L2VPNTerminationBulkEditView.as_view(), name='l2vpntermination_bulk_edit'), + path('l2vpn-terminations/delete/', views.L2VPNTerminationBulkDeleteView.as_view(), name='l2vpntermination_bulk_delete'), + path('l2vpn-terminations//', views.L2VPNTerminationView.as_view(), name='l2vpntermination'), + path('l2vpn-terminations//edit/', views.L2VPNTerminationEditView.as_view(), name='l2vpntermination_edit'), + path('l2vpn-terminations//delete/', views.L2VPNTerminationDeleteView.as_view(), name='l2vpntermination_delete'), + path('l2vpn-terminations//changelog/', ObjectChangeLogView.as_view(), name='l2vpntermination_changelog', kwargs={'model': L2VPNTermination}), + path('l2vpn-terminations//journal/', ObjectJournalView.as_view(), name='l2vpntermination_journal', kwargs={'model': L2VPNTermination}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 9ae7cd4d7..a086ab66d 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -17,6 +17,7 @@ from . import filtersets, forms, tables from .constants import * from .models import * from .models import ASN +from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans @@ -39,11 +40,11 @@ class VRFView(generic.ObjectView): ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count() import_targets_table = tables.RouteTargetTable( - instance.import_targets.prefetch_related('tenant'), + instance.import_targets.all(), orderable=False ) export_targets_table = tables.RouteTargetTable( - instance.export_targets.prefetch_related('tenant'), + instance.export_targets.all(), orderable=False ) @@ -71,14 +72,14 @@ class VRFBulkImportView(generic.BulkImportView): class VRFBulkEditView(generic.BulkEditView): - queryset = VRF.objects.prefetch_related('tenant') + queryset = VRF.objects.all() filterset = filtersets.VRFFilterSet table = tables.VRFTable form = forms.VRFBulkEditForm class VRFBulkDeleteView(generic.BulkDeleteView): - queryset = VRF.objects.prefetch_related('tenant') + queryset = VRF.objects.all() filterset = filtersets.VRFFilterSet table = tables.VRFTable @@ -99,11 +100,11 @@ class RouteTargetView(generic.ObjectView): def get_extra_context(self, request, instance): importing_vrfs_table = tables.VRFTable( - instance.importing_vrfs.prefetch_related('tenant'), + instance.importing_vrfs.all(), orderable=False ) exporting_vrfs_table = tables.VRFTable( - instance.exporting_vrfs.prefetch_related('tenant'), + instance.exporting_vrfs.all(), orderable=False ) @@ -129,14 +130,14 @@ class RouteTargetBulkImportView(generic.BulkImportView): class RouteTargetBulkEditView(generic.BulkEditView): - queryset = RouteTarget.objects.prefetch_related('tenant') + queryset = RouteTarget.objects.all() filterset = filtersets.RouteTargetFilterSet table = tables.RouteTargetTable form = forms.RouteTargetBulkEditForm class RouteTargetBulkDeleteView(generic.BulkDeleteView): - queryset = RouteTarget.objects.prefetch_related('tenant') + queryset = RouteTarget.objects.all() filterset = filtersets.RouteTargetFilterSet table = tables.RouteTargetTable @@ -420,7 +421,7 @@ class PrefixListView(generic.ObjectListView): class PrefixView(generic.ObjectView): - queryset = Prefix.objects.prefetch_related('vrf', 'site__region', 'tenant__group', 'vlan__group', 'role') + queryset = Prefix.objects.all() def get_extra_context(self, request, instance): try: @@ -436,7 +437,7 @@ class PrefixView(generic.ObjectView): ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( - 'site', 'role', 'tenant' + 'site', 'role', 'tenant', 'vlan', ) parent_prefix_table = tables.PrefixTable( list(parent_prefixes), @@ -450,7 +451,7 @@ class PrefixView(generic.ObjectView): ).exclude( pk=instance.pk ).prefetch_related( - 'site', 'role' + 'site', 'role', 'tenant', 'vlan', ) duplicate_prefix_table = tables.PrefixTable( list(duplicate_prefixes), @@ -503,7 +504,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_ranges().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', 'tenant__group', + 'tenant__group', ) def get_extra_context(self, request, instance): @@ -522,7 +523,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant') + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant', 'tenant__group') def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') @@ -555,14 +556,14 @@ class PrefixBulkImportView(generic.BulkImportView): class PrefixBulkEditView(generic.BulkEditView): - queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.prefetch_related('vrf__tenant') filterset = filtersets.PrefixFilterSet table = tables.PrefixTable form = forms.PrefixBulkEditForm class PrefixBulkDeleteView(generic.BulkDeleteView): - queryset = Prefix.objects.prefetch_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') + queryset = Prefix.objects.prefetch_related('vrf__tenant') filterset = filtersets.PrefixFilterSet table = tables.PrefixTable @@ -614,14 +615,14 @@ class IPRangeBulkImportView(generic.BulkImportView): class IPRangeBulkEditView(generic.BulkEditView): - queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + queryset = IPRange.objects.all() filterset = filtersets.IPRangeFilterSet table = tables.IPRangeTable form = forms.IPRangeBulkEditForm class IPRangeBulkDeleteView(generic.BulkDeleteView): - queryset = IPRange.objects.prefetch_related('vrf', 'tenant') + queryset = IPRange.objects.all() filterset = filtersets.IPRangeFilterSet table = tables.IPRangeTable @@ -792,14 +793,14 @@ class IPAddressBulkImportView(generic.BulkImportView): class IPAddressBulkEditView(generic.BulkEditView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + queryset = IPAddress.objects.prefetch_related('vrf__tenant') filterset = filtersets.IPAddressFilterSet table = tables.IPAddressTable form = forms.IPAddressBulkEditForm class IPAddressBulkDeleteView(generic.BulkDeleteView): - queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + queryset = IPAddress.objects.prefetch_related('vrf__tenant') filterset = filtersets.IPAddressFilterSet table = tables.IPAddressTable @@ -822,7 +823,8 @@ class VLANGroupView(generic.ObjectView): def get_extra_context(self, request, instance): vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related( - Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)) + Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user)), + 'tenant', 'site', 'role', ).order_by('vid') vlans_count = vlans.count() vlans = add_available_vlans(vlans, vlan_group=instance) @@ -897,7 +899,7 @@ class FHRPGroupView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses ipaddress_table = tables.AssignedIPAddressesTable( - data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=instance.ip_addresses.restrict(request.user, 'view'), orderable=False ) @@ -987,11 +989,11 @@ class VLANListView(generic.ObjectListView): class VLANView(generic.ObjectView): - queryset = VLAN.objects.prefetch_related('site__region', 'tenant__group', 'role') + queryset = VLAN.objects.all() def get_extra_context(self, request, instance): prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related( - 'vrf', 'site', 'role' + 'vrf', 'site', 'role', 'tenant' ) prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False) @@ -1049,14 +1051,14 @@ class VLANBulkImportView(generic.BulkImportView): class VLANBulkEditView(generic.BulkEditView): - queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.all() filterset = filtersets.VLANFilterSet table = tables.VLANTable form = forms.VLANBulkEditForm class VLANBulkDeleteView(generic.BulkDeleteView): - queryset = VLAN.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.all() filterset = filtersets.VLANFilterSet table = tables.VLANTable @@ -1109,14 +1111,14 @@ class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): # class ServiceListView(generic.ObjectListView): - queryset = Service.objects.all() + queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable class ServiceView(generic.ObjectView): - queryset = Service.objects.prefetch_related('ipaddresses') + queryset = Service.objects.all() class ServiceCreateView(generic.ObjectEditView): @@ -1126,7 +1128,7 @@ class ServiceCreateView(generic.ObjectEditView): class ServiceEditView(generic.ObjectEditView): - queryset = Service.objects.prefetch_related('ipaddresses') + queryset = Service.objects.all() form = forms.ServiceForm template_name = 'ipam/service_edit.html' @@ -1152,3 +1154,105 @@ class ServiceBulkDeleteView(generic.BulkDeleteView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet table = tables.ServiceTable + + +# L2VPN + + +class L2VPNListView(generic.ObjectListView): + queryset = L2VPN.objects.all() + table = L2VPNTable + filterset = filtersets.L2VPNFilterSet + filterset_form = forms.L2VPNFilterForm + + +class L2VPNView(generic.ObjectView): + queryset = L2VPN.objects.all() + + def get_extra_context(self, request, instance): + terminations = L2VPNTermination.objects.restrict(request.user, 'view').filter(l2vpn=instance) + terminations_table = tables.L2VPNTerminationTable(terminations, user=request.user, exclude=('l2vpn', )) + terminations_table.configure(request) + + import_targets_table = tables.RouteTargetTable( + instance.import_targets.prefetch_related('tenant'), + orderable=False + ) + export_targets_table = tables.RouteTargetTable( + instance.export_targets.prefetch_related('tenant'), + orderable=False + ) + + return { + 'terminations_table': terminations_table, + 'import_targets_table': import_targets_table, + 'export_targets_table': export_targets_table, + } + + +class L2VPNEditView(generic.ObjectEditView): + queryset = L2VPN.objects.all() + form = forms.L2VPNForm + + +class L2VPNDeleteView(generic.ObjectDeleteView): + queryset = L2VPN.objects.all() + + +class L2VPNBulkImportView(generic.BulkImportView): + queryset = L2VPN.objects.all() + model_form = forms.L2VPNCSVForm + table = tables.L2VPNTable + + +class L2VPNBulkEditView(generic.BulkEditView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + form = forms.L2VPNBulkEditForm + + +class L2VPNBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPN.objects.all() + filterset = filtersets.L2VPNFilterSet + table = tables.L2VPNTable + + +class L2VPNTerminationListView(generic.ObjectListView): + queryset = L2VPNTermination.objects.all() + table = L2VPNTerminationTable + filterset = filtersets.L2VPNTerminationFilterSet + filterset_form = forms.L2VPNTerminationFilterForm + + +class L2VPNTerminationView(generic.ObjectView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationEditView(generic.ObjectEditView): + queryset = L2VPNTermination.objects.all() + form = forms.L2VPNTerminationForm + template_name = 'ipam/l2vpntermination_edit.html' + + +class L2VPNTerminationDeleteView(generic.ObjectDeleteView): + queryset = L2VPNTermination.objects.all() + + +class L2VPNTerminationBulkImportView(generic.BulkImportView): + queryset = L2VPNTermination.objects.all() + model_form = forms.L2VPNTerminationCSVForm + table = tables.L2VPNTerminationTable + + +class L2VPNTerminationBulkEditView(generic.BulkEditView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable + form = forms.L2VPNTerminationBulkEditForm + + +class L2VPNTerminationBulkDeleteView(generic.BulkDeleteView): + queryset = L2VPNTermination.objects.all() + filterset = filtersets.L2VPNTerminationFilterSet + table = tables.L2VPNTerminationTable diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 1eaa7d1c4..e69de29bb 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,14 +0,0 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField -from .routers import NetBoxRouter -from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer - - -__all__ = ( - 'BulkOperationSerializer', - 'ChoiceField', - 'ContentTypeField', - 'NetBoxRouter', - 'SerializedPKRelatedField', - 'ValidatedModelSerializer', - 'WritableNestedSerializer', -) diff --git a/netbox/netbox/api/authentication.py b/netbox/netbox/api/authentication.py index 5e177bfcb..b8607a0bb 100644 --- a/netbox/netbox/api/authentication.py +++ b/netbox/netbox/api/authentication.py @@ -1,16 +1,42 @@ +import logging + from django.conf import settings +from django.utils import timezone from rest_framework import authentication, exceptions from rest_framework.permissions import BasePermission, DjangoObjectPermissions, SAFE_METHODS +from netbox.config import get_config from users.models import Token +from utilities.request import get_client_ip class TokenAuthentication(authentication.TokenAuthentication): """ - A custom authentication scheme which enforces Token expiration times. + A custom authentication scheme which enforces Token expiration times and source IP restrictions. """ model = Token + def authenticate(self, request): + result = super().authenticate(request) + + if result: + token = result[1] + + # Enforce source IP restrictions (if any) set on the token + if token.allowed_ips: + client_ip = get_client_ip(request) + if client_ip is None: + raise exceptions.AuthenticationFailed( + "Client IP address could not be determined for validation. Check that the HTTP server is " + "correctly configured to pass the required header(s)." + ) + if not token.validate_client_ip(client_ip): + raise exceptions.AuthenticationFailed( + f"Source IP {client_ip} is not permitted to authenticate using this token." + ) + + return result + def authenticate_credentials(self, key): model = self.get_model() try: @@ -18,6 +44,16 @@ class TokenAuthentication(authentication.TokenAuthentication): except model.DoesNotExist: raise exceptions.AuthenticationFailed("Invalid token") + # Update last used, but only once per minute at most. This reduces write load on the database + if not token.last_used or (timezone.now() - token.last_used).total_seconds() > 60: + # If maintenance mode is enabled, assume the database is read-only, and disable updating the token's + # last_used time upon authentication. + if get_config().MAINTENANCE_MODE: + logger = logging.getLogger('netbox.auth.login') + logger.debug("Maintenance mode enabled: Disabling update of token's last used timestamp") + else: + Token.objects.filter(pk=token.pk).update(last_used=timezone.now()) + # Enforce the Token's expiration time, if one has been set. if token.is_expired: raise exceptions.AuthenticationFailed("Token expired") diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index d73cbcac2..52343c2f6 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -1,12 +1,16 @@ -from collections import OrderedDict - -import pytz -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist +from netaddr import IPNetwork from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.relations import PrimaryKeyRelatedField, RelatedField +__all__ = ( + 'ChoiceField', + 'ContentTypeField', + 'IPNetworkSerializer', + 'SerializedPKRelatedField', +) + class ChoiceField(serializers.Field): """ @@ -42,10 +46,10 @@ class ChoiceField(serializers.Field): def to_representation(self, obj): if obj == '': return None - return OrderedDict([ - ('value', obj), - ('label', self._choices[obj]) - ]) + return { + 'value': obj, + 'label': self._choices[obj], + } def to_internal_value(self, data): if data == '': @@ -104,6 +108,17 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" +class IPNetworkSerializer(serializers.Serializer): + """ + Representation of an IP network value (e.g. 192.0.2.0/24). + """ + def to_representation(self, instance): + return str(instance) + + def to_internal_value(self, value): + return IPNetwork(value) + + class SerializedPKRelatedField(PrimaryKeyRelatedField): """ Extends PrimaryKeyRelatedField to return a serialized object on read. This is useful for representing related diff --git a/netbox/netbox/api/metadata.py b/netbox/netbox/api/metadata.py index bc4ecf871..dff1474d9 100644 --- a/netbox/netbox/api/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -5,7 +5,7 @@ from rest_framework import exceptions from rest_framework.metadata import SimpleMetadata from rest_framework.request import clone_request -from netbox.api import ContentTypeField +from netbox.api.fields import ContentTypeField class BulkOperationMetadata(SimpleMetadata): diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py index adc556549..0ec3ab5f3 100644 --- a/netbox/netbox/api/serializers/__init__.py +++ b/netbox/netbox/api/serializers/__init__.py @@ -2,6 +2,7 @@ from rest_framework import serializers from .base import * from .features import * +from .generic import * from .nested import * diff --git a/netbox/netbox/api/serializers/generic.py b/netbox/netbox/api/serializers/generic.py new file mode 100644 index 000000000..5016bdaab --- /dev/null +++ b/netbox/netbox/api/serializers/generic.py @@ -0,0 +1,45 @@ +from django.contrib.contenttypes.models import ContentType +from drf_yasg.utils import swagger_serializer_method +from rest_framework import serializers + +from netbox.api.fields import ContentTypeField +from netbox.constants import NESTED_SERIALIZER_PREFIX +from utilities.api import get_serializer_for_model +from utilities.utils import content_type_identifier + +__all__ = ( + 'GenericObjectSerializer', +) + + +class GenericObjectSerializer(serializers.Serializer): + """ + Minimal representation of some generic object identified by ContentType and PK. + """ + object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + object_id = serializers.IntegerField() + object = serializers.SerializerMethodField(read_only=True) + + def to_internal_value(self, data): + data = super().to_internal_value(data) + model = data['object_type'].model_class() + return model.objects.get(pk=data['object_id']) + + def to_representation(self, instance): + ct = ContentType.objects.get_for_model(instance) + data = { + 'object_type': content_type_identifier(ct), + 'object_id': instance.pk, + } + if 'request' in self.context: + data['object'] = self.get_object(instance) + + return data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_object(self, obj): + serializer = get_serializer_for_model(obj, prefix=NESTED_SERIALIZER_PREFIX) + # context = {'request': self.context['request']} + return serializer(obj, context=self.context).data diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 835ebc6a9..6c6083959 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -1,5 +1,4 @@ import platform -from collections import OrderedDict from django import __version__ as DJANGO_VERSION from django.apps import apps @@ -26,18 +25,18 @@ class APIRootView(APIView): def get(self, request, format=None): - return Response(OrderedDict(( - ('circuits', reverse('circuits-api:api-root', request=request, format=format)), - ('dcim', reverse('dcim-api:api-root', request=request, format=format)), - ('extras', reverse('extras-api:api-root', request=request, format=format)), - ('ipam', reverse('ipam-api:api-root', request=request, format=format)), - ('plugins', reverse('plugins-api:api-root', request=request, format=format)), - ('status', reverse('api-status', request=request, format=format)), - ('tenancy', reverse('tenancy-api:api-root', request=request, format=format)), - ('users', reverse('users-api:api-root', request=request, format=format)), - ('virtualization', reverse('virtualization-api:api-root', request=request, format=format)), - ('wireless', reverse('wireless-api:api-root', request=request, format=format)), - ))) + return Response({ + 'circuits': reverse('circuits-api:api-root', request=request, format=format), + 'dcim': reverse('dcim-api:api-root', request=request, format=format), + 'extras': reverse('extras-api:api-root', request=request, format=format), + 'ipam': reverse('ipam-api:api-root', request=request, format=format), + 'plugins': reverse('plugins-api:api-root', request=request, format=format), + 'status': reverse('api-status', request=request, format=format), + 'tenancy': reverse('tenancy-api:api-root', request=request, format=format), + 'users': reverse('users-api:api-root', request=request, format=format), + 'virtualization': reverse('virtualization-api:api-root', request=request, format=format), + 'wireless': reverse('wireless-api:api-root', request=request, format=format), + }) class StatusView(APIView): diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 0f149240d..c50ad9ca6 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -12,6 +12,7 @@ from extras.models import ExportTemplate from netbox.api.exceptions import SerializerNotFound from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model +from utilities.exceptions import AbortRequest from .mixins import * __all__ = ( @@ -126,6 +127,14 @@ class NetBoxModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ObjectVali *args, **kwargs ) + except AbortRequest as e: + logger.debug(e.message) + return self.finalize_response( + request, + Response({'detail': e.message}, status=400), + *args, + **kwargs + ) def list(self, request, *args, **kwargs): """ diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 00fb3ee66..62512943e 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -8,8 +8,11 @@ from django.contrib.auth.models import Group, AnonymousUser from django.core.exceptions import ImproperlyConfigured from django.db.models import Q +from users.constants import CONSTRAINT_TOKEN_USER from users.models import ObjectPermission -from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct +from utilities.permissions import ( + permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_ct, +) UserModel = get_user_model() @@ -99,8 +102,10 @@ class ObjectPermissionMixin: if not user_obj.is_active or user_obj.is_anonymous: return False + object_permissions = self.get_all_permissions(user_obj) + # If no applicable ObjectPermissions have been created for this user/permission, deny permission - if perm not in self.get_all_permissions(user_obj): + if perm not in object_permissions: return False # If no object has been specified, grant permission. (The presence of a permission in this set tells @@ -113,21 +118,16 @@ class ObjectPermissionMixin: if model._meta.label_lower != '.'.join((app_label, model_name)): raise ValueError(f"Invalid permission {perm} for model {model}") - # Compile a query filter that matches all instances of the specified model - obj_perm_constraints = self.get_all_permissions(user_obj)[perm] - constraints = Q() - for perm_constraints in obj_perm_constraints: - if perm_constraints: - constraints |= Q(**perm_constraints) - else: - # Found ObjectPermission with null constraints; allow model-level access - constraints = Q() - break + # Compile a QuerySet filter that matches all instances of the specified model + tokens = { + CONSTRAINT_TOKEN_USER: user_obj, + } + qs_filter = qs_filter_from_constraints(object_permissions[perm], tokens) # Permission to perform the requested action on the object depends on whether the specified object matches # the specified constraints. Note that this check is made against the *database* record representing the object, # not the instance itself. - return model.objects.filter(constraints, pk=obj.pk).exists() + return model.objects.filter(qs_filter, pk=obj.pk).exists() class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend): diff --git a/netbox/netbox/denormalized.py b/netbox/netbox/denormalized.py new file mode 100644 index 000000000..cd4a869d2 --- /dev/null +++ b/netbox/netbox/denormalized.py @@ -0,0 +1,58 @@ +import logging + +from django.db.models.signals import post_save +from django.dispatch import receiver + +from extras.registry import registry + + +logger = logging.getLogger('netbox.denormalized') + + +def register(model, field_name, mappings): + """ + Register a denormalized model field to ensure that it is kept up-to-date with the related object. + + Args: + model: The class being updated + field_name: The name of the field related to the triggering instance + mappings: Dictionary mapping of local to remote fields + """ + logger.debug(f'Registering denormalized field {model}.{field_name}') + + field = model._meta.get_field(field_name) + rel_model = field.related_model + + registry['denormalized_fields'][rel_model].append( + (model, field_name, mappings) + ) + + +@receiver(post_save) +def update_denormalized_fields(sender, instance, created, raw, **kwargs): + """ + Check if the sender has denormalized fields registered, and update them as necessary. + """ + def _get_field_value(instance, field_name): + field = instance._meta.get_field(field_name) + return field.value_from_object(instance) + + # Skip for new objects or those being populated from raw data + if created or raw: + return + + # Look up any denormalized fields referencing this model from the application registry + for model, field_name, mappings in registry['denormalized_fields'].get(sender, []): + logger.debug(f'Updating denormalized values for {model}.{field_name}') + filter_params = { + field_name: instance.pk, + } + update_params = { + # Map the denormalized field names to the instance's values + denorm: _get_field_value(instance, origin) for denorm, origin in mappings.items() + } + + # TODO: Improve efficiency here by placing conditions on the query? + # Update all the denormalized fields with the triggering object's new values + count = model.objects.filter(**filter_params).update(**update_params) + logger.debug(f'Updated {count} rows') diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f509afa5b..3a0434592 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -197,24 +197,11 @@ class BaseFilterSet(django_filters.FilterSet): class ChangeLoggedModelFilterSet(BaseFilterSet): - created = django_filters.DateTimeFilter() - created__gte = django_filters.DateTimeFilter( - field_name='created', - lookup_expr='gte' - ) - created__lte = django_filters.DateTimeFilter( - field_name='created', - lookup_expr='lte' - ) - last_updated = django_filters.DateTimeFilter() - last_updated__gte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='gte' - ) - last_updated__lte = django_filters.DateTimeFilter( - field_name='last_updated', - lookup_expr='lte' - ) + """ + Base FilterSet for ChangeLoggedModel classes. + """ + created = filters.MultiValueDateTimeFilter() + last_updated = filters.MultiValueDateTimeFilter() class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 0e232af1d..2676e4cde 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -94,30 +94,19 @@ class NetBoxModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields['pk'].queryset = self.model.objects.all() + self._extend_nullable_fields() + def _get_form_field(self, customfield): return customfield.to_form_field(set_initial=False, enforce_required=False) - def _append_customfield_fields(self): - """ - Append form fields for all CustomFields assigned to this object type. - """ - nullable_custom_fields = [] - for customfield in self._get_custom_fields(self._get_content_type()): - field_name = f'cf_{customfield.name}' - self.fields[field_name] = self._get_form_field(customfield) - - # Record non-required custom fields as nullable - if not customfield.required: - nullable_custom_fields.append(field_name) - - # Annotate the field in the list of CustomField form fields - self.custom_fields[field_name] = customfield - - # Annotate nullable custom fields (if any) on the form instance - if nullable_custom_fields: - self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) + def _extend_nullable_fields(self): + nullable_custom_fields = [ + name for name, customfield in self.custom_fields.items() if not customfield.required + ] + self.nullable_fields = (*self.nullable_fields, *nullable_custom_fields) class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, forms.Form): diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index cc768cbdc..5c4b2813d 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -179,8 +179,8 @@ class ExceptionHandlingMiddleware: def process_exception(self, request, exception): # Handle exceptions that occur from REST API requests - if is_api_request(request): - return rest_api_server_error(request) + # if is_api_request(request): + # return rest_api_server_error(request) # Don't catch exceptions when in debug mode if settings.DEBUG: diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 675103d06..c36d36f46 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,6 +2,7 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from extras.utils import is_taggable from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet from netbox.models.features import * @@ -56,6 +57,25 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): def get_prerequisite_models(cls): return [] + def clone(self): + """ + Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. + """ + attrs = {} + + for field_name in getattr(self, 'clone_fields', []): + field = self._meta.get_field(field_name) + field_value = field.value_from_object(self) + if field_value not in (None, ''): + attrs[field_name] = field_value + + # Include tags (if applicable) + if is_taggable(self): + attrs['tags'] = [tag.pk for tag in self.tags.all()] + + return attrs + class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index e443dde5f..6b2ee1f94 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -1,3 +1,5 @@ +from collections import defaultdict + from django.contrib.contenttypes.fields import GenericRelation from django.db.models.signals import class_prepared from django.dispatch import receiver @@ -7,7 +9,7 @@ from django.core.validators import ValidationError from django.db import models from taggit.managers import TaggableManager -from extras.choices import ObjectChangeActionChoices +from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices from extras.utils import register_features from netbox.signals import post_clean from utilities.utils import serialize_object @@ -47,11 +49,19 @@ class ChangeLoggingMixin(models.Model): class Meta: abstract = True + def serialize_object(self): + """ + Return a JSON representation of the instance. Models can override this method to replace or extend the default + serialization logic provided by the `serialize_object()` utility function. + """ + return serialize_object(self) + def snapshot(self): """ - Save a snapshot of the object's current state in preparation for modification. + Save a snapshot of the object's current state in preparation for modification. The snapshot is saved as + `_prechange_snapshot` on the instance. """ - self._prechange_snapshot = serialize_object(self) + self._prechange_snapshot = self.serialize_object() def to_objectchange(self, action): """ @@ -67,7 +77,7 @@ class ChangeLoggingMixin(models.Model): if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot if action in (ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE): - objectchange.postchange_data = serialize_object(self) + objectchange.postchange_data = self.serialize_object() return objectchange @@ -98,7 +108,7 @@ class CustomFieldsMixin(models.Model): """ return self.custom_field_data - def get_custom_fields(self): + def get_custom_fields(self, omit_hidden=False): """ Return a dictionary of custom fields for a single object in the form `{field: value}`. @@ -112,11 +122,25 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + value = self.custom_field_data.get(field.name) data[field] = field.deserialize(value) return data + def get_custom_fields_by_group(self): + """ + Return a dictionary of custom field/value mappings organized by group. Hidden fields are omitted. + """ + grouped_custom_fields = defaultdict(dict) + for cf, value in self.get_custom_fields(omit_hidden=True).items(): + grouped_custom_fields[cf.group_name][cf] = value + + return dict(grouped_custom_fields) + def clean(self): super().clean() from extras.models import CustomField diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 9a55c263e..a495f17c9 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -174,7 +174,7 @@ DEVICES_MENU = Menu( CONNECTIONS_MENU = Menu( label='Connections', - icon_class='mdi mdi-ethernet', + icon_class='mdi mdi-connection', groups=( MenuGroup( label='Connections', @@ -271,6 +271,20 @@ IPAM_MENU = Menu( ), ) +OVERLAY_MENU = Menu( + label='Overlay', + icon_class='mdi mdi-graph-outline', + groups=( + MenuGroup( + label='L2VPNs', + items=( + get_model_item('ipam', 'l2vpn', 'L2VPNs'), + get_model_item('ipam', 'l2vpntermination', 'Terminations'), + ), + ), + ), +) + VIRTUALIZATION_MENU = Menu( label='Virtualization', icon_class='mdi mdi-monitor', @@ -380,6 +394,7 @@ MENUS = [ CONNECTIONS_MENU, WIRELESS_MENU, IPAM_MENU, + OVERLAY_MENU, VIRTUALIZATION_MENU, CIRCUITS_MENU, POWER_MENU, diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2dd35f317..0edce8f69 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.2.10-dev' +VERSION = '3.3.1-dev' # Hostname HOSTNAME = platform.node() @@ -424,6 +424,8 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' +TEST_RUNNER = "django_rich.test.RichRunner" + # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. EXEMPT_EXCLUDE_MODELS = ( @@ -527,6 +529,7 @@ REST_FRAMEWORK = { ), 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', + 'rest_framework.filters.OrderingFilter', ), 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index 8c5fb039c..38399b5fe 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -7,6 +7,7 @@ from django.db.models.fields.related import RelatedField from django_tables2.data import TableQuerysetData from extras.models import CustomField, CustomLink +from extras.choices import CustomFieldVisibilityChoices from netbox.tables import columns from utilities.paginator import EnhancedPaginator, get_paginate_count @@ -97,7 +98,7 @@ class BaseTable(tables.Table): break if prefetch_path: prefetch_fields.append('__'.join(prefetch_path)) - self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + self.data.data = self.data.data.prefetch_related(*prefetch_fields) def _get_columns(self, visible=True): columns = [] @@ -178,7 +179,10 @@ class NetBoxTable(BaseTable): # Add custom field & custom link columns content_type = ContentType.objects.get_for_model(self._meta.model) - custom_fields = CustomField.objects.filter(content_types=content_type) + custom_fields = CustomField.objects.filter( + content_types=content_type + ).exclude(ui_visibility=CustomFieldVisibilityChoices.VISIBILITY_HIDDEN) + extra_columns.extend([ (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields ]) diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 7fc12b4fd..ef4554b4b 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,3 +1,5 @@ +import datetime + from django.conf import settings from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType @@ -8,10 +10,77 @@ from netaddr import IPNetwork from rest_framework.test import APIClient from dcim.models import Site -from ipam.choices import PrefixStatusChoices from ipam.models import Prefix from users.models import ObjectPermission, Token from utilities.testing import TestCase +from utilities.testing.api import APITestCase + + +class TokenAuthenticationTestCase(APITestCase): + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_authentication(self): + url = reverse('dcim-api:site-list') + + # Request without a token should return a 403 + response = self.client.get(url) + self.assertEqual(response.status_code, 403) + + # Valid token should return a 200 + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Check that the token's last_used time has been updated + token.refresh_from_db() + self.assertIsNotNone(token.last_used) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_expiration(self): + url = reverse('dcim-api:site-list') + + # Request without a non-expired token should succeed + token = Token.objects.create(user=self.user) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 200) + + # Request with an expired token should fail + token.expires = datetime.datetime(2020, 1, 1, tzinfo=datetime.timezone.utc) + token.save() + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_write_enabled(self): + url = reverse('dcim-api:site-list') + data = { + 'name': 'Site 1', + 'slug': 'site-1', + } + + # Request with a write-disabled token should fail + token = Token.objects.create(user=self.user, write_enabled=False) + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + # Request with a write-enabled token should succeed + token.write_enabled = True + token.save() + response = self.client.post(url, data, format='json', HTTP_AUTHORIZATION=f'Token {token.key}') + self.assertEqual(response.status_code, 403) + + @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) + def test_token_allowed_ips(self): + url = reverse('dcim-api:site-list') + + # Request from a non-allowed client IP should fail + token = Token.objects.create(user=self.user, allowed_ips=['192.0.2.0/24']) + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='127.0.0.1') + self.assertEqual(response.status_code, 403) + + # Request with an expired token should fail + response = self.client.get(url, HTTP_AUTHORIZATION=f'Token {token.key}', REMOTE_ADDR='192.0.2.1') + self.assertEqual(response.status_code, 200) class ExternalAuthenticationTestCase(TestCase): diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py index 204fce469..bc1f0e2ca 100644 --- a/netbox/netbox/views/__init__.py +++ b/netbox/netbox/views/__init__.py @@ -3,7 +3,6 @@ import sys from django.conf import settings from django.core.cache import cache -from django.db.models import F from django.http import HttpResponseServerError from django.shortcuts import redirect, render from django.template import loader @@ -38,14 +37,13 @@ class HomeView(View): return redirect("login") connected_consoleports = ConsolePort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_complete=True ) connected_powerports = PowerPort.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False + _path__is_complete=True ) connected_interfaces = Interface.objects.restrict(request.user, 'view').prefetch_related('_path').filter( - _path__destination_id__isnull=False, - pk__lt=F('_path__destination_id') + _path__is_complete=True ) def build_stats(): diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 29007985c..d30d67468 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -1,6 +1,5 @@ import logging import re -from collections import defaultdict from copy import deepcopy from django.contrib import messages @@ -13,11 +12,12 @@ from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django_tables2.export import TableExport +from django.utils.safestring import mark_safe from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import PermissionsViolation +from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) @@ -25,6 +25,7 @@ from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.views import GetReturnURLMixin from .base import BaseMultiObjectView +from .mixins import ActionsMixin, TableMixin from .utils import get_prerequisite_model __all__ = ( @@ -38,9 +39,9 @@ __all__ = ( ) -class ObjectListView(BaseMultiObjectView): +class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): """ - Display multiple objects, all of the same type, as a table. + Display multiple objects, all the same type, as a table. Attributes: filterset: A django-filter FilterSet that is applied to the queryset @@ -52,31 +53,10 @@ class ObjectListView(BaseMultiObjectView): template_name = 'generic/object_list.html' filterset = None filterset_form = None - actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete') - action_perms = defaultdict(set, **{ - 'add': {'add'}, - 'import': {'add'}, - 'bulk_edit': {'change'}, - 'bulk_delete': {'delete'}, - }) def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') - def get_table(self, request, bulk_actions=True): - """ - Return the django-tables2 Table instance to be used for rendering the objects list. - - Args: - request: The current request - bulk_actions: Show checkboxes for object selection - """ - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and bulk_actions: - table.columns.show('pk') - - return table - # # Export methods # @@ -150,19 +130,14 @@ class ObjectListView(BaseMultiObjectView): self.queryset = self.filterset(request.GET, self.queryset).qs # Determine the available actions - actions = [] - for action in self.actions: - if request.user.has_perms([ - get_permission_for_model(model, name) for name in self.action_perms[action] - ]): - actions.append(action) + actions = self.get_permitted_actions(request.user) has_bulk_actions = any([a.startswith('bulk_') for a in actions]) if 'export' in request.GET: # Export the current table view if request.GET['export'] == 'table': - table = self.get_table(request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_bulk_actions) columns = [name for name, _ in table.selected_columns] return self.export_table(table, columns) @@ -180,12 +155,11 @@ class ObjectListView(BaseMultiObjectView): # Fall back to default table/YAML export else: - table = self.get_table(request, has_bulk_actions) + table = self.get_table(self.queryset, request, has_bulk_actions) return self.export_table(table) # Render the objects table - table = self.get_table(request, has_bulk_actions) - table.configure(request) + table = self.get_table(self.queryset, request, has_bulk_actions) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -200,6 +174,7 @@ class ObjectListView(BaseMultiObjectView): 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, **self.get_extra_context(request), } + if requirement: context['required_model'] = requirement @@ -308,10 +283,10 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: pass - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) + clear_webhooks.send(sender=self) else: logger.debug("Form validation failed") @@ -436,10 +411,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): except ValidationError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object import failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -586,10 +560,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): messages.error(self.request, ", ".join(e.messages)) clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -683,10 +656,9 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): messages.success(request, f"Renamed {len(selected_objects)} {model_name}") return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Object update failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -761,11 +733,17 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete objects") handle_protectederror(queryset, request, e) return redirect(self.get_return_url(request)) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(self.get_return_url(request)) + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" logger.info(msg) messages.success(request, msg) @@ -874,10 +852,9 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): except IntegrityError: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not form.errors: diff --git a/netbox/netbox/views/generic/mixins.py b/netbox/netbox/views/generic/mixins.py new file mode 100644 index 000000000..8e363f0a5 --- /dev/null +++ b/netbox/netbox/views/generic/mixins.py @@ -0,0 +1,48 @@ +from collections import defaultdict + +from utilities.permissions import get_permission_for_model + +__all__ = ( + 'ActionsMixin', + 'TableMixin', +) + + +class ActionsMixin: + actions = ('add', 'import', 'export', 'bulk_edit', 'bulk_delete') + action_perms = defaultdict(set, **{ + 'add': {'add'}, + 'import': {'add'}, + 'bulk_edit': {'change'}, + 'bulk_delete': {'delete'}, + }) + + def get_permitted_actions(self, user, model=None): + """ + Return a tuple of actions for which the given user is permitted to do. + """ + model = model or self.queryset.model + return [ + action for action in self.actions if user.has_perms([ + get_permission_for_model(model, name) for name in self.action_perms[action] + ]) + ] + + +class TableMixin: + + def get_table(self, data, request, bulk_actions=True): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + Args: + data: Queryset or iterable containing table data + request: The current request + bulk_actions: Render checkboxes for object selection + """ + table = self.table(data, user=request.user) + if 'pk' in table.base_columns and bulk_actions: + table.columns.show('pk') + table.configure(request) + + return table diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 878f293a0..7c63b2ec6 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -13,13 +13,14 @@ from django.utils.safestring import mark_safe from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model from utilities.utils import get_viewname, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin from .base import BaseObjectView +from .mixins import ActionsMixin, TableMixin from .utils import get_prerequisite_model __all__ = ( @@ -70,12 +71,18 @@ class ObjectView(BaseObjectView): }) -class ObjectChildrenView(ObjectView): +class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): """ - Display a table of child objects associated with the parent object. + Display a table of child objects associated with the parent object. For example, NetBox uses this to display + the set of child IP addresses within a parent prefix. Attributes: - table: Table class used to render child objects list + child_model: The model class which represents the child objects + table: The django-tables2 Table class used to render the child objects list + filterset: A django-filter FilterSet that is applied to the queryset + actions: Supported actions for the model. When adding custom actions, bulk action names must + be prefixed with `bulk_`. Default actions: add, import, export, bulk_edit, bulk_delete + action_perms: A dictionary mapping supported actions to a set of permissions required for each """ child_model = None table = None @@ -85,8 +92,9 @@ class ObjectChildrenView(ObjectView): """ Return a QuerySet of child objects. - request: The current request - parent: The parent object + Args: + request: The current request + parent: The parent object """ raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()') @@ -115,16 +123,11 @@ class ObjectChildrenView(ObjectView): if self.filterset: child_objects = self.filterset(request.GET, child_objects).qs - permissions = {} - for action in ('change', 'delete'): - perm_name = get_permission_for_model(self.child_model, action) - permissions[action] = request.user.has_perm(perm_name) + # Determine the available actions + actions = self.get_permitted_actions(request.user, model=self.child_model) - table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user) - # Determine whether to display bulk action checkboxes - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') - table.configure(request) + table_data = self.prep_table_data(request, child_objects, instance) + table = self.get_table(table_data, request, bool(actions)) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -135,8 +138,9 @@ class ObjectChildrenView(ObjectView): return render(request, self.get_template_name(), { 'object': instance, + 'child_model': self.child_model, 'table': table, - 'permissions': permissions, + 'actions': actions, **self.get_extra_context(request, instance), }) @@ -244,10 +248,9 @@ class ObjectImportView(GetReturnURLMixin, BaseObjectView): except AbortTransaction: clear_webhooks.send(sender=self) - except PermissionsViolation: - msg = "Object creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) if not model_form.errors: @@ -402,11 +405,11 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): if '_addanother' in request.POST: redirect_url = request.path - # If the object has clone_fields, pre-populate a new instance of the form + # If cloning is supported, pre-populate a new instance of the form params = prepare_cloned_fields(obj) - if 'return_url' in request.GET: - params['return_url'] = request.GET.get('return_url') if params: + if 'return_url' in request.GET: + params['return_url'] = request.GET.get('return_url') redirect_url += f"?{params.urlencode()}" return redirect(redirect_url) @@ -415,10 +418,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): return redirect(return_url) - except PermissionsViolation: - msg = "Object save failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) else: @@ -494,11 +496,17 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): try: obj.delete() + except ProtectedError as e: logger.info("Caught ProtectedError while attempting to delete object") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) + except AbortRequest as e: + logger.debug(e.message) + messages.error(request, mark_safe(e.message)) + return redirect(obj.get_absolute_url()) + msg = 'Deleted {} {}'.format(self.queryset.model._meta.verbose_name, obj) logger.info(msg) messages.success(request, msg) @@ -608,10 +616,9 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView): else: return redirect(self.get_return_url(request)) - except PermissionsViolation: - msg = "Component creation failed due to object-level permissions violation" - logger.debug(msg) - form.add_error(None, msg) + except (AbortRequest, PermissionsViolation) as e: + logger.debug(e.message) + form.add_error(None, e.message) clear_webhooks.send(sender=self) return render(request, self.template_name, { diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css index 50622f128..ff431f4ad 100644 --- a/netbox/project-static/dist/cable_trace.css +++ b/netbox/project-static/dist/cable_trace.css @@ -1 +1 @@ -:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px} +:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg polyline{fill:none;stroke-width:5px}svg .cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px} diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489..229ea2f97 100644 --- a/netbox/project-static/dist/rack_elevation.css +++ b/netbox/project-static/dist/rack_elevation.css @@ -1 +1 @@ -svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.reserved:hover[class]+.add-device{fill:#000}svg .slot.reserved[class],svg .slot.reserved:hover[class]{fill:url(#reserved)}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0} +svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)} diff --git a/netbox/project-static/styles/cable-trace.scss b/netbox/project-static/styles/cable-trace.scss index 51d94d38a..59c67ad4d 100644 --- a/netbox/project-static/styles/cable-trace.scss +++ b/netbox/project-static/styles/cable-trace.scss @@ -55,7 +55,11 @@ svg { line { stroke-width: 5px; } - line.cable-shadow { + polyline { + fill: none; + stroke-width: 5px; + } + .cable-shadow { stroke: var(--nbx-trace-cable-shadow); stroke-width: 7px; } diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bf8063110..8d6bdddb9 100644 --- a/netbox/project-static/styles/rack-elevation.scss +++ b/netbox/project-static/styles/rack-elevation.scss @@ -48,6 +48,13 @@ svg { visibility: hidden; } + rect.shaded, image.shaded { + opacity: 25%; + } + text.shaded { + opacity: 50%; + } + // Rack elevation container. .rack { fill: none; @@ -81,17 +88,6 @@ svg { opacity: 1; } - // When a reserved slot is hovered, use a more readable color for the 'Add Device' text. - &.reserved:hover[class] + .add-device { - fill: $black; - } - - // Reserved rack unit background color. - &.reserved[class], - &.reserved:hover[class] { - fill: url(#reserved); - } - // Occupied rack unit background color. &.occupied[class], &.occupied:hover[class] { @@ -108,4 +104,9 @@ svg { opacity: 0; } } + + // Reservation background color. + .reservation[class] { + fill: url(#reserved); + } } diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 881b6cca6..a11139032 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -8,70 +8,78 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Circuit -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Provider{{ object.provider|linkify }}
    Circuit ID{{ object.cid }}
    Type{{ object.type|linkify }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
    Install Date{{ object.install_date|annotated_date|placeholder }}
    Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
    Description{{ object.description|placeholder }}
    -
    +
    +
    +
    +
    Circuit
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Provider{{ object.provider|linkify }}
    Circuit ID{{ object.cid }}
    Type{{ object.type|linkify }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
    Install Date{{ object.install_date|annotated_date|placeholder }}
    Termination Date{{ object.termination_date|annotated_date|placeholder }}
    Commit Rate{{ object.commit_rate|humanize_speed|placeholder }}
    Description{{ object.description|placeholder }}
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% plugin_left_page object %} -
    -
    - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} - {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/panels/contacts.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_right_page object %} -
    -
    -
    -
    - {% plugin_full_width_page object %} +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    +
    + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} +
    +
    + {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 8919bbd66..5196eddf2 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -10,6 +10,7 @@ {% render_field form.provider %} {% render_field form.circuit %} {% render_field form.term_side %} + {% render_field form.tags %} {% render_field form.mark_connected %} {% with providernetwork_tab_active=form.initial.provider_network %}
    @@ -47,4 +48,13 @@ {% render_field form.pp_info %} {% render_field form.description %}
    + + {% if form.custom_fields %} +
    +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index b673cd4a3..f4e0ea6ca 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -2,7 +2,6 @@
    - Termination - {{ side }} Side
    {% if not termination and perms.circuits.add_circuittermination %} @@ -10,10 +9,10 @@ {% endif %} {% if termination and perms.circuits.change_circuittermination %} - + Edit - + Swap {% endif %} @@ -23,6 +22,7 @@ {% endif %}
    +
    Termination {{ side }}
    {% if termination %} @@ -44,16 +44,15 @@ Marked as connected {% elif termination.cable %} - {{ termination.cable }} - {% with peer=termination.get_link_peer %} - to + {{ termination.cable }} to + {% for peer in termination.link_peers %} {% if peer.device %} {{ peer.device|linkify }}
    {% elif peer.circuit %} {{ peer.circuit|linkify }}
    {% endif %} - {{ peer|linkify }} - {% endwith %} + {{ peer|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} {% endif %} @@ -110,6 +109,33 @@ Description {{ termination.description|placeholder }} + + Tags + + {% for tag in termination.tags.all %} + {% tag tag %} + {% empty %} + {{ ''|placeholder }} + {% endfor %} + + + {% for group_name, fields in termination.get_custom_fields_by_group.items %} + + + {{ group_name|default:"Custom Fields" }} + + + {% for field, value in fields.items %} + + + {{ field }} + + + {% customfield_value field value %} + + + {% endfor %} + {% endfor %} {% else %} None diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index cd171cbb3..e032d7034 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -5,85 +5,79 @@ {% load plugins %} {% block content %} -
    -
    -
    -
    - Cable -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - -
    Type{{ object.get_type_display|placeholder }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
    Label{{ object.label|placeholder }}
    Color - {% if object.color %} -   - {% else %} - {{ ''|placeholder }} - {% endif %} -
    Length - {% if object.length %} - {{ object.length|floatformat }} {{ object.get_length_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} -
    -
    -
    -
    - Termination A -
    -
    - {% include 'dcim/inc/cable_termination.html' with termination=object.termination_a %} -
    -
    -
    -
    - Termination B -
    -
    - {% include 'dcim/inc/cable_termination.html' with termination=object.termination_b %} -
    -
    - {% plugin_right_page object %} +
    +
    +
    +
    Cable
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Type{{ object.get_type_display|placeholder }}
    Status{% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
    Label{{ object.label|placeholder }}
    Color + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} +
    Length + {% if object.length %} + {{ object.length|floatformat }} {{ object.get_length_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    - {% plugin_full_width_page object %} +
    +
    +
    Termination A
    +
    + {% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}
    +
    +
    +
    Termination B
    +
    + {% include 'dcim/inc/cable_termination.html' with terminations=object.b_terminations %} +
    +
    + {% plugin_right_page object %}
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html deleted file mode 100644 index 1d50040c7..000000000 --- a/netbox/templates/dcim/cable_connect.html +++ /dev/null @@ -1,186 +0,0 @@ -{% extends 'base/layout.html' %} -{% load static %} -{% load helpers %} -{% load form_helpers %} - -{% block title %}Connect {{ form.instance.termination_a.device }} {{ form.instance.termination_a }} to {{ termination_b_type|bettertitle }}{% endblock %} - -{% block tabs %} - -{% endblock %} - -{% block content-wrapper %} -
    - {% with termination_a=form.instance.termination_a %} - {% render_errors form %} -
    - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
    -
    -
    -
    A Side
    -
    - {% if termination_a.device %} - {# Device component #} -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    - {% else %} - {# Circuit termination #} -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    -
    - -
    - -
    -
    - {% endif %} -
    -
    -
    -
    - -
    -
    -
    -
    B Side
    -
    - {% if tabs %} - - {% endif %} - {% if 'termination_b_provider' in form.fields %} - {% render_field form.termination_b_provider %} - {% endif %} - {% if 'termination_b_region' in form.fields %} - {% render_field form.termination_b_region %} - {% endif %} - {% if 'termination_b_sitegroup' in form.fields %} - {% render_field form.termination_b_sitegroup %} - {% endif %} - {% if 'termination_b_site' in form.fields %} - {% render_field form.termination_b_site %} - {% endif %} - {% if 'termination_b_location' in form.fields %} - {% render_field form.termination_b_location %} - {% 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 %} -
    - -
    - -
    -
    - {% render_field form.termination_b_id %} -
    -
    -
    -
    -
    -
    -
    -
    Cable
    -
    - {% include 'dcim/inc/cable_form.html' %} -
    -
    -
    -
    -
    -
    - Cancel - -
    -
    -
    - {% endwith %} -
    -{% endblock %} diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html index 45f05faad..e2cef7601 100644 --- a/netbox/templates/dcim/cable_edit.html +++ b/netbox/templates/dcim/cable_edit.html @@ -1,5 +1,125 @@ -{% extends 'generic/object_edit.html' %} +{% extends 'base/layout.html' %} +{% load static %} +{% load helpers %} +{% load form_helpers %} -{% block form %} - {% include 'dcim/inc/cable_form.html' %} +{% block title %}Connect Cable{% endblock %} + +{% block tabs %} + +{% endblock %} + +{% block content-wrapper %} +
    + {% render_errors form %} +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    +
    +
    +
    A Side
    +
    + {% render_field form.termination_a_region %} + {% render_field form.termination_a_sitegroup %} + {% render_field form.termination_a_site %} + {% render_field form.termination_a_location %} + {% if 'termination_a_rack' in form.fields %} + {% render_field form.termination_a_rack %} + {% endif %} + {% if 'termination_a_device' in form.fields %} + {% render_field form.termination_a_device %} + {% endif %} + {% if 'termination_a_powerpanel' in form.fields %} + {% render_field form.termination_a_powerpanel %} + {% endif %} + {% if 'termination_a_provider' in form.fields %} + {% render_field form.termination_a_provider %} + {% endif %} + {% if 'termination_a_circuit' in form.fields %} + {% render_field form.termination_a_circuit %} + {% endif %} + {% render_field form.a_terminations %} +
    +
    +
    +
    + +
    +
    +
    +
    B Side
    +
    + {% render_field form.termination_b_region %} + {% render_field form.termination_b_sitegroup %} + {% render_field form.termination_b_site %} + {% render_field form.termination_b_location %} + {% 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_powerpanel' in form.fields %} + {% render_field form.termination_b_powerpanel %} + {% endif %} + {% if 'termination_b_provider' in form.fields %} + {% render_field form.termination_b_provider %} + {% endif %} + {% if 'termination_b_circuit' in form.fields %} + {% render_field form.termination_b_circuit %} + {% endif %} + {% render_field form.b_terminations %} +
    +
    +
    +
    +
    +
    +
    +
    Cable
    +
    + {% render_field form.status %} + {% render_field form.type %} + {% render_field form.tenant_group %} + {% render_field form.tenant %} + {% render_field form.label %} + {% render_field form.color %} +
    + +
    + {{ form.length }} +
    +
    + {{ form.length_unit }} +
    +
    +
    + {% render_field form.tags %} + {% if form.custom_fields %} +
    +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    + {% endif %} +
    +
    +
    +
    +
    +
    + Cancel + +
    +
    +
    +
    {% endblock %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index ac0481925..2611686f6 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -10,49 +10,49 @@
    {% if path %}
    - {% with traced_path=path.origin.trace %} - {% if path.is_split %} -

    Path split!

    -

    Select a node below to continue:

    -
      - {% for next_node in path.get_split_nodes %} - {% if next_node.cable %} -
    • - {{ next_node }} - (Cable {{ next_node.cable|linkify }}) -
    • - {% else %} -
    • {{ next_node }}
    • - {% endif %} - {% endfor %} -
    - {% else %} -

    Trace Completed

    - - - - - - - - - -
    Total segments{{ traced_path|length }}
    Total length - {% if total_length %} - {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / - {{ total_length|meters_to_feet|floatformat:"-2" }} Feet - {% else %} - N/A - {% endif %} -
    - {% endif %} - {% endwith %} + {% if path.is_split %} +

    Path split!

    +

    Select a node below to continue:

    +
      + {% for next_node in path.get_split_nodes %} + {% if next_node.cable %} +
    • + {{ next_node }} + (Cable {{ next_node.cable|linkify }}) +
    • + {% else %} +
    • {{ next_node }}
    • + {% endif %} + {% endfor %} +
    + {% else %} +

    Trace Completed

    + + + + + + + + + +
    Total segments{{ path.segment_count }}
    Total length + {% if total_length %} + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / + {{ total_length|meters_to_feet|floatformat:"-2" }} Feet + {% else %} + N/A + {% endif %} +
    + {% endif %}
    {% else %}

    @@ -80,18 +80,16 @@ {% for cablepath in related_paths %} - - {{ cablepath.origin.parent_object }} / {{ cablepath.origin }} - + {{ cablepath.origins|join:", " }} - {% if cablepath.destination %} - {{ cablepath.destination }} ({{ cablepath.destination.parent_object }}) + {% if cablepath.destinations %} + {{ cablepath.destinations|join:", " }} {% else %} Incomplete {% endif %} - + {{ cablepath.segment_count }} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index ce2c1655d..39ffbf552 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -111,28 +111,13 @@

    diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index 52b1a3229..642e758a3 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -113,28 +113,13 @@
    diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 68af0c08f..8286f2c61 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -46,7 +46,18 @@ Rack - {{ object.rack|linkify|placeholder }} + + {% if object.rack %} + {{ object.rack|linkify }} +
    + + + +
    + {% else %} + {{ ''|placeholder }} + {% endif %} + Position diff --git a/netbox/templates/dcim/device/consoleports.html b/netbox/templates/dcim/device/consoleports.html index 6f8b383c3..1f7cd037e 100644 --- a/netbox/templates/dcim/device/consoleports.html +++ b/netbox/templates/dcim/device/consoleports.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_consoleport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_consoleport %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_consoleport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/device/consoleserverports.html b/netbox/templates/dcim/device/consoleserverports.html index f246d4a82..259a072b4 100644 --- a/netbox/templates/dcim/device/consoleserverports.html +++ b/netbox/templates/dcim/device/consoleserverports.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_consoleserverport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_consoleserverport %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_consoleserverport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/device/devicebays.html b/netbox/templates/dcim/device/devicebays.html index d84408962..5081b752b 100644 --- a/netbox/templates/dcim/device/devicebays.html +++ b/netbox/templates/dcim/device/devicebays.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_devicebay %} + {% if 'bulk_edit' in actions %}
    {% endif %} - {% if perms.dcim.delete_devicebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/frontports.html b/netbox/templates/dcim/device/frontports.html index 513d02090..044337d00 100644 --- a/netbox/templates/dcim/device/frontports.html +++ b/netbox/templates/dcim/device/frontports.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_frontport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_frontport %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_frontport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/device/interfaces.html b/netbox/templates/dcim/device/interfaces.html index 2019d9135..9de486a6f 100644 --- a/netbox/templates/dcim/device/interfaces.html +++ b/netbox/templates/dcim/device/interfaces.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_interface %} + {% if 'bulk_edit' in actions %}
    {% endif %} - {% if perms.dcim.change_interface %} + {% if 'bulk_edit' in actions %}
    {% if perms.dcim.add_interface %} - + {% endif %}
    {% endblock %} {% block modals %} -{{ block.super }} -{% table_config_form table %} -{% endblock modals %} \ No newline at end of file + {{ block.super }} + {% table_config_form table %} +{% endblock modals %} diff --git a/netbox/templates/dcim/device/inventory.html b/netbox/templates/dcim/device/inventory.html index 8b74acaae..065fd92f6 100644 --- a/netbox/templates/dcim/device/inventory.html +++ b/netbox/templates/dcim/device/inventory.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_inventoryitem %} + {% if 'bulk_edit' in actions %}
    {% endif %} - {% if perms.dcim.delete_inventoryitem %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html index 67b9d88d4..6358a3815 100644 --- a/netbox/templates/dcim/device/modulebays.html +++ b/netbox/templates/dcim/device/modulebays.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_modulebay %} + {% if 'bulk_edit' in actions %}
    {% endif %} - {% if perms.dcim.delete_modulebay %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/dcim/device/poweroutlets.html b/netbox/templates/dcim/device/poweroutlets.html index 61c2b61f4..35a9795d5 100644 --- a/netbox/templates/dcim/device/poweroutlets.html +++ b/netbox/templates/dcim/device/poweroutlets.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_poweroutlet %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/device/powerports.html b/netbox/templates/dcim/device/powerports.html index cd8597e63..69485c985 100644 --- a/netbox/templates/dcim/device/powerports.html +++ b/netbox/templates/dcim/device/powerports.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_powerport %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_powerport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/device/rearports.html b/netbox/templates/dcim/device/rearports.html index b370de189..109e195dc 100644 --- a/netbox/templates/dcim/device/rearports.html +++ b/netbox/templates/dcim/device/rearports.html @@ -17,7 +17,7 @@
    - {% if perms.dcim.change_rearport %} + {% if 'bulk_edit' in actions %}
    {% endif %}
    - {% if perms.dcim.delete_rearport %} + {% if 'bulk_delete' in actions %} {% endif %} - {% if perms.dcim.change_rearport %} + {% if 'bulk_edit' in actions %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 891f217ee..2ef955fe9 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -41,7 +41,11 @@ Color -   + {% if object.color %} +   + {% else %} + {{ ''|placeholder }} + {% endif %} @@ -105,22 +109,22 @@
    diff --git a/netbox/templates/dcim/inc/cable_form.html b/netbox/templates/dcim/inc/cable_form.html deleted file mode 100644 index 0f11ac3cb..000000000 --- a/netbox/templates/dcim/inc/cable_form.html +++ /dev/null @@ -1,27 +0,0 @@ -{% load form_helpers %} - -{% render_field form.status %} -{% render_field form.type %} -{% render_field form.tenant_group %} -{% render_field form.tenant %} -{% render_field form.label %} -{% render_field form.color %} -
    - -
    - {{ form.length }} -
    -
    - {{ form.length_unit }} -
    -
    -
    -{% render_field form.tags %} -{% if form.custom_fields %} -
    -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -{% endif %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index 6d75aee85..ced9bda50 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -1,42 +1,62 @@ {% load helpers %} - - {% if termination.device %} - {# Device component #} - - - - - - - - - {% if termination.device.rack %} - - - - - {% endif %} - - - - - - - - - {% else %} - {# Circuit termination #} - - - - - - - - - - - - +{% if terminations.0 %} +
    Device{{ termination.device|linkify }}
    Site{{ termination.device.site|linkify }}
    Rack{{ termination.device.rack|linkify }}
    Type{{ termination|meta:"verbose_name"|capfirst }}
    Component{{ termination|linkify }}
    Provider{{ termination.circuit.provider|linkify }}
    Circuit{{ termination.circuit|linkify }}
    Termination{{ termination }}
    + {% if terminations.0.device %} + {# Device component #} + + + + + + + + + + + + + + + + + {% elif terminations.0.power_panel %} + {# Power feed #} + + + + + + + + + + + + + {% elif terminations.0.circuit %} + {# Circuit termination #} + + + + + + + + {% endif %} -
    Site{{ terminations.0.device.site|linkify }}
    Rack{{ terminations.0.device.rack|linkify|placeholder }}
    Device{{ terminations.0.device|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
    Site{{ terminations.0.power_panel.site|linkify }}
    Power Panel{{ terminations.0.power_panel|linkify }}
    {{ terminations.0|meta:"verbose_name"|capfirst }} + {% for term in terminations %} + {{ term|linkify }}{% if not forloop.last %},{% endif %} + {% endfor %} +
    Provider{{ terminations.0.circuit.provider|linkify }}
    Circuit + {% for term in terminations %} + {{ term.circuit|linkify }} ({{ term }}){% if not forloop.last %},{% endif %} + {% endfor %} +
    + +{% else %} + No termination +{% endif %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 27372193d..d2c4e4e08 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,8 +1,8 @@
    - +
    diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index c4cb8b72f..7503e1be2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -69,6 +69,14 @@ Description {{ object.description|placeholder }} + + PoE Mode + {{ object.get_poe_mode_display|placeholder }} + + + PoE Mode + {{ object.get_poe_type_display|placeholder }} + 802.1Q Mode {{ object.get_mode_display|placeholder }} @@ -96,6 +104,10 @@ LAG {{ object.lag|linkify|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
    @@ -207,7 +219,7 @@ Path Status - {% if object.path.is_active %} + {% if object.path.is_complete and object.path.is_active %} Reachable {% else %} Not Reachable @@ -251,24 +263,16 @@
    diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 62aa54ef3..a044de660 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -72,6 +72,14 @@
    {% endif %} +
    +
    +
    Power over Ethernet (PoE)
    +
    + {% render_field form.poe_mode %} + {% render_field form.poe_type %} +
    +
    802.1Q Switching
    diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index b2b2bc4cd..f0335036f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -43,6 +43,10 @@ Parent {{ object.parent|linkify|placeholder }} + + Status + {% badge object.get_status_display bg_color=object.get_status_color %} + Tenant diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index ed1f9a1cd..584454df8 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -158,8 +158,7 @@ {% if not object.mark_connected and not object.cable %}
    @@ -61,10 +73,6 @@ {% endif %} - - Filter Logic - {{ object.get_filter_logic_display }} -
    diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 451c530e1..ef95ccdc0 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -59,7 +59,7 @@ Context: {# Extra buttons #} {% block extra_controls %}{% endblock %} - {% if object.clone_fields and request.user|can_add:object %} + {% if request.user|can_add:object %} {% clone_button object %} {% endif %} {% if request.user|can_change:object %} diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 6d23c81aa..616b1c712 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -1,49 +1,28 @@ {% load helpers %} -{% with custom_fields=object.get_custom_fields %} - {% if custom_fields %} -
    -
    Custom Fields
    -
    - - {% for field, value in custom_fields.items %} - - - - - {% endfor %} -
    - {{ field }} - - {% if field.type == 'integer' and value is not None %} - {{ value }} - {% elif field.type == 'longtext' and value %} - {{ value|markdown }} - {% elif field.type == 'boolean' and value == True %} - {% checkmark value true="True" %} - {% elif field.type == 'boolean' and value == False %} - {% checkmark value false="False" %} - {% elif field.type == 'url' and value %} - {{ value|truncatechars:70 }} - {% elif field.type == 'json' and value %} -
    {{ value|json }}
    - {% elif field.type == 'multiselect' and value %} - {{ value|join:", " }} - {% elif field.type == 'object' and value %} - {{ value|linkify }} - {% elif field.type == 'multiobject' and value %} - {% for obj in value %} - {{ obj|linkify }}{% if not forloop.last %}
    {% endif %} - {% endfor %} - {% elif value %} - {{ value }} - {% elif field.required %} - Not defined - {% else %} - {{ ''|placeholder }} - {% endif %} -
    -
    -
    - {% endif %} +{% with custom_fields=object.get_custom_fields_by_group %} + {% if custom_fields %} +
    +
    Custom Fields
    +
    + {% for group_name, fields in custom_fields.items %} + {% if group_name %} +
    {{ group_name }}
    + {% endif %} + + {% for field, value in fields.items %} + + + + + {% endfor %} +
    + {{ field }} + + {% customfield_value field value %} +
    + {% endfor %} +
    +
    + {% endif %} {% endwith %} diff --git a/netbox/templates/inc/profile_button.html b/netbox/templates/inc/profile_button.html index 1e562651f..b63b25464 100644 --- a/netbox/templates/inc/profile_button.html +++ b/netbox/templates/inc/profile_button.html @@ -19,15 +19,20 @@ {% endif %}
  • - + Profile
  • - + Preferences
  • +
  • + + API Tokens + +
  • diff --git a/netbox/templates/ipam/aggregate/prefixes.html b/netbox/templates/ipam/aggregate/prefixes.html index d1b48429a..8256236f4 100644 --- a/netbox/templates/ipam/aggregate/prefixes.html +++ b/netbox/templates/ipam/aggregate/prefixes.html @@ -25,12 +25,12 @@
    - {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html index d1d49e7cc..8b628c2f7 100644 --- a/netbox/templates/ipam/ipaddress.html +++ b/netbox/templates/ipam/ipaddress.html @@ -91,8 +91,14 @@ - NAT (outside) - {{ object.nat_outside|linkify|placeholder }} + Outside NAT IPs + + {% for ip in object.nat_outside.all %} + {{ ip|linkify }}
    + {% empty %} + {{ ''|placeholder }} + {% endfor %} +
    diff --git a/netbox/templates/ipam/iprange/ip_addresses.html b/netbox/templates/ipam/iprange/ip_addresses.html index d9ac77fd0..61b2ee335 100644 --- a/netbox/templates/ipam/iprange/ip_addresses.html +++ b/netbox/templates/ipam/iprange/ip_addresses.html @@ -23,12 +23,12 @@
    - {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/l2vpn.html b/netbox/templates/ipam/l2vpn.html new file mode 100644 index 000000000..44a1da818 --- /dev/null +++ b/netbox/templates/ipam/l2vpn.html @@ -0,0 +1,75 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block content %} +
    +
    +
    +
    L2VPN Attributes
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name|placeholder }}
    Identifier{{ object.identifier|placeholder }}
    Type{{ object.get_type_display }}
    Description{{ object.description|placeholder }}
    Tenant{{ object.tenant|linkify|placeholder }}
    +
    +
    + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpn_list' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/contacts.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %} +
    +
    + {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %} +
    +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination.html b/netbox/templates/ipam/l2vpntermination.html new file mode 100644 index 000000000..b34d1a710 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination.html @@ -0,0 +1,31 @@ +{% extends 'generic/object.html' %} +{% load helpers %} + +{% block content %} +
    +
    +
    +
    + L2VPN Attributes +
    +
    + + + + + + + + + +
    L2VPN{{ object.l2vpn|linkify }}
    Assigned Object{{ object.assigned_object|linkify }}
    +
    +
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' with tags=object.tags.all url='ipam:l2vpntermination_list' %} +
    +
    + +{% endblock %} diff --git a/netbox/templates/ipam/l2vpntermination_edit.html b/netbox/templates/ipam/l2vpntermination_edit.html new file mode 100644 index 000000000..c66b8a3d1 --- /dev/null +++ b/netbox/templates/ipam/l2vpntermination_edit.html @@ -0,0 +1,49 @@ +{% extends 'generic/object_edit.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block form %} +
    +
    +
    L2VPN Termination
    +
    + {% render_field form.l2vpn %} +
    +
    + +
    +
    +
    +
    +
    + {% render_field form.device_vlan %} + {% render_field form.vlan %} +
    +
    + {% render_field form.device %} + {% render_field form.interface %} +
    +
    + {% render_field form.virtual_machine %} + {% render_field form.vminterface %} +
    +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index d734b825f..31a22497d 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -23,12 +23,12 @@
    - {% if perms.ipam.change_ipaddress %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_ipaddress %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 268c290a1..45b1d4fd0 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -23,12 +23,12 @@
    - {% if perms.ipam.change_iprange %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_iprange %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 5d42596ba..46fa29581 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -25,12 +25,12 @@
    - {% if perms.ipam.change_prefix %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.ipam.delete_prefix %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index fd0ba36a3..53bb75b8f 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -64,6 +64,10 @@ Description {{ object.description|placeholder }} + + L2VPN + {{ object.l2vpn_termination.l2vpn|linkify|placeholder }} +
    diff --git a/netbox/templates/tenancy/tenant.html b/netbox/templates/tenancy/tenant.html index 52c13e1aa..e8dc4b23a 100644 --- a/netbox/templates/tenancy/tenant.html +++ b/netbox/templates/tenancy/tenant.html @@ -61,6 +61,10 @@

    {{ stats.device_count }}

    Devices

    +

    {{ stats.vrf_count }}

    VRFs

    @@ -102,8 +106,12 @@

    Clusters

    +
    +

    {{ stats.wirelesslink_count }}

    +

    Wireless Links

    diff --git a/netbox/templates/users/api_tokens.html b/netbox/templates/users/api_tokens.html index 01ffec23a..e1641468c 100644 --- a/netbox/templates/users/api_tokens.html +++ b/netbox/templates/users/api_tokens.html @@ -1,63 +1,25 @@ {% extends 'users/base.html' %} {% load helpers %} +{% load render_table from django_tables2 %} {% block title %}API Tokens{% endblock %} {% block content %} -
    -
    - {% for token in tokens %} -
    -
    -
    - Copy - Edit - Delete -
    - - {{ token.key }} - {% if token.is_expired %} - Expired - {% endif %} -
    -
    -
    -
    - Created
    - {{ token.created|annotated_date }} -
    -
    - Expires
    - {% if token.expires %} - {{ token.expires|annotated_date }} - {% else %} - Never - {% endif %} -
    -
    - Create/Edit/Delete Operations
    - {% if token.write_enabled %} - Enabled - {% else %} - Disabled - {% endif %} -
    -
    - {% if token.description %} -
    {{ token.description }} - {% endif %} -
    -
    - {% empty %} -
    You do not have any API tokens.
    -

    Tokens are used to authenticate REST and GraphQL API requests.

    - {% endfor %} - -
    + +
    +
    +
    +
    + {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
    +
    +
    {% endblock %} diff --git a/netbox/templates/users/base.html b/netbox/templates/users/base.html index cf4cd585d..58861ee90 100644 --- a/netbox/templates/users/base.html +++ b/netbox/templates/users/base.html @@ -3,18 +3,18 @@ {% block tabs %} {% endblock %} diff --git a/netbox/templates/users/password.html b/netbox/templates/users/password.html index 81082792d..02e80bb26 100644 --- a/netbox/templates/users/password.html +++ b/netbox/templates/users/password.html @@ -13,7 +13,7 @@ {% render_field form.new_password2 %}
    - Cancel + Cancel
    diff --git a/netbox/templates/users/preferences.html b/netbox/templates/users/preferences.html index 6ce60a458..f2c88db3c 100644 --- a/netbox/templates/users/preferences.html +++ b/netbox/templates/users/preferences.html @@ -79,7 +79,7 @@
    - Cancel + Cancel
    diff --git a/netbox/templates/virtualization/cluster/virtual_machines.html b/netbox/templates/virtualization/cluster/virtual_machines.html index 953d9f940..9cb33258f 100644 --- a/netbox/templates/virtualization/cluster/virtual_machines.html +++ b/netbox/templates/virtualization/cluster/virtual_machines.html @@ -14,12 +14,12 @@
    - {% if perms.virtualization.change_virtualmachine %} + {% if 'bulk_edit' in actions %} {% endif %} - {% if perms.virtualization.delete_virtualmachine %} + {% if 'bulk_delete' in actions %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 61f9aa61a..f62da6fed 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -78,31 +78,39 @@
    -
    - Cluster -
    +
    Cluster
    + + + + + + + +
    Site + {{ object.site|linkify|placeholder }} +
    Cluster {% if object.cluster.group %} {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster|linkify }} + {{ object.cluster|linkify|placeholder }}
    Cluster Type {{ object.cluster.type }}
    Device + {{ object.device|linkify|placeholder }} +
    -
    - Resources -
    +
    Resources
    diff --git a/netbox/templates/virtualization/virtualmachine/base.html b/netbox/templates/virtualization/virtualmachine/base.html index 0c2f43de8..946467e31 100644 --- a/netbox/templates/virtualization/virtualmachine/base.html +++ b/netbox/templates/virtualization/virtualmachine/base.html @@ -5,7 +5,13 @@ {% block breadcrumbs %} {{ block.super }} - + {% endblock %} {% block extra_controls %} diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index 185a44904..9250ef7ef 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -6,36 +6,45 @@ {% block content %}
    -
    -
    Wireless LAN
    -
    -
    - - - - - - - - - - - - - - - - -
    SSID{{ object.ssid }}
    Group{{ object.group|linkify|placeholder }}
    Description{{ object.description|placeholder }}
    VLAN{{ object.vlan|linkify|placeholder }}
    -
    -
    - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    Wireless LAN
    +
    + + + + + + + + + + + + + + + + + + + + + +
    SSID{{ object.ssid }}
    Group{{ object.group|linkify|placeholder }}
    Description{{ object.description|placeholder }}
    VLAN{{ object.vlan|linkify|placeholder }}
    Tenant + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
    +
    -
    - {% include 'wireless/inc/authentication_attrs.html' %} - {% include 'inc/panels/custom_fields.html' %} - {% plugin_right_page object %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'wireless/inc/authentication_attrs.html' %} + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %}
    diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 4795dcdde..d1a93e40d 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -23,6 +23,15 @@ SSID {{ object.ssid|placeholder }} + + Tenant + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + + Description {{ object.description|placeholder }} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 00ac6ff84..2f95eca8c 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from tenancy.models import * __all__ = [ diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index 626554077..f217fdaf8 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -2,7 +2,7 @@ from django.contrib.auth.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from netbox.api import ChoiceField, ContentTypeField +from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from netbox.constants import NESTED_SERIALIZER_PREFIX from tenancy.choices import ContactPriorityChoices diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 7dbe59ea4..18ea98241 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 75ec9f69c..41881f853 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -112,9 +112,9 @@ class Contact(NetBoxModel): blank=True ) - clone_fields = [ - 'group', - ] + clone_fields = ( + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', + ) class Meta: ordering = ['name'] @@ -155,7 +155,7 @@ class ContactAssignment(WebhooksMixin, ChangeLoggedModel): blank=True ) - clone_fields = ('content_type', 'object_id') + clone_fields = ('content_type', 'object_id', 'role', 'priority') class Meta: ordering = ('priority', 'contact') diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index 88d8d52f1..b0ccd1cb2 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -76,9 +76,9 @@ class Tenant(NetBoxModel): to='tenancy.ContactAssignment' ) - clone_fields = [ + clone_fields = ( 'group', 'description', - ] + ) class Meta: ordering = ['name'] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index f6f95b123..9a2fe6ab9 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,6 +7,7 @@ from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN from netbox.views import generic from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster +from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * @@ -94,7 +95,7 @@ class TenantListView(generic.ObjectListView): class TenantView(generic.ObjectView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() def get_extra_context(self, request, instance): stats = { @@ -114,6 +115,8 @@ class TenantView(generic.ObjectView): 'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(), 'asn_count': ASN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'wirelesslan_count': WirelessLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(), + 'wirelesslink_count': WirelessLink.objects.restrict(request.user, 'view').filter(tenant=instance).count(), } return { @@ -137,14 +140,14 @@ class TenantBulkImportView(generic.BulkImportView): class TenantBulkEditView(generic.BulkEditView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() filterset = filtersets.TenantFilterSet table = tables.TenantTable form = forms.TenantBulkEditForm class TenantBulkDeleteView(generic.BulkDeleteView): - queryset = Tenant.objects.prefetch_related('group') + queryset = Tenant.objects.all() filterset = filtersets.TenantFilterSet table = tables.TenantTable @@ -334,14 +337,14 @@ class ContactBulkImportView(generic.BulkImportView): class ContactBulkEditView(generic.BulkEditView): - queryset = Contact.objects.prefetch_related('group') + queryset = Contact.objects.all() filterset = filtersets.ContactFilterSet table = tables.ContactTable form = forms.ContactBulkEditForm class ContactBulkDeleteView(generic.BulkDeleteView): - queryset = Contact.objects.prefetch_related('group') + queryset = Contact.objects.all() filterset = filtersets.ContactFilterSet table = tables.ContactTable diff --git a/netbox/users/admin/__init__.py b/netbox/users/admin/__init__.py index 1b163ed06..2db822cfe 100644 --- a/netbox/users/admin/__init__.py +++ b/netbox/users/admin/__init__.py @@ -58,9 +58,13 @@ class UserAdmin(UserAdmin_): class TokenAdmin(admin.ModelAdmin): form = forms.TokenAdminForm list_display = [ - 'key', 'user', 'created', 'expires', 'write_enabled', 'description' + 'key', 'user', 'created', 'expires', 'last_used', 'write_enabled', 'description', 'list_allowed_ips' ] + def list_allowed_ips(self, obj): + return obj.allowed_ips or 'Any' + list_allowed_ips.short_description = "Allowed IPs" + # # Permissions diff --git a/netbox/users/admin/forms.py b/netbox/users/admin/forms.py index 7d0212441..540735ecc 100644 --- a/netbox/users/admin/forms.py +++ b/netbox/users/admin/forms.py @@ -3,11 +3,11 @@ from django.contrib.auth.models import Group, User from django.contrib.admin.widgets import FilteredSelectMultiple from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, ValidationError -from django.db.models import Q -from users.constants import OBJECTPERMISSION_OBJECT_TYPES +from users.constants import CONSTRAINT_TOKEN_USER, OBJECTPERMISSION_OBJECT_TYPES from users.models import ObjectPermission, Token from utilities.forms.fields import ContentTypeMultipleChoiceField +from utilities.permissions import qs_filter_from_constraints __all__ = ( 'GroupAdminForm', @@ -51,7 +51,7 @@ class TokenAdminForm(forms.ModelForm): class Meta: fields = [ - 'user', 'key', 'write_enabled', 'expires', 'description' + 'user', 'key', 'write_enabled', 'expires', 'description', 'allowed_ips' ] model = Token @@ -125,7 +125,10 @@ class ObjectPermissionForm(forms.ModelForm): for ct in object_types: model = ct.model_class() try: - model.objects.filter(*[Q(**c) for c in constraints]).exists() + tokens = { + CONSTRAINT_TOKEN_USER: 0, # Replace token with a null user ID + } + model.objects.filter(qs_filter_from_constraints(constraints, tokens)).exists() except FieldError as e: raise ValidationError({ 'constraints': f'Invalid filter for {model}: {e}' diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 51e0c5b26..e9e730cc4 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -2,7 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, WritableNestedSerializer +from netbox.api.fields import ContentTypeField +from netbox.api.serializers import WritableNestedSerializer from users.models import ObjectPermission, Token __all__ = [ diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 059bb0bd7..1ec3528f7 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -2,7 +2,8 @@ from django.contrib.auth.models import Group, User from django.contrib.contenttypes.models import ContentType from rest_framework import serializers -from netbox.api import ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api.fields import ContentTypeField, IPNetworkSerializer, SerializedPKRelatedField +from netbox.api.serializers import ValidatedModelSerializer from users.models import ObjectPermission, Token from .nested_serializers import * @@ -64,10 +65,19 @@ class TokenSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:token-detail') key = serializers.CharField(min_length=40, max_length=40, allow_blank=True, required=False) user = NestedUserSerializer() + allowed_ips = serializers.ListField( + child=IPNetworkSerializer(), + required=False, + allow_empty=True, + default=[] + ) class Meta: model = Token - fields = ('id', 'url', 'display', 'user', 'created', 'expires', 'key', 'write_enabled', 'description') + fields = ( + 'id', 'url', 'display', 'user', 'created', 'expires', 'last_used', 'key', 'write_enabled', 'description', + 'allowed_ips', + ) def to_internal_value(self, data): if 'key' not in data: diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index f46cc1680..599d0bb61 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,6 +1,6 @@ from django.urls import include, path -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/users/constants.py b/netbox/users/constants.py index e6917c482..1e6e7c71c 100644 --- a/netbox/users/constants.py +++ b/netbox/users/constants.py @@ -6,3 +6,5 @@ OBJECTPERMISSION_OBJECT_TYPES = Q( Q(app_label='auth', model__in=['group', 'user']) | Q(app_label='users', model__in=['objectpermission', 'token']) ) + +CONSTRAINT_TOKEN_USER = '$user' diff --git a/netbox/users/forms.py b/netbox/users/forms.py index d5e6218e5..b4e86461d 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,7 +1,9 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.contrib.postgres.forms import SimpleArrayField from django.utils.html import mark_safe +from ipam.formfields import IPNetworkFormField from netbox.preferences import PREFERENCES from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect from utilities.utils import flatten_dict @@ -99,11 +101,18 @@ class TokenForm(BootstrapMixin, forms.ModelForm): required=False, help_text="If no key is provided, one will be generated automatically." ) + allowed_ips = SimpleArrayField( + base_field=IPNetworkFormField(), + required=False, + label='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Example: 10.1.1.0/24,192.168.10.16/32,2001:db8:1::/64', + ) class Meta: model = Token fields = [ - 'key', 'write_enabled', 'expires', 'description', + 'key', 'write_enabled', 'expires', 'description', 'allowed_ips', ] widgets = { 'expires': DateTimePicker(), diff --git a/netbox/users/migrations/0003_token_allowed_ips_last_used.py b/netbox/users/migrations/0003_token_allowed_ips_last_used.py new file mode 100644 index 000000000..946226f75 --- /dev/null +++ b/netbox/users/migrations/0003_token_allowed_ips_last_used.py @@ -0,0 +1,23 @@ +import django.contrib.postgres.fields +from django.db import migrations, models +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='token', + name='allowed_ips', + field=django.contrib.postgres.fields.ArrayField(base_field=ipam.fields.IPNetworkField(), blank=True, null=True, size=None), + ), + migrations.AddField( + model_name='token', + name='last_used', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 23068442e..4ee4dce6b 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -9,13 +9,14 @@ from django.db import models from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone +from netaddr import IPNetwork +from ipam.fields import IPNetworkField from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * - __all__ = ( 'ObjectPermission', 'Token', @@ -203,6 +204,10 @@ class Token(models.Model): blank=True, null=True ) + last_used = models.DateTimeField( + blank=True, + null=True + ) key = models.CharField( max_length=40, unique=True, @@ -216,6 +221,14 @@ class Token(models.Model): max_length=200, blank=True ) + allowed_ips = ArrayField( + base_field=IPNetworkField(), + blank=True, + null=True, + verbose_name='Allowed IPs', + help_text='Allowed IPv4/IPv6 networks from where the token can be used. Leave blank for no restrictions. ' + 'Ex: "10.1.1.0/24, 192.168.10.16/32, 2001:DB8:1::/64"', + ) class Meta: pass @@ -240,6 +253,19 @@ class Token(models.Model): return False return True + def validate_client_ip(self, client_ip): + """ + Validate the API client IP address against the source IP restrictions (if any) set on the token. + """ + if not self.allowed_ips: + return True + + for ip_network in self.allowed_ips: + if client_ip in IPNetwork(ip_network): + return True + + return False + # # Permissions diff --git a/netbox/users/tables.py b/netbox/users/tables.py new file mode 100644 index 000000000..27547b955 --- /dev/null +++ b/netbox/users/tables.py @@ -0,0 +1,42 @@ +from .models import Token +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'TokenTable', +) + + +TOKEN = """{{ value }}""" + +ALLOWED_IPS = """{{ value|join:", " }}""" + +COPY_BUTTON = """ + + + +""" + + +class TokenTable(NetBoxTable): + key = columns.TemplateColumn( + template_code=TOKEN + ) + write_enabled = columns.BooleanColumn( + verbose_name='Write' + ) + created = columns.DateColumn() + expired = columns.DateColumn() + last_used = columns.DateTimeColumn() + allowed_ips = columns.TemplateColumn( + template_code=ALLOWED_IPS + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=COPY_BUTTON + ) + + class Meta(NetBoxTable.Meta): + model = Token + fields = ( + 'pk', 'key', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips', 'description', + ) diff --git a/netbox/users/urls.py b/netbox/users/urls.py index 0cfcfc9de..62b17a663 100644 --- a/netbox/users/urls.py +++ b/netbox/users/urls.py @@ -2,7 +2,7 @@ from django.urls import path from . import views -app_name = 'user' +app_name = 'users' urlpatterns = [ path('profile/', views.ProfileView.as_view(), name='profile'), diff --git a/netbox/users/views.py b/netbox/users/views.py index f08cac844..06259b5ec 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -22,6 +22,7 @@ from netbox.config import get_config from utilities.forms import ConfirmationForm from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token, UserConfig +from .tables import TokenTable # @@ -164,7 +165,7 @@ class UserConfigView(LoginRequiredMixin, View): form.save() messages.success(request, "Your preferences have been updated.") - return redirect('user:preferences') + return redirect('users:preferences') return render(request, self.template_name, { 'form': form, @@ -179,7 +180,7 @@ class ChangePasswordView(LoginRequiredMixin, View): # LDAP users cannot change their password here if getattr(request.user, 'ldap_username', None): messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.") - return redirect('user:profile') + return redirect('users:profile') form = PasswordChangeForm(user=request.user) @@ -194,7 +195,7 @@ class ChangePasswordView(LoginRequiredMixin, View): form.save() update_session_auth_hash(request, form.user) messages.success(request, "Your password has been changed successfully.") - return redirect('user:profile') + return redirect('users:profile') return render(request, self.template_name, { 'form': form, @@ -211,10 +212,13 @@ class TokenListView(LoginRequiredMixin, View): def get(self, request): tokens = Token.objects.filter(user=request.user) + table = TokenTable(tokens) + table.configure(request) return render(request, 'users/api_tokens.html', { 'tokens': tokens, 'active_tab': 'api-tokens', + 'table': table, }) @@ -232,7 +236,7 @@ class TokenEditView(LoginRequiredMixin, View): return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) def post(self, request, pk=None): @@ -255,12 +259,12 @@ class TokenEditView(LoginRequiredMixin, View): if '_addanother' in request.POST: return redirect(request.path) else: - return redirect('user:token_list') + return redirect('users:token_list') return render(request, 'generic/object_edit.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) @@ -270,14 +274,14 @@ class TokenDeleteView(LoginRequiredMixin, View): token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk) initial_data = { - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), } form = ConfirmationForm(initial=initial_data) return render(request, 'generic/object_delete.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) def post(self, request, pk): @@ -287,10 +291,10 @@ class TokenDeleteView(LoginRequiredMixin, View): if form.is_valid(): token.delete() messages.success(request, "Token deleted") - return redirect('user:token_list') + return redirect('users:token_list') return render(request, 'generic/object_delete.html', { 'object': token, 'form': form, - 'return_url': reverse('user:token_list'), + 'return_url': reverse('users:token_list'), }) diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index b110a9123..1a5ede23f 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -6,7 +6,8 @@ from rest_framework.fields import ChoiceField from rest_framework.relations import ManyRelatedField from extras.api.customfields import CustomFieldsDataField -from netbox.api import ChoiceField, SerializedPKRelatedField, WritableNestedSerializer +from netbox.api.fields import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import WritableNestedSerializer class NetBoxSwaggerAutoSchema(SwaggerAutoSchema): diff --git a/netbox/utilities/exceptions.py b/netbox/utilities/exceptions.py index 4ba62bc01..657e90745 100644 --- a/netbox/utilities/exceptions.py +++ b/netbox/utilities/exceptions.py @@ -1,6 +1,13 @@ from rest_framework import status from rest_framework.exceptions import APIException +__all__ = ( + 'AbortRequest', + 'AbortTransaction', + 'PermissionsViolation', + 'RQWorkerNotRunningException', +) + class AbortTransaction(Exception): """ @@ -9,12 +16,20 @@ class AbortTransaction(Exception): pass +class AbortRequest(Exception): + """ + Raised to cleanly abort a request (for example, by a pre_save signal receiver). + """ + def __init__(self, message): + self.message = message + + class PermissionsViolation(Exception): """ Raised when an operation was prevented because it would violate the allowed permissions. """ - pass + message = "Operation failed due to object-level permissions violation" class RQWorkerNotRunningException(APIException): diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 9a4b011e0..a6f037e0b 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,7 +1,6 @@ import re from django import forms -from django.conf import settings from django.forms.models import fields_for_model from utilities.choices import unpack_grouped_choices diff --git a/netbox/utilities/permissions.py b/netbox/utilities/permissions.py index b11bf504a..b20aafce0 100644 --- a/netbox/utilities/permissions.py +++ b/netbox/utilities/permissions.py @@ -1,5 +1,14 @@ from django.conf import settings from django.contrib.contenttypes.models import ContentType +from django.db.models import Q + +__all__ = ( + 'get_permission_for_model', + 'permission_is_exempt', + 'qs_filter_from_constraints', + 'resolve_permission', + 'resolve_permission_ct', +) def get_permission_for_model(model, action): @@ -69,3 +78,29 @@ def permission_is_exempt(name): return True return False + + +def qs_filter_from_constraints(constraints, tokens=None): + """ + Construct a Q filter object from an iterable of ObjectPermission constraints. + + Args: + tokens: A dictionary mapping string tokens to be replaced with a value. + """ + if tokens is None: + tokens = {} + + def _replace_tokens(value, tokens): + if type(value) is list: + return list(map(lambda v: tokens.get(v, v), value)) + return tokens.get(value, value) + + params = Q() + for constraint in constraints: + if constraint: + params |= Q(**{k: _replace_tokens(v, tokens) for k, v in constraint.items()}) + else: + # Found null constraint; permit model-level access + return Q() + + return params diff --git a/netbox/utilities/querysets.py b/netbox/utilities/querysets.py index 97d2e8779..955a10d64 100644 --- a/netbox/utilities/querysets.py +++ b/netbox/utilities/querysets.py @@ -1,6 +1,7 @@ -from django.db.models import Q, QuerySet +from django.db.models import QuerySet -from utilities.permissions import permission_is_exempt +from users.constants import CONSTRAINT_TOKEN_USER +from utilities.permissions import permission_is_exempt, qs_filter_from_constraints class RestrictedQuerySet(QuerySet): @@ -28,23 +29,13 @@ class RestrictedQuerySet(QuerySet): # Filter the queryset to include only objects with allowed attributes else: - attrs = Q() - for perm_attrs in user._object_perm_cache[permission_required]: - if type(perm_attrs) is list: - for p in perm_attrs: - attrs |= Q(**p) - elif perm_attrs: - attrs |= Q(**perm_attrs) - else: - # Any permission with null constraints grants access to _all_ instances - attrs = Q() - break - else: - # for else, when no break - # avoid duplicates when JOIN on many-to-many fields without using DISTINCT. - # DISTINCT acts globally on the entire request, which may not be desirable. - allowed_objects = self.model.objects.filter(attrs) - attrs = Q(pk__in=allowed_objects) - qs = self.filter(attrs) + tokens = { + CONSTRAINT_TOKEN_USER: user, + } + attrs = qs_filter_from_constraints(user._object_perm_cache[permission_required], tokens) + # #8715: Avoid duplicates when JOIN on many-to-many fields without using DISTINCT. + # DISTINCT acts globally on the entire request, which may not be desirable. + allowed_objects = self.model.objects.filter(attrs) + qs = self.filter(pk__in=allowed_objects) return qs diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py new file mode 100644 index 000000000..3b8e1edde --- /dev/null +++ b/netbox/utilities/request.py @@ -0,0 +1,27 @@ +from netaddr import IPAddress + +__all__ = ( + 'get_client_ip', +) + + +def get_client_ip(request, additional_headers=()): + """ + Return the client (source) IP address of the given request. + """ + HTTP_HEADERS = ( + 'HTTP_X_REAL_IP', + 'HTTP_X_FORWARDED_FOR', + 'REMOTE_ADDR', + *additional_headers + ) + for header in HTTP_HEADERS: + if header in request.META: + client_ip = request.META[header].split(',')[0] + try: + return IPAddress(client_ip) + except ValueError: + raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + + # Could not determine the client IP address from request headers + return None diff --git a/netbox/utilities/templates/builtins/customfield_value.html b/netbox/utilities/templates/builtins/customfield_value.html new file mode 100644 index 000000000..ff93a5168 --- /dev/null +++ b/netbox/utilities/templates/builtins/customfield_value.html @@ -0,0 +1,27 @@ +{% if customfield.type == 'integer' and value is not None %} + {{ value }} +{% elif customfield.type == 'longtext' and value %} + {{ value|markdown }} +{% elif customfield.type == 'boolean' and value == True %} + {% checkmark value true="True" %} +{% elif customfield.type == 'boolean' and value == False %} + {% checkmark value false="False" %} +{% elif customfield.type == 'url' and value %} + {{ value|truncatechars:70 }} +{% elif customfield.type == 'json' and value %} +
    {{ value|json }}
    +{% elif customfield.type == 'multiselect' and value %} + {{ value|join:", " }} +{% elif customfield.type == 'object' and value %} + {{ value|linkify }} +{% elif customfield.type == 'multiobject' and value %} + {% for object in value %} + {{ object|linkify }}{% if not forloop.last %}
    {% endif %} + {% endfor %} +{% elif value %} + {{ value }} +{% elif customfield.required %} + Not defined +{% else %} + {{ ''|placeholder }} +{% endif %} diff --git a/netbox/utilities/templates/form_helpers/render_custom_fields.html b/netbox/utilities/templates/form_helpers/render_custom_fields.html index f3e5bffa9..6b0b2840b 100644 --- a/netbox/utilities/templates/form_helpers/render_custom_fields.html +++ b/netbox/utilities/templates/form_helpers/render_custom_fields.html @@ -1,7 +1,12 @@ {% load form_helpers %} -{% for field in form %} - {% if field.name in form.custom_fields %} - {% render_field field %} - {% endif %} +{% for group, fields in form.custom_field_groups.items %} + {% if group %} +
    +
    {{ group }}
    +
    + {% endif %} + {% for name in fields %} + {% render_field form|getfield:name %} + {% endfor %} {% endfor %} diff --git a/netbox/utilities/templates/navigation/menu.html b/netbox/utilities/templates/navigation/menu.html index dfc85968a..33a476081 100644 --- a/netbox/utilities/templates/navigation/menu.html +++ b/netbox/utilities/templates/navigation/menu.html @@ -1,58 +1,43 @@ {% load helpers %} +
    +
  • + {% endfor %} diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 666b6a31c..ed464b332 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -18,6 +18,21 @@ def tag(value, viewname=None): } +@register.inclusion_tag('builtins/customfield_value.html') +def customfield_value(customfield, value): + """ + Render a custom field value according to the field type. + + Args: + customfield: A CustomField instance + value: The custom field value applied to an object + """ + return { + 'customfield': customfield, + 'value': value, + } + + @register.inclusion_tag('builtins/badge.html') def badge(value, bg_color=None, show_empty=False): """ diff --git a/netbox/utilities/templatetags/navigation.py b/netbox/utilities/templatetags/navigation.py index ede8792fa..ef0657446 100644 --- a/netbox/utilities/templatetags/navigation.py +++ b/netbox/utilities/templatetags/navigation.py @@ -13,7 +13,26 @@ def nav(context: Context) -> Dict: """ Render the navigation menu. """ + user = context['request'].user + nav_items = [] + + # Construct the navigation menu based upon the current user's permissions + for menu in MENUS: + groups = [] + for group in menu.groups: + items = [] + for item in group.items: + if user.has_perms(item.permissions): + buttons = [ + button for button in item.buttons if user.has_perms(button.permissions) + ] + items.append((item, buttons)) + if items: + groups.append((group, items)) + if groups: + nav_items.append((menu, groups)) + return { - "nav_items": MENUS, + "nav_items": nav_items, "request": context["request"] } diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 9c90f5530..00f3d9745 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -25,11 +25,11 @@ class ChangeLoggedFilterSetTests(BaseFilterSetTests): def test_created(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'created': '2021-01-01T00:00:00'} + params = {'created': ['2021-01-01T00:00:00']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_last_updated(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc)) - params = {'last_updated': '2021-01-02T00:00:00'} + params = {'last_updated': ['2021-01-02T00:00:00']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 466b5e22b..52ccd002d 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -34,15 +34,16 @@ def post_data(data): return ret -def create_test_device(name): +def create_test_device(name, site=None, **attrs): """ Convenience method for creating a Device (e.g. for component testing). """ - site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') + if site is None: + site, _ = Site.objects.get_or_create(name='Site 1', slug='site-1') manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1') devicetype, _ = DeviceType.objects.get_or_create(model='Device Type 1', manufacturer=manufacturer) devicerole, _ = DeviceRole.objects.get_or_create(name='Device Role 1', slug='device-role-1') - device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole) + device = Device.objects.create(name=name, site=site, device_type=devicetype, device_role=devicerole, **attrs) return device diff --git a/netbox/utilities/tests/test_api.py b/netbox/utilities/tests/test_api.py index 1171bd496..e341442be 100644 --- a/netbox/utilities/tests/test_api.py +++ b/netbox/utilities/tests/test_api.py @@ -176,6 +176,64 @@ class APIPaginationTestCase(APITestCase): self.assertEqual(len(response.data['results']), 100) +class APIOrderingTestCase(APITestCase): + user_permissions = ('dcim.view_site',) + + @classmethod + def setUpTestData(cls): + cls.url = reverse('dcim-api:site-list') + + sites = ( + Site(name='Site 1', slug='site-1', facility='C', description='Z'), + Site(name='Site 2', slug='site-2', facility='C', description='Y'), + Site(name='Site 3', slug='site-3', facility='B', description='X'), + Site(name='Site 4', slug='site-4', facility='B', description='W'), + Site(name='Site 5', slug='site-5', facility='A', description='V'), + Site(name='Site 6', slug='site-6', facility='A', description='U'), + ) + Site.objects.bulk_create(sites) + + def test_default_order(self): + response = self.client.get(self.url, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 1', 'Site 2', 'Site 3', 'Site 4', 'Site 5', 'Site 6'] + ) + + def test_order_single_field(self): + response = self.client.get(f'{self.url}?ordering=description', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_reversed(self): + response = self.client.get(f'{self.url}?ordering=-name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 6', 'Site 5', 'Site 4', 'Site 3', 'Site 2', 'Site 1'] + ) + + def test_order_multiple_fields(self): + response = self.client.get(f'{self.url}?ordering=facility,name', format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], 6) + self.assertListEqual( + [s['name'] for s in response.data['results']], + ['Site 5', 'Site 6', 'Site 3', 'Site 4', 'Site 1', 'Site 2'] + ) + + class APIDocsTestCase(TestCase): def setUp(self): diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index da7cdde94..1dece76c8 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -1,6 +1,6 @@ import datetime +import decimal import json -from collections import OrderedDict from decimal import Decimal from itertools import count, groupby @@ -217,7 +217,7 @@ def deepmerge(original, new): """ Deep merge two dictionaries (new into original) and return a new dict """ - merged = OrderedDict(original) + merged = dict(original) for key, val in new.items(): if key in original and isinstance(original[key], dict) and val and isinstance(val, dict): merged[key] = deepmerge(original[key], val) @@ -226,6 +226,21 @@ def deepmerge(original, new): return merged +def drange(start, end, step=decimal.Decimal(1)): + """ + Decimal-compatible implementation of Python's range() + """ + start, end, step = decimal.Decimal(start), decimal.Decimal(end), decimal.Decimal(step) + if start < end: + while start < end: + yield start + start += step + else: + while start > end: + yield start + start += step + + def to_meters(length, unit): """ Convert the given length to meters. @@ -266,26 +281,22 @@ def render_jinja2(template_code, context): def prepare_cloned_fields(instance): """ - Compile an object's `clone_fields` list into a string of URL query parameters. Tags are automatically cloned where - applicable. + Generate a QueryDict comprising attributes from an object's clone() method. """ + # Generate the clone attributes from the instance + if not hasattr(instance, 'clone'): + return QueryDict() + attrs = instance.clone() + + # Prepare querydict parameters params = [] - for field_name in getattr(instance, 'clone_fields', []): - field = instance._meta.get_field(field_name) - field_value = field.value_from_object(instance) - - # Pass False as null for boolean fields - if field_value is False: - params.append((field_name, '')) - - # Omit empty values - elif field_value not in (None, ''): - params.append((field_name, field_value)) - - # Copy tags - if is_taggable(instance): - for tag in instance.tags.all(): - params.append(('tags', tag.pk)) + for key, value in attrs.items(): + if type(value) in (list, tuple): + params.extend([(key, v) for v in value]) + elif value not in (False, None): + params.append((key, value)) + else: + params.append((key, '')) # Return a QueryDict with the parameters return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True) @@ -325,14 +336,34 @@ def flatten_dict(d, prefix='', separator='.'): return ret +def array_to_ranges(array): + """ + Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as + single-item tuples. For example: + [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]" + """ + group = ( + list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) + ) + return [ + (g[0], g[-1])[:len(g)] for g in group + ] + + def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. For example: [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" """ - group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + ret = [] + ranges = array_to_ranges(array) + for value in ranges: + if len(value) == 1: + ret.append(str(value[0])) + else: + ret.append(f'{value[0]}-{value[1]}') + return ', '.join(ret) def content_type_name(ct): diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 16e10b5fd..07a9f5d13 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface __all__ = [ diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index afdf50b96..903d89a07 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -1,11 +1,15 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer +from dcim.api.nested_serializers import ( + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer, +) from dcim.choices import InterfaceModeChoices -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer +from ipam.api.nested_serializers import ( + NestedIPAddressSerializer, NestedL2VPNTerminationSerializer, NestedVLANSerializer, NestedVRFSerializer, +) from ipam.models import VLAN -from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.fields import ChoiceField, SerializedPKRelatedField from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * @@ -45,6 +49,7 @@ class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) + status = ChoiceField(choices=ClusterStatusChoices, required=False) tenant = NestedTenantSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True, default=None) device_count = serializers.IntegerField(read_only=True) @@ -53,8 +58,8 @@ class ClusterSerializer(NetBoxModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'status', 'tenant', 'site', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -65,8 +70,9 @@ class ClusterSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) - site = NestedSiteSerializer(read_only=True) - cluster = NestedClusterSerializer() + site = NestedSiteSerializer(required=False, allow_null=True) + cluster = NestedClusterSerializer(required=False, allow_null=True) + device = NestedDeviceSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) @@ -77,9 +83,9 @@ class VirtualMachineSerializer(NetBoxModelSerializer): class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -89,9 +95,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', - 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'platform', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', + 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -117,6 +123,7 @@ class VMInterfaceSerializer(NetBoxModelSerializer): many=True ) vrf = NestedVRFSerializer(required=False, allow_null=True) + l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) @@ -124,8 +131,8 @@ class VMInterfaceSerializer(NetBoxModelSerializer): model = VMInterface fields = [ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'custom_fields', 'created', - 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'l2vpn_termination', 'tags', 'custom_fields', + 'created', 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index 07b20bfd7..2ceeb8ce6 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 665114881..d2a90ae34 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -54,7 +54,7 @@ class ClusterViewSet(NetBoxModelViewSet): class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( - 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) filterset_class = filtersets.VirtualMachineFilterSet diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 693e53df6..2cf6357e1 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -1,6 +1,28 @@ from utilities.choices import ChoiceSet +# +# Clusters +# + +class ClusterStatusChoices(ChoiceSet): + key = 'Cluster.status' + + STATUS_PLANNED = 'planned' + STATUS_STAGING = 'staging' + STATUS_ACTIVE = 'active' + STATUS_DECOMMISSIONING = 'decommissioning' + STATUS_OFFLINE = 'offline' + + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_OFFLINE, 'Offline', 'red'), + ] + + # # VirtualMachines # diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index 5a2aa8b42..00d3e2313 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.filtersets import LocalConfigContextFilterSet from ipam.models import VRF from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet @@ -90,6 +90,10 @@ class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte to_field_name='slug', label='Cluster type (slug)', ) + status = django_filters.MultipleChoiceFilter( + choices=ClusterStatusChoices, + null_value=None + ) class Meta: model = Cluster @@ -146,39 +150,48 @@ class VirtualMachineFilterSet( to_field_name='name', label='Cluster', ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + field_name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', label='Region (ID)', ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='cluster__site__region', + field_name='site__region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', label='Site group (ID)', ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='cluster__site__group', + field_name='site__group', lookup_expr='in', to_field_name='slug', label='Site group (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - field_name='cluster__site__slug', + field_name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 4894d78cf..b2429744b 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -2,7 +2,7 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant @@ -58,6 +58,12 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): queryset=ClusterGroup.objects.all(), required=False ) + status = forms.ChoiceField( + choices=add_blank_choice(ClusterStatusChoices), + required=False, + initial='', + widget=StaticSelect() + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -85,7 +91,7 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm): model = Cluster fieldsets = ( - (None, ('type', 'group', 'tenant',)), + (None, ('type', 'group', 'status', 'tenant',)), ('Site', ('region', 'site_group', 'site',)), ) nullable_fields = ( @@ -100,9 +106,23 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): initial='', widget=StaticSelect(), ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), - required=False + required=False, + query_params={ + 'site_id': '$site' + } + ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster' + } ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.filter( @@ -140,11 +160,11 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): model = VirtualMachine fieldsets = ( - (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + (None, ('site', 'cluster', 'device', 'status', 'role', 'tenant', 'platform')), ('Resources', ('vcpus', 'memory', 'disk')) ) nullable_fields = ( - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'site', 'cluster', 'device', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', ) @@ -236,8 +256,10 @@ class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): # See 5643 if 'pk' in self.initial: site = None - interfaces = VMInterface.objects.filter(pk__in=self.initial['pk']).prefetch_related( - 'virtual_machine__cluster__site' + interfaces = VMInterface.objects.filter( + pk__in=self.initial['pk'] + ).prefetch_related( + 'virtual_machine__site' ) # Check interface sites. First interface should set site, further interfaces will either continue the diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index eab6fc9e7..2d7ee52e2 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,5 +1,5 @@ from dcim.choices import InterfaceModeChoices -from dcim.models import DeviceRole, Platform, Site +from dcim.models import Device, DeviceRole, Platform, Site from ipam.models import VRF from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant @@ -44,6 +44,10 @@ class ClusterCSVForm(NetBoxModelCSVForm): required=False, help_text='Assigned cluster group' ) + status = CSVChoiceField( + choices=ClusterStatusChoices, + help_text='Operational status' + ) site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -59,7 +63,7 @@ class ClusterCSVForm(NetBoxModelCSVForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'site', 'comments') + fields = ('name', 'type', 'group', 'status', 'site', 'comments') class VirtualMachineCSVForm(NetBoxModelCSVForm): @@ -67,11 +71,24 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): choices=VirtualMachineStatusChoices, help_text='Operational status' ) + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned site' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', + required=False, help_text='Assigned cluster' ) + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + required=False, + help_text='Assigned device within cluster' + ) role = CSVModelChoiceField( queryset=DeviceRole.objects.filter( vm_role=True @@ -96,7 +113,8 @@ class VirtualMachineCSVForm(NetBoxModelCSVForm): class Meta: model = VirtualMachine fields = ( - 'name', 'status', 'role', 'cluster', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + 'name', 'status', 'role', 'site', 'cluster', 'device', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'comments', ) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 2f52850bd..4b8ff6d21 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -1,7 +1,7 @@ from django import forms from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from extras.forms import LocalConfigContextFilterForm from ipam.models import VRF from netbox.forms import NetBoxModelFilterSetForm @@ -39,7 +39,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi model = Cluster fieldsets = ( (None, ('q', 'tag')), - ('Attributes', ('group_id', 'type_id')), + ('Attributes', ('group_id', 'type_id', 'status')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Tenant', ('tenant_group_id', 'tenant_id')), ('Contacts', ('contact', 'contact_role', 'contact_group')), @@ -54,6 +54,10 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi required=False, label=_('Region') ) + status = MultipleChoiceField( + choices=ClusterStatusChoices, + required=False + ) site_group_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -87,7 +91,7 @@ class VirtualMachineFilterForm( model = VirtualMachine fieldsets = ( (None, ('q', 'tag')), - ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id', 'device_id')), ('Location', ('region_id', 'site_group_id', 'site_id')), ('Attributes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), ('Tenant', ('tenant_group_id', 'tenant_id')), @@ -110,6 +114,11 @@ class VirtualMachineFilterForm( required=False, label=_('Cluster') ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + label=_('Device') + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index d2ebe5345..723c19332 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -79,15 +79,19 @@ class ClusterForm(TenancyForm, NetBoxModelForm): comments = CommentField() fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'status', 'tags')), + ('Site', ('region', 'site_group', 'site')), ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'status', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) + widgets = { + 'status': StaticSelect(), + } class ClusterAddDevicesForm(BootstrapMixin, forms.Form): @@ -161,6 +165,10 @@ class ClusterRemoveDevicesForm(ConfirmationForm): class VirtualMachineForm(TenancyForm, NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -171,10 +179,21 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): ) cluster = DynamicModelChoiceField( queryset=Cluster.objects.all(), + required=False, query_params={ - 'group_id': '$cluster_group' + 'site_id': '$site', + 'group_id': '$cluster_group', } ) + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'cluster_id': '$cluster', + 'site_id': '$site', + }, + help_text="Optionally pin this VM to a specific host device within the cluster" + ) role = DynamicModelChoiceField( queryset=DeviceRole.objects.all(), required=False, @@ -193,7 +212,7 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): fieldsets = ( ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), + ('Site/Cluster', ('site', 'cluster_group', 'cluster', 'device')), ('Tenancy', ('tenant_group', 'tenant')), ('Management', ('platform', 'primary_ip4', 'primary_ip6')), ('Resources', ('vcpus', 'memory', 'disk')), @@ -203,8 +222,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class Meta: model = VirtualMachine fields = [ - 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', + 'name', 'status', 'site', 'cluster_group', 'cluster', 'device', '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 " diff --git a/netbox/virtualization/migrations/0030_cluster_status.py b/netbox/virtualization/migrations/0030_cluster_status.py new file mode 100644 index 000000000..e836bb914 --- /dev/null +++ b/netbox/virtualization/migrations/0030_cluster_status.py @@ -0,0 +1,18 @@ +# Generated by Django 4.0.4 on 2022-05-19 19:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0029_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='cluster', + name='status', + field=models.CharField(default='active', max_length=50), + ), + ] diff --git a/netbox/virtualization/migrations/0031_virtualmachine_site_device.py b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py new file mode 100644 index 000000000..85ea24455 --- /dev/null +++ b/netbox/virtualization/migrations/0031_virtualmachine_site_device.py @@ -0,0 +1,28 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0153_created_datetimefield'), + ('virtualization', '0030_cluster_status'), + ] + + operations = [ + migrations.AddField( + model_name='virtualmachine', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.site'), + ), + migrations.AddField( + model_name='virtualmachine', + name='device', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='dcim.device'), + ), + migrations.AlterField( + model_name='virtualmachine', + name='cluster', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_machines', to='virtualization.cluster'), + ), + ] diff --git a/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py new file mode 100644 index 000000000..e9c52bfde --- /dev/null +++ b/netbox/virtualization/migrations/0032_virtualmachine_update_sites.py @@ -0,0 +1,27 @@ +from django.db import migrations + + +def update_virtualmachines_site(apps, schema_editor): + """ + Automatically set the site for all virtual machines. + """ + VirtualMachine = apps.get_model('virtualization', 'VirtualMachine') + + virtual_machines = VirtualMachine.objects.filter(cluster__site__isnull=False) + for vm in virtual_machines: + vm.site = vm.cluster.site + VirtualMachine.objects.bulk_update(virtual_machines, ['site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0031_virtualmachine_site_device'), + ] + + operations = [ + migrations.RunPython( + code=update_virtualmachines_site, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b7151a1f0..b8131c1ce 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -119,6 +119,11 @@ class Cluster(NetBoxModel): blank=True, null=True ) + status = models.CharField( + max_length=50, + choices=ClusterStatusChoices, + default=ClusterStatusChoices.STATUS_ACTIVE + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -148,9 +153,9 @@ class Cluster(NetBoxModel): to='tenancy.ContactAssignment' ) - clone_fields = [ - 'type', 'group', 'tenant', 'site', - ] + clone_fields = ( + 'type', 'group', 'status', 'tenant', 'site', + ) class Meta: ordering = ['name'] @@ -169,6 +174,9 @@ class Cluster(NetBoxModel): def get_absolute_url(self): return reverse('virtualization:cluster', args=[self.pk]) + def get_status_color(self): + return ClusterStatusChoices.colors.get(self.status) + def clean(self): super().clean() @@ -191,10 +199,26 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True + ) cluster = models.ForeignKey( to='virtualization.Cluster', on_delete=models.PROTECT, - related_name='virtual_machines' + related_name='virtual_machines', + blank=True, + null=True + ) + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.PROTECT, + related_name='virtual_machines', + blank=True, + null=True ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -279,9 +303,9 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): objects = ConfigContextModelQuerySet.as_manager() - clone_fields = [ - 'cluster', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', - ] + clone_fields = ( + 'site', 'cluster', 'device', 'tenant', 'platform', 'status', 'role', 'vcpus', 'memory', 'disk', + ) class Meta: ordering = ('_name', 'pk') # Name may be non-unique @@ -316,6 +340,32 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): def clean(self): super().clean() + # Must be assigned to a site and/or cluster + if not self.site and not self.cluster: + raise ValidationError({ + 'cluster': f'A virtual machine must be assigned to a site and/or cluster.' + }) + + # Validate site for cluster & device + if self.cluster and self.cluster.site != self.site: + raise ValidationError({ + 'cluster': f'The selected cluster ({self.cluster} is not assigned to this site ({self.site}).' + }) + if self.device and self.device.site != self.site: + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this site ({self.site}).' + }) + + # Validate assigned cluster device + if self.device and not self.cluster: + raise ValidationError({ + 'device': f'Must specify a cluster when assigning a host device.' + }) + if self.device and self.device not in self.cluster.devices.all(): + raise ValidationError({ + 'device': f'The selected device ({self.device} is not assigned to this cluster ({self.cluster}).' + }) + # Validate primary IP addresses interfaces = self.interfaces.all() for field in ['primary_ip4', 'primary_ip6']: @@ -344,10 +394,6 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): else: return None - @property - def site(self): - return self.cluster.site - # # Interfaces @@ -406,6 +452,12 @@ class VMInterface(NetBoxModel, BaseInterface): object_id_field='interface_id', related_query_name='+' ) + l2vpn_terminations = GenericRelation( + to='ipam.L2VPNTermination', + content_type_field='assigned_object_type', + object_id_field='assigned_object_id', + related_query_name='vminterface', + ) class Meta: verbose_name = 'interface' @@ -464,3 +516,7 @@ class VMInterface(NetBoxModel, BaseInterface): @property def parent_object(self): return self.virtual_machine + + @property + def l2vpn_termination(self): + return self.l2vpn_terminations.first() diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py index ccffe23fd..ba0f2d301 100644 --- a/netbox/virtualization/tables/clusters.py +++ b/netbox/virtualization/tables/clusters.py @@ -91,7 +91,7 @@ class ClusterTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Cluster fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', 'vm_count', 'contacts', - 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'type', 'group', 'status', 'tenant', 'tenant_group', 'site', 'comments', 'device_count', + 'vm_count', 'contacts', 'tags', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') + default_columns = ('pk', 'name', 'type', 'group', 'status', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index cace51ccc..410c0f541 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -30,9 +30,15 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): linkify=True ) status = columns.ChoiceFieldColumn() + site = tables.Column( + linkify=True + ) cluster = tables.Column( linkify=True ) + device = tables.Column( + linkify=True + ) role = columns.ColoredLabelColumn() comments = columns.MarkdownColumn() primary_ip4 = tables.Column( @@ -58,12 +64,12 @@ class VirtualMachineTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = VirtualMachine fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'tenant_group', 'platform', 'vcpus', 'memory', - 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', - 'last_updated', + 'pk', 'id', 'name', 'status', 'site', 'cluster', 'device', 'role', 'tenant', 'tenant_group', 'platform', + 'vcpus', 'memory', 'disk', 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', + 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + 'pk', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', ) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index f6c07fa54..b2ae68860 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,8 +2,10 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices +from dcim.models import Site from ipam.models import VLAN, VRF -from utilities.testing import APITestCase, APIViewTestCases +from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -85,6 +87,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { + 'status': 'offline', 'comments': 'New comment', } @@ -104,9 +107,9 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): ClusterGroup.objects.bulk_create(cluster_groups) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0]), - Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 2', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), + Cluster(name='Cluster 3', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED), ) Cluster.objects.bulk_create(clusters) @@ -115,16 +118,19 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): 'name': 'Cluster 4', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 5', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, { 'name': 'Cluster 6', 'type': cluster_types[1].pk, 'group': cluster_groups[1].pk, + 'status': ClusterStatusChoices.STATUS_STAGING, }, ] @@ -141,31 +147,49 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clustergroup = ClusterGroup.objects.create(name='Cluster Group 1', slug='cluster-group-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + clusters = ( - Cluster(name='Cluster 1', type=clustertype, group=clustergroup), - Cluster(name='Cluster 2', type=clustertype, group=clustergroup), + Cluster(name='Cluster 1', type=clustertype, site=sites[0], group=clustergroup), + Cluster(name='Cluster 2', type=clustertype, site=sites[1], group=clustergroup), + Cluster(name='Cluster 3', type=clustertype), ) Cluster.objects.bulk_create(clusters) + device1 = create_test_device('device1', site=sites[0], cluster=clusters[0]) + device2 = create_test_device('device2', site=sites[1], cluster=clusters[1]) + virtual_machines = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], local_context_data={'A': 1}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], local_context_data={'B': 2}), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], local_context_data={'C': 3}), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=device1, local_context_data={'A': 1}), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], local_context_data={'B': 2}), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], local_context_data={'C': 3}), ) VirtualMachine.objects.bulk_create(virtual_machines) cls.create_data = [ { 'name': 'Virtual Machine 4', + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': device2.pk, }, { 'name': 'Virtual Machine 5', + 'site': sites[1].pk, 'cluster': clusters[1].pk, }, { 'name': 'Virtual Machine 6', - 'cluster': clusters[1].pk, + 'site': sites[1].pk, + }, + { + 'name': 'Virtual Machine 7', + 'cluster': clusters[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 9e264ac5c..d3ff12887 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,9 +1,9 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup +from dcim.models import Device, DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup -from utilities.testing import ChangeLoggedFilterSetTests +from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.choices import * from virtualization.filtersets import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -123,9 +123,9 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) clusters = ( - Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], site=sites[0], tenant=tenants[0]), - Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], site=sites[1], tenant=tenants[1]), - Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], site=sites[2], tenant=tenants[2]), + Cluster(name='Cluster 1', type=cluster_types[0], group=cluster_groups[0], status=ClusterStatusChoices.STATUS_PLANNED, site=sites[0], tenant=tenants[0]), + Cluster(name='Cluster 2', type=cluster_types[1], group=cluster_groups[1], status=ClusterStatusChoices.STATUS_STAGING, site=sites[1], tenant=tenants[1]), + Cluster(name='Cluster 3', type=cluster_types[2], group=cluster_groups[2], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[2], tenant=tenants[2]), ) Cluster.objects.bulk_create(clusters) @@ -161,6 +161,10 @@ class ClusterTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'group': [groups[0].slug, groups[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_status(self): + params = {'status': [ClusterStatusChoices.STATUS_PLANNED, ClusterStatusChoices.STATUS_STAGING]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): types = ClusterType.objects.all()[:2] params = {'type_id': [types[0].pk, types[1].pk]} @@ -221,9 +225,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): site_group.save() sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0], group=site_groups[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1], group=site_groups[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2], group=site_groups[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=site_groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=site_groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=site_groups[2]), ) Site.objects.bulk_create(sites) @@ -248,6 +252,12 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): ) DeviceRole.objects.bulk_create(roles) + devices = ( + create_test_device('device1', cluster=clusters[0]), + create_test_device('device2', cluster=clusters[1]), + create_test_device('device3', cluster=clusters[2]), + ) + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -264,9 +274,9 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) vms = ( - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], platform=platforms[0], role=roles[0], tenant=tenants[0], status=VirtualMachineStatusChoices.STATUS_ACTIVE, vcpus=1, memory=1, disk=1, local_context_data={"foo": 123}), + VirtualMachine(name='Virtual Machine 2', site=sites[1], cluster=clusters[1], device=devices[1], platform=platforms[1], role=roles[1], tenant=tenants[1], status=VirtualMachineStatusChoices.STATUS_STAGED, vcpus=2, memory=2, disk=2), + VirtualMachine(name='Virtual Machine 3', site=sites[2], cluster=clusters[2], device=devices[2], platform=platforms[2], role=roles[2], tenant=tenants[2], status=VirtualMachineStatusChoices.STATUS_OFFLINE, vcpus=3, memory=3, disk=3), ) VirtualMachine.objects.bulk_create(vms) @@ -327,6 +337,13 @@ class VirtualMachineTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cluster': [clusters[0].name, clusters[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_device(self): + devices = Device.objects.all()[:2] + params = {'device_id': [devices[0].pk, devices[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device': [devices[0].name, devices[1].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} diff --git a/netbox/virtualization/tests/test_models.py b/netbox/virtualization/tests/test_models.py index 3b4d73a30..df5816efa 100644 --- a/netbox/virtualization/tests/test_models.py +++ b/netbox/virtualization/tests/test_models.py @@ -1,21 +1,19 @@ from django.core.exceptions import ValidationError from django.test import TestCase +from dcim.models import Site from virtualization.models import * from tenancy.models import Tenant class VirtualMachineTestCase(TestCase): - def setUp(self): - - cluster_type = ClusterType.objects.create(name='Test Cluster Type 1', slug='Test Cluster Type 1') - self.cluster = Cluster.objects.create(name='Test Cluster 1', type=cluster_type) - def test_vm_duplicate_name_per_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + cluster = Cluster.objects.create(name='Cluster 1', type=cluster_type) vm1 = VirtualMachine( - cluster=self.cluster, + cluster=cluster, name='Test VM 1' ) vm1.save() @@ -43,3 +41,33 @@ class VirtualMachineTestCase(TestCase): # Two VMs assigned to the same Cluster and different Tenants should pass validation vm2.full_clean() vm2.save() + + def test_vm_mismatched_site_cluster(self): + cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') + + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + clusters = ( + Cluster(name='Cluster 1', type=cluster_type, site=sites[0]), + Cluster(name='Cluster 2', type=cluster_type, site=sites[1]), + Cluster(name='Cluster 3', type=cluster_type, site=None), + ) + Cluster.objects.bulk_create(clusters) + + # VM with site only should pass + VirtualMachine(name='vm1', site=sites[0]).full_clean() + + # VM with non-site cluster only should pass + VirtualMachine(name='vm1', cluster=clusters[2]).full_clean() + + # VM with mismatched site & cluster should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=sites[0], cluster=clusters[1]).full_clean() + + # VM with cluster site but no direct site should fail + with self.assertRaises(ValidationError): + VirtualMachine(name='vm1', site=None, cluster=clusters[0]).full_clean() diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 8edc14f00..01d4394f3 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -5,7 +5,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site from ipam.models import VLAN, VRF -from utilities.testing import ViewTestCases, create_tags +from utilities.testing import ViewTestCases, create_tags, create_test_device from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -101,9 +101,9 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): ClusterType.objects.bulk_create(clustertypes) Cluster.objects.bulk_create([ - Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], site=sites[0]), - Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], site=sites[0]), + Cluster(name='Cluster 1', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 2', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), + Cluster(name='Cluster 3', group=clustergroups[0], type=clustertypes[0], status=ClusterStatusChoices.STATUS_ACTIVE, site=sites[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -112,6 +112,7 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Cluster X', 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'Some comments', @@ -119,15 +120,16 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,type", - "Cluster 4,Cluster Type 1", - "Cluster 5,Cluster Type 1", - "Cluster 6,Cluster Type 1", + "name,type,status", + "Cluster 4,Cluster Type 1,active", + "Cluster 5,Cluster Type 1,active", + "Cluster 6,Cluster Type 1,active", ) cls.bulk_edit_data = { 'group': clustergroups[1].pk, 'type': clustertypes[1].pk, + 'status': ClusterStatusChoices.STATUS_OFFLINE, 'tenant': None, 'site': sites[1].pk, 'comments': 'New comments', @@ -166,24 +168,37 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Platform.objects.bulk_create(platforms) + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') clusters = ( - Cluster(name='Cluster 1', type=clustertype), - Cluster(name='Cluster 2', type=clustertype), + Cluster(name='Cluster 1', type=clustertype, site=sites[0]), + Cluster(name='Cluster 2', type=clustertype, site=sites[1]), ) Cluster.objects.bulk_create(clusters) + devices = ( + create_test_device('device1', site=sites[0], cluster=clusters[0]), + create_test_device('device2', site=sites[1], cluster=clusters[1]), + ) + VirtualMachine.objects.bulk_create([ - VirtualMachine(name='Virtual Machine 1', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 2', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), - VirtualMachine(name='Virtual Machine 3', cluster=clusters[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 1', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 2', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), + VirtualMachine(name='Virtual Machine 3', site=sites[0], cluster=clusters[0], device=devices[0], role=deviceroles[0], platform=platforms[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'cluster': clusters[1].pk, + 'device': devices[1].pk, + 'site': sites[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'name': 'Virtual Machine X', @@ -200,14 +215,16 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,status,cluster", - "Virtual Machine 4,active,Cluster 1", - "Virtual Machine 5,active,Cluster 1", - "Virtual Machine 6,active,Cluster 1", + "name,status,site,cluster,device", + "Virtual Machine 4,active,Site 1,Cluster 1,device1", + "Virtual Machine 5,active,Site 1,Cluster 1,device1", + "Virtual Machine 6,active,Site 1,Cluster 1,", ) cls.bulk_edit_data = { + 'site': sites[1].pk, 'cluster': clusters[1].pk, + 'device': devices[1].pk, 'tenant': None, 'platform': platforms[1].pk, 'status': VirtualMachineStatusChoices.STATUS_STAGED, @@ -243,8 +260,8 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): clustertype = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1') cluster = Cluster.objects.create(name='Cluster 1', type=clustertype, site=site) virtualmachines = ( - VirtualMachine(name='Virtual Machine 1', cluster=cluster, role=devicerole), - VirtualMachine(name='Virtual Machine 2', cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 1', site=site, cluster=cluster, role=devicerole), + VirtualMachine(name='Virtual Machine 2', site=site, cluster=cluster, role=devicerole), ) VirtualMachine.objects.bulk_create(virtualmachines) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4cd7da30d..5b26f8503 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -209,14 +209,14 @@ class ClusterBulkImportView(generic.BulkImportView): class ClusterBulkEditView(generic.BulkEditView): - queryset = Cluster.objects.prefetch_related('type', 'group', 'site') + queryset = Cluster.objects.all() filterset = filtersets.ClusterFilterSet table = tables.ClusterTable form = forms.ClusterBulkEditForm class ClusterBulkDeleteView(generic.BulkDeleteView): - queryset = Cluster.objects.prefetch_related('type', 'group', 'site') + queryset = Cluster.objects.all() filterset = filtersets.ClusterFilterSet table = tables.ClusterTable @@ -308,7 +308,7 @@ class ClusterRemoveDevicesView(generic.ObjectEditView): # class VirtualMachineListView(generic.ObjectListView): - queryset = VirtualMachine.objects.all() + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet filterset_form = forms.VirtualMachineFilterForm table = tables.VirtualMachineTable @@ -334,7 +334,8 @@ class VirtualMachineView(generic.ObjectView): services = Service.objects.restrict(request.user, 'view').filter( virtual_machine=instance ).prefetch_related( - Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)) + Prefetch('ipaddresses', queryset=IPAddress.objects.restrict(request.user)), + 'virtual_machine' ) return { @@ -383,14 +384,14 @@ class VirtualMachineBulkImportView(generic.BulkImportView): class VirtualMachineBulkEditView(generic.BulkEditView): - queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable form = forms.VirtualMachineBulkEditForm class VirtualMachineBulkDeleteView(generic.BulkDeleteView): - queryset = VirtualMachine.objects.prefetch_related('cluster', 'tenant', 'role') + queryset = VirtualMachine.objects.prefetch_related('primary_ip4', 'primary_ip6') filterset = filtersets.VirtualMachineFilterSet table = tables.VirtualMachineTable @@ -413,7 +414,7 @@ class VMInterfaceView(generic.ObjectView): def get_extra_context(self, request, instance): # Get assigned IP addresses ipaddress_table = AssignedIPAddressesTable( - data=instance.ip_addresses.restrict(request.user, 'view').prefetch_related('vrf', 'tenant'), + data=instance.ip_addresses.restrict(request.user, 'view'), orderable=False ) diff --git a/netbox/wireless/api/nested_serializers.py b/netbox/wireless/api/nested_serializers.py index e9a840bfc..0e8404266 100644 --- a/netbox/wireless/api/nested_serializers.py +++ b/netbox/wireless/api/nested_serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import WritableNestedSerializer from wireless.models import * __all__ = ( diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index 4a6abe94d..d65511765 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -3,8 +3,9 @@ from rest_framework import serializers from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer -from netbox.api import ChoiceField +from netbox.api.fields import ChoiceField from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer +from tenancy.api.nested_serializers import NestedTenantSerializer from wireless.choices import * from wireless.models import * from .nested_serializers import * @@ -33,14 +34,15 @@ class WirelessLANSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) + tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLAN fields = [ - 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'ssid', 'description', 'group', 'vlan', 'tenant', 'auth_type', 'auth_cipher', + 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -49,12 +51,13 @@ class WirelessLinkSerializer(NetBoxModelSerializer): status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() interface_b = NestedInterfaceSerializer() + tenant = NestedTenantSerializer(required=False, allow_null=True) auth_type = ChoiceField(choices=WirelessAuthTypeChoices, required=False, allow_blank=True) auth_cipher = ChoiceField(choices=WirelessAuthCipherChoices, required=False, allow_blank=True) class Meta: model = WirelessLink fields = [ - 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'description', 'auth_type', + 'id', 'url', 'display', 'interface_a', 'interface_b', 'ssid', 'status', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index 47799bd3a..5375172eb 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -1,4 +1,4 @@ -from netbox.api import NetBoxRouter +from netbox.api.routers import NetBoxRouter from . import views diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 77a766c50..1103cec37 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -27,12 +27,12 @@ class WirelessLANGroupViewSet(NetBoxModelViewSet): class WirelessLANViewSet(NetBoxModelViewSet): - queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') + queryset = WirelessLAN.objects.prefetch_related('vlan', 'tenant', 'tags') serializer_class = serializers.WirelessLANSerializer filterset_class = filtersets.WirelessLANFilterSet class WirelessLinkViewSet(NetBoxModelViewSet): - queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') + queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tenant', 'tags') serializer_class = serializers.WirelessLinkSerializer filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index 7b0be857b..60c4f935b 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -4,6 +4,7 @@ from django.db.models import Q from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet +from tenancy.filtersets import TenancyFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -30,7 +31,7 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): fields = ['id', 'name', 'slug', 'description'] -class WirelessLANFilterSet(NetBoxModelFilterSet): +class WirelessLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -66,7 +67,7 @@ class WirelessLANFilterSet(NetBoxModelFilterSet): return queryset.filter(qs_filter) -class WirelessLinkFilterSet(NetBoxModelFilterSet): +class WirelessLinkFilterSet(NetBoxModelFilterSet, TenancyFilterSet): interface_a_id = MultiValueNumberFilter() interface_b_id = MultiValueNumberFilter() status = django_filters.MultipleChoiceFilter( diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 8a472e164..639a1ed1b 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -3,6 +3,7 @@ from django import forms from dcim.choices import LinkStatusChoices from ipam.models import VLAN from netbox.forms import NetBoxModelBulkEditForm +from tenancy.models import Tenant from utilities.forms import add_blank_choice, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH @@ -47,6 +48,10 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): required=False, label='SSID' ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( required=False ) @@ -65,11 +70,11 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLAN fieldsets = ( - (None, ('group', 'vlan', 'ssid', 'description')), + (None, ('group', 'ssid', 'vlan', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) nullable_fields = ( - 'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) @@ -83,6 +88,10 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): choices=add_blank_choice(LinkStatusChoices), required=False ) + tenant = DynamicModelChoiceField( + queryset=Tenant.objects.all(), + required=False + ) description = forms.CharField( required=False ) @@ -101,9 +110,9 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): model = WirelessLink fieldsets = ( - (None, ('ssid', 'status', 'description')), + (None, ('ssid', 'status', 'tenant', 'description')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) ) nullable_fields = ( - 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index 4b8acb385..6a1ca4f36 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -2,6 +2,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN from netbox.forms import NetBoxModelCSVForm +from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -40,6 +41,12 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): to_field_name='name', help_text='Bridged VLAN' ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, @@ -53,7 +60,7 @@ class WirelessLANCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLAN - fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ('ssid', 'group', 'vlan', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk') class WirelessLinkCSVForm(NetBoxModelCSVForm): @@ -67,6 +74,12 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): interface_b = CSVModelChoiceField( queryset=Interface.objects.all() ) + tenant = CSVModelChoiceField( + queryset=Tenant.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned tenant' + ) auth_type = CSVChoiceField( choices=WirelessAuthTypeChoices, required=False, @@ -80,4 +93,6 @@ class WirelessLinkCSVForm(NetBoxModelCSVForm): class Meta: model = WirelessLink - fields = ('interface_a', 'interface_b', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk') + fields = ( + 'interface_a', 'interface_b', 'ssid', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 8dcb48673..9e8808e17 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -3,6 +3,7 @@ from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import TenancyFilterForm from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField from wireless.choices import * from wireless.models import * @@ -24,11 +25,12 @@ class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLANFilterForm(NetBoxModelFilterSetForm): +class WirelessLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLAN fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('ssid', 'group_id',)), + ('Tenant', ('tenant_group_id', 'tenant_id')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) ssid = forms.CharField( @@ -57,8 +59,14 @@ class WirelessLANFilterForm(NetBoxModelFilterSetForm): tag = TagFilterField(model) -class WirelessLinkFilterForm(NetBoxModelFilterSetForm): +class WirelessLinkFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = WirelessLink + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('ssid', 'status',)), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) ssid = forms.CharField( required=False, label='SSID' diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index d1012ba59..bcffcf896 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,6 +1,7 @@ from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from ipam.models import VLAN, VLANGroup from netbox.forms import NetBoxModelForm +from tenancy.forms import TenancyForm from utilities.forms import DynamicModelChoiceField, SlugField, StaticSelect from wireless.models import * @@ -25,7 +26,7 @@ class WirelessLANGroupForm(NetBoxModelForm): ] -class WirelessLANForm(NetBoxModelForm): +class WirelessLANForm(TenancyForm, NetBoxModelForm): group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -79,14 +80,15 @@ class WirelessLANForm(NetBoxModelForm): fieldsets = ( ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), ('VLAN', ('region', 'site_group', 'site', 'vlan_group', 'vlan',)), + ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: model = WirelessLAN fields = [ - 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'auth_type', - 'auth_cipher', 'auth_psk', 'tags', + 'ssid', 'group', 'description', 'region', 'site_group', 'site', 'vlan_group', 'vlan', 'tenant_group', + 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'auth_type': StaticSelect, @@ -94,7 +96,7 @@ class WirelessLANForm(NetBoxModelForm): } -class WirelessLinkForm(NetBoxModelForm): +class WirelessLinkForm(TenancyForm, NetBoxModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -180,6 +182,7 @@ class WirelessLinkForm(NetBoxModelForm): ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), ('Link', ('status', 'ssid', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) @@ -187,7 +190,7 @@ class WirelessLinkForm(NetBoxModelForm): model = WirelessLink fields = [ 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', - 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', + 'status', 'ssid', 'tenant_group', 'tenant', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] widgets = { 'status': StaticSelect, diff --git a/netbox/wireless/migrations/0004_wireless_tenancy.py b/netbox/wireless/migrations/0004_wireless_tenancy.py new file mode 100644 index 000000000..aa5837b0a --- /dev/null +++ b/netbox/wireless/migrations/0004_wireless_tenancy.py @@ -0,0 +1,25 @@ +# Generated by Django 4.0.5 on 2022-06-27 13:44 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0007_contact_link'), + ('wireless', '0003_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='wirelesslan', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_lans', to='tenancy.tenant'), + ), + migrations.AddField( + model_name='wirelesslink', + name='tenant', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='wireless_links', to='tenancy.tenant'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index f9838c0c7..36410b83b 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -102,11 +102,20 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): null=True, verbose_name='VLAN' ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='wireless_lans', + blank=True, + null=True + ) description = models.CharField( max_length=200, blank=True ) + clone_fields = ('ssid', 'group', 'tenant', 'description') + class Meta: ordering = ('ssid', 'pk') verbose_name = 'Wireless LAN' @@ -144,6 +153,13 @@ class WirelessLink(WirelessAuthenticationBase, NetBoxModel): choices=LinkStatusChoices, default=LinkStatusChoices.STATUS_CONNECTED ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='wireless_links', + blank=True, + null=True + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/wireless/signals.py b/netbox/wireless/signals.py index 3b4831a8d..ff7b1229c 100644 --- a/netbox/wireless/signals.py +++ b/netbox/wireless/signals.py @@ -25,18 +25,16 @@ def update_connected_interfaces(instance, created, raw=False, **kwargs): if instance.interface_a.wireless_link != instance: logger.debug(f"Updating interface A for wireless link {instance}") instance.interface_a.wireless_link = instance - instance.interface_a._link_peer = instance.interface_b instance.interface_a.save() if instance.interface_b.cable != instance: logger.debug(f"Updating interface B for wireless link {instance}") instance.interface_b.wireless_link = instance - instance.interface_b._link_peer = instance.interface_a instance.interface_b.save() # Create/update cable paths if created: for interface in (instance.interface_a, instance.interface_b): - create_cablepath(interface) + create_cablepath([interface]) @receiver(post_delete, sender=WirelessLink) @@ -48,19 +46,11 @@ def nullify_connected_interfaces(instance, **kwargs): if instance.interface_a is not None: logger.debug(f"Nullifying interface A for wireless link {instance}") - Interface.objects.filter(pk=instance.interface_a.pk).update( - wireless_link=None, - _link_peer_type=None, - _link_peer_id=None - ) + Interface.objects.filter(pk=instance.interface_a.pk).update(wireless_link=None) if instance.interface_b is not None: logger.debug(f"Nullifying interface B for wireless link {instance}") - Interface.objects.filter(pk=instance.interface_b.pk).update( - wireless_link=None, - _link_peer_type=None, - _link_peer_id=None - ) + Interface.objects.filter(pk=instance.interface_b.pk).update(wireless_link=None) # Delete and retrace any dependent cable paths - for cablepath in CablePath.objects.filter(path__contains=instance): + for cablepath in CablePath.objects.filter(_nodes__contains=instance): cablepath.delete() diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py index 9955d4ac4..af0cdae88 100644 --- a/netbox/wireless/tables/wirelesslan.py +++ b/netbox/wireless/tables/wirelesslan.py @@ -2,6 +2,7 @@ import django_tables2 as tables from dcim.models import Interface from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin from wireless.models import * __all__ = ( @@ -32,7 +33,7 @@ class WirelessLANGroupTable(NetBoxTable): default_columns = ('pk', 'name', 'wirelesslan_count', 'description') -class WirelessLANTable(NetBoxTable): +class WirelessLANTable(TenancyColumnsMixin, NetBoxTable): ssid = tables.Column( linkify=True ) @@ -49,8 +50,8 @@ class WirelessLANTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLAN fields = ( - 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', - 'tags', 'created', 'last_updated', + 'pk', 'ssid', 'group', 'tenant', 'tenant_group', 'description', 'vlan', 'interface_count', 'auth_type', + 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py index 72037c4d9..0135ce620 100644 --- a/netbox/wireless/tables/wirelesslink.py +++ b/netbox/wireless/tables/wirelesslink.py @@ -1,6 +1,7 @@ import django_tables2 as tables from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenancyColumnsMixin from wireless.models import * __all__ = ( @@ -8,7 +9,7 @@ __all__ = ( ) -class WirelessLinkTable(NetBoxTable): +class WirelessLinkTable(TenancyColumnsMixin, NetBoxTable): id = tables.Column( linkify=True, verbose_name='ID' @@ -35,8 +36,8 @@ class WirelessLinkTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = WirelessLink fields = ( - 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', - 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'tenant', + 'tenant_group', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', diff --git a/netbox/wireless/tests/test_api.py b/netbox/wireless/tests/test_api.py index 917b7b320..9ef552eb7 100644 --- a/netbox/wireless/tests/test_api.py +++ b/netbox/wireless/tests/test_api.py @@ -1,10 +1,11 @@ from django.urls import reverse -from wireless.choices import * -from wireless.models import * from dcim.choices import InterfaceTypeChoices from dcim.models import Interface +from tenancy.models import Tenant from utilities.testing import APITestCase, APIViewTestCases, create_test_device +from wireless.choices import * +from wireless.models import * class AppTest(APITestCase): @@ -52,6 +53,12 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + groups = ( WirelessLANGroup(name='Group 1', slug='group-1'), WirelessLANGroup(name='Group 2', slug='group-2'), @@ -71,21 +78,25 @@ class WirelessLANTest(APIViewTestCases.APIViewTestCase): { 'ssid': 'WLAN4', 'group': groups[0].pk, + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_OPEN, }, { 'ssid': 'WLAN5', 'group': groups[1].pk, + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, }, { 'ssid': 'WLAN6', + 'tenant': tenants[0].pk, 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_ENTERPRISE, }, ] cls.bulk_update_data = { 'group': groups[2].pk, + 'tenant': tenants[1].pk, 'description': 'New description', 'auth_type': WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, 'auth_cipher': WirelessAuthCipherChoices.CIPHER_AES, @@ -115,10 +126,16 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase): ] Interface.objects.bulk_create(interfaces) + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + ) + Tenant.objects.bulk_create(tenants) + wireless_links = ( - WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1]), - WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3]), - WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5]), + WirelessLink(ssid='LINK1', interface_a=interfaces[0], interface_b=interfaces[1], tenant=tenants[0]), + WirelessLink(ssid='LINK2', interface_a=interfaces[2], interface_b=interfaces[3], tenant=tenants[0]), + WirelessLink(ssid='LINK3', interface_a=interfaces[4], interface_b=interfaces[5], tenant=tenants[0]), ) WirelessLink.objects.bulk_create(wireless_links) @@ -127,15 +144,18 @@ class WirelessLinkTest(APIViewTestCases.APIViewTestCase): 'interface_a': interfaces[6].pk, 'interface_b': interfaces[7].pk, 'ssid': 'LINK4', + 'tenant': tenants[1].pk, }, { 'interface_a': interfaces[8].pk, 'interface_b': interfaces[9].pk, 'ssid': 'LINK5', + 'tenant': tenants[1].pk, }, { 'interface_a': interfaces[10].pk, 'interface_b': interfaces[11].pk, 'ssid': 'LINK6', + 'tenant': tenants[1].pk, }, ] diff --git a/netbox/wireless/tests/test_filtersets.py b/netbox/wireless/tests/test_filtersets.py index 5fee4fbf4..ffe919c32 100644 --- a/netbox/wireless/tests/test_filtersets.py +++ b/netbox/wireless/tests/test_filtersets.py @@ -3,6 +3,7 @@ from django.test import TestCase from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.models import Interface from ipam.models import VLAN +from tenancy.models import Tenant from wireless.choices import * from wireless.filtersets import * from wireless.models import * @@ -43,10 +44,6 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['wireless-lan-group-1', 'wireless-lan-group-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_description(self): - params = {'description': ['A', 'B']} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_parent(self): parent_groups = WirelessLANGroup.objects.filter(parent__isnull=True)[:2] params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} @@ -81,10 +78,17 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): ) VLAN.objects.bulk_create(vlans) + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + wireless_lans = ( - WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), - WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), - WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), + WirelessLAN(ssid='WLAN1', group=groups[0], vlan=vlans[0], tenant=tenants[0], auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1'), + WirelessLAN(ssid='WLAN2', group=groups[1], vlan=vlans[1], tenant=tenants[1], auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2'), + WirelessLAN(ssid='WLAN3', group=groups[2], vlan=vlans[2], tenant=tenants[2], auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, auth_psk='PSK3'), ) WirelessLAN.objects.bulk_create(wireless_lans) @@ -116,6 +120,13 @@ class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'auth_psk': ['PSK1', 'PSK2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = WirelessLink.objects.all() @@ -124,6 +135,13 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + devices = ( create_test_device('device1'), create_test_device('device2'), @@ -152,6 +170,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_psk='PSK1', + tenant=tenants[0], description='foobar1' ).save() WirelessLink( @@ -162,6 +181,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_psk='PSK2', + tenant=tenants[1], description='foobar2' ).save() WirelessLink( @@ -171,7 +191,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): status=LinkStatusChoices.STATUS_DECOMMISSIONING, auth_type=WirelessAuthTypeChoices.TYPE_WPA_PERSONAL, auth_cipher=WirelessAuthCipherChoices.CIPHER_AES, - auth_psk='PSK3' + auth_psk='PSK3', + tenant=tenants[2], ).save() WirelessLink( interface_a=interfaces[5], @@ -202,3 +223,10 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests): def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_tenant(self): + tenants = Tenant.objects.all()[:2] + params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'tenant': [tenants[0].slug, tenants[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/wireless/tests/test_views.py b/netbox/wireless/tests/test_views.py index 4141af6d6..7dea17d15 100644 --- a/netbox/wireless/tests/test_views.py +++ b/netbox/wireless/tests/test_views.py @@ -2,6 +2,7 @@ from wireless.choices import * from wireless.models import * from dcim.choices import InterfaceTypeChoices, LinkStatusChoices from dcim.models import Interface +from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -47,6 +48,13 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + groups = ( WirelessLANGroup(name='Wireless LAN Group 1', slug='wireless-lan-group-1'), WirelessLANGroup(name='Wireless LAN Group 2', slug='wireless-lan-group-2'), @@ -55,9 +63,9 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): group.save() WirelessLAN.objects.bulk_create([ - WirelessLAN(group=groups[0], ssid='WLAN1'), - WirelessLAN(group=groups[0], ssid='WLAN2'), - WirelessLAN(group=groups[0], ssid='WLAN3'), + WirelessLAN(group=groups[0], ssid='WLAN1', tenant=tenants[0]), + WirelessLAN(group=groups[0], ssid='WLAN2', tenant=tenants[0]), + WirelessLAN(group=groups[0], ssid='WLAN3', tenant=tenants[0]), ]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -65,14 +73,15 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'ssid': 'WLAN2', 'group': groups[1].pk, + 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "group,ssid", - "Wireless LAN Group 2,WLAN4", - "Wireless LAN Group 2,WLAN5", - "Wireless LAN Group 2,WLAN6", + f"group,ssid,tenant", + f"Wireless LAN Group 2,WLAN4,{tenants[0].name}", + f"Wireless LAN Group 2,WLAN5,{tenants[1].name}", + f"Wireless LAN Group 2,WLAN6,{tenants[2].name}", ) cls.bulk_edit_data = { @@ -85,6 +94,14 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): + + tenants = ( + Tenant(name='Tenant 1', slug='tenant-1'), + Tenant(name='Tenant 2', slug='tenant-2'), + Tenant(name='Tenant 3', slug='tenant-3'), + ) + Tenant.objects.bulk_create(tenants) + device = create_test_device('test-device') interfaces = [ Interface( @@ -98,9 +115,9 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): ] Interface.objects.bulk_create(interfaces) - WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1').save() - WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2').save() - WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3').save() + WirelessLink(interface_a=interfaces[0], interface_b=interfaces[1], ssid='LINK1', tenant=tenants[0]).save() + WirelessLink(interface_a=interfaces[2], interface_b=interfaces[3], ssid='LINK2', tenant=tenants[0]).save() + WirelessLink(interface_a=interfaces[4], interface_b=interfaces[5], ssid='LINK3', tenant=tenants[0]).save() tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -108,14 +125,15 @@ class WirelessLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'interface_a': interfaces[6].pk, 'interface_b': interfaces[7].pk, 'status': LinkStatusChoices.STATUS_PLANNED, + 'tenant': tenants[1].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "interface_a,interface_b,status", - f"{interfaces[6].pk},{interfaces[7].pk},connected", - f"{interfaces[8].pk},{interfaces[9].pk},connected", - f"{interfaces[10].pk},{interfaces[11].pk},connected", + f"interface_a,interface_b,status,tenant", + f"{interfaces[6].pk},{interfaces[7].pk},connected,{tenants[0].name}", + f"{interfaces[8].pk},{interfaces[9].pk},connected,{tenants[1].name}", + f"{interfaces[10].pk},{interfaces[11].pk},connected,{tenants[2].name}", ) cls.bulk_edit_data = { diff --git a/requirements.txt b/requirements.txt index d99e09943..ebe5c3b8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,9 +8,10 @@ django-mptt==0.13.4 django-pglocks==1.0.4 django-prometheus==2.2.0 django-redis==5.2.0 +django-rich==1.4.0 django-rq==2.5.1 django-tables2==2.4.1 -django-taggit==2.1.0 +django-taggit==3.0.0 django-timezone-field==5.0 djangorestframework==3.13.1 drf-yasg[validation]==1.21.3 @@ -18,7 +19,6 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.4.1 -markdown-include==0.7.0 mkdocs-material==8.4.0 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 @@ -30,10 +30,7 @@ social-auth-app-django==5.0.0 social-auth-core==4.3.0 svgwrite==1.4.3 tablib==3.2.1 -tzdata==2022.1 +tzdata==2022.2 # Workaround for #7401 jsonschema==3.2.0 - -# Workaround for #9986 -pytz==2022.1