diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..5b4e4e5a4 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: [jeremystretch] diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 5df769b94..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,40 +0,0 @@ ---- -name: 🐛 Bug Report -about: Report a reproducible bug in the current release of NetBox - ---- - - -### Environment -* Python version: -* NetBox version: - - -### Steps to Reproduce -1. -2. -3. - - -### Expected Behavior - - - -### Observed Behavior diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 000000000..a83e9b34e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,63 @@ +--- +name: 🐛 Bug Report +about: Report a reproducible bug in the current release of NetBox +labels: ["type: bug"] +body: + - type: markdown + attributes: + value: "**NOTE:** This form is only for reporting _reproducible bugs_ in a + current NetBox installation. If you're having trouble with installation or just + looking for assistance with using NetBox, please visit our + [discussion forum](https://github.com/netbox-community/netbox/discussions) instead." + - type: input + attributes: + label: NetBox version + description: "What version of NetBox are you currently running?" + placeholder: v2.10.4 + validations: + required: true + - type: dropdown + attributes: + label: Python version + description: "What version of Python are you currently running?" + options: + - 3.6 + - 3.7 + - 3.8 + - 3.9 + validations: + required: true + - type: textarea + attributes: + label: Steps to Reproduce + description: "Describe in detail the exact steps that someone else can take to + reproduce this bug using the current stable release of NetBox. Begin with the + creation of any necessary database objects and call out every operation being + performed explicitly. If reporting a bug in the REST API, be sure to reconstruct + the raw HTTP request(s) being made: Don't rely on a client library such as + pynetbox." + placeholder: | + 1. Click on "create widget" + 2. Set foo to 12 and bar to G + 3. Click the "create" button + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: "What did you expect to happen?" + placeholder: "A new widget should have been created with the specified attributes" + validations: + required: true + - type: textarea + attributes: + label: Observed Behavior + description: "What happened instead?" + placeholder: "A TypeError exception was raised" + validations: + required: true + - type: markdown + attributes: + value: | + ### Additional information + You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/documentation_change.md b/.github/ISSUE_TEMPLATE/documentation_change.md deleted file mode 100644 index 9419e0ba9..000000000 --- a/.github/ISSUE_TEMPLATE/documentation_change.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: 📖 Documentation Change -about: Suggest an addition or modification to the NetBox documentation - ---- - - -### Change Type -[ ] Addition -[ ] Correction -[ ] Deprecation -[ ] Cleanup (formatting, typos, etc.) - -### Area -[ ] Installation instructions -[ ] Configuration parameters -[ ] Functionality/features -[ ] REST API -[ ] Administration/development -[ ] Other - - -### Proposed Changes diff --git a/.github/ISSUE_TEMPLATE/documentation_change.yaml b/.github/ISSUE_TEMPLATE/documentation_change.yaml new file mode 100644 index 000000000..bff755719 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -0,0 +1,40 @@ +--- +name: 📖 Documentation Change +about: Suggest an addition or modification to the NetBox documentation +labels: ["type: documentation"] +body: + - type: dropdown + attributes: + label: Change Type + description: What type of change are you proposing? + options: + - Addition + - Correction + - Removal + - Cleanup (formatting, typos, etc.) + validations: + required: true + - type: dropdown + attributes: + label: Area + description: To what section of the documentation does this change primarily pertain? + options: + - Installation instructions + - Configuration parameters + - Functionality/features + - REST API + - Administration/development + - Other + validations: + required: true + - type: textarea + attributes: + label: Proposed Changes + description: "Describe the proposed changes and why they are necessary" + validations: + required: true + - type: markdown + attributes: + value: | + ### Additional information + You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index a1114706f..000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,54 +0,0 @@ ---- -name: ✨ Feature Request -about: Propose a new NetBox feature or enhancement - ---- - - -### Environment -* Python version: -* NetBox version: - - -### Proposed Functionality - - - -### Use Case - - - -### Database Changes - - - -### External Dependencies diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 000000000..efa83b376 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,58 @@ +--- +name: ✨ Feature Request +about: Propose a new NetBox feature or enhancement +labels: ["type: feature"] +body: + - type: markdown + attributes: + value: "**NOTE:** This form is only for submitting well-formed proposals to extend or + modify NetBox in some way. If you're trying to solve a problem but can't figure out how, + or if you still need time to work on the details of a proposed new feature, please start + a [discussion](https://github.com/netbox-community/netbox/discussions) instead." + - type: input + attributes: + label: NetBox version + description: "What version of NetBox are you currently running?" + placeholder: v2.10.4 + validations: + required: true + - type: dropdown + attributes: + label: Feature type + options: + - Data model extension + - New functionality + - Change to existing functionality + validations: + required: true + - type: textarea + attributes: + label: Proposed functionality + description: "Describe in detail the new feature or behavior you'd like to propose. + Include any specific changes to work flows, data models, or the user interface." + validations: + required: true + - type: textarea + attributes: + label: Use case + description: "Explain how adding this functionality would benefit NetBox users. What + need does it address?" + validations: + required: true + - type: textarea + attributes: + label: Database changes + description: "Note any changes to the database schema necessary to support the new + feature. For example, does the proposal require adding a new model or field? (Not + all new features require database changes.)" + - type: textarea + attributes: + label: External dependencies + description: "List any new dependencies on external libraries or services that this + new feature would introduce. For example, does the proposal require the installation + of a new Python package? (Not all new features introduce new dependencies.)" + - type: markdown + attributes: + value: | + ### Additional information + You can use the space below to provide any additional information or to attach files. diff --git a/.github/ISSUE_TEMPLATE/housekeeping.md b/.github/ISSUE_TEMPLATE/housekeeping.md deleted file mode 100644 index f9ad4142d..000000000 --- a/.github/ISSUE_TEMPLATE/housekeeping.md +++ /dev/null @@ -1,16 +0,0 @@ ---- -name: 🏡 Housekeeping -about: A change pertaining to the codebase itself (developers only) - ---- - - -### Proposed Changes - - - -### Justification diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yaml b/.github/ISSUE_TEMPLATE/housekeeping.yaml new file mode 100644 index 000000000..0f466aa24 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/housekeeping.yaml @@ -0,0 +1,27 @@ +--- +name: 🏡 Housekeeping +about: A change pertaining to the codebase itself (developers only) +labels: ["type: housekeeping"] +body: + - type: markdown + attributes: + value: "**NOTE:** This template is for use by maintainers only. Please do not submit + an issue using this template unless you have been specifically asked to do so." + - type: textarea + attributes: + label: Proposed Changes + description: "Describe in detail the new feature or behavior you'd like to propose. + Include any specific changes to work flows, data models, or the user interface." + validations: + required: true + - type: textarea + attributes: + label: Justification + description: "Please provide justification for the proposed change(s)." + validations: + required: true + - type: markdown + attributes: + value: | + ### Additional information + You can use the space below to provide any additional information or to attach files. diff --git a/.github/stale.yml b/.github/stale.yml index fdfb1d590..92da07e6a 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,8 +1,5 @@ # Configuration for Stale (https://github.com/apps/stale) -# Pull requests are exempt from being marked as stale -only: issues - # Number of days of inactivity before an issue becomes stale daysUntilStale: 45 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d154f5017..9182457a0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,5 +1,5 @@ name: CI -on: push +on: [push, pull_request] jobs: build: runs-on: ubuntu-latest diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c86f810f3..009e6586c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,7 +25,7 @@ discussions. ### Slack -For real-time chat, you can join the **#netbox** Slack channel on [NetworkToCode](https://slack.networktocode.com/). +For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://slack.netbox.dev/). Unfortunately, the Slack channel does not provide long-term retention of chat history, so try to avoid it for any discussions would benefit from being preserved for future reference. @@ -185,11 +185,5 @@ overlooked. sync to review agenda items. This meeting provides opportunity to present and discuss pressing topics. Meetings are held as virtual audio/video conferences. -* Official channels for communication include: - - * GitHub issues, pull requests, and discussions - * The [netbox-discuss](https://groups.google.com/g/netbox-discuss) mailing list - * The **#netbox** channel on [NetworkToCode Slack](https://networktocode.slack.com/) - * Maintainers with no substantial recorded activity in a 60-day period will be removed from the project. diff --git a/README.md b/README.md index 0b9531df0..e35b72c2e 100644 --- a/README.md +++ b/README.md @@ -7,13 +7,16 @@ to address the needs of network and infrastructure engineers. It is intended to function as a domain-specific source of truth for network operations. NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) -Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a +Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). -The complete documentation for NetBox can be found at [Read the Docs](http://netbox.readthedocs.io/en/stable/). +The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). -Questions? Comments? Please start a [discussion on GitHub](https://github.com/netbox-community/netbox/discussions), -or join us in the **#netbox** Slack channel on [NetworkToCode](https://networktocode.slack.com/)! +### Discussion + +* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions +* [Slack](https://slack.netbox.dev/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out +* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions ### Build Status @@ -36,7 +39,7 @@ or join us in the **#netbox** Slack channel on [NetworkToCode](https://networkto ## Installation -Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for +Please see [the documentation](https://netbox.readthedocs.io/en/stable/) for instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) and run `upgrade.sh`. diff --git a/base_requirements.txt b/base_requirements.txt index a57e88604..bf03bf71e 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -93,3 +93,7 @@ redis # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite svgwrite + +# Tabular dataset library (for table-based exports) +# https://github.com/jazzband/tablib +tablib diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md index 359d65202..ebce63578 100644 --- a/docs/additional-features/caching.md +++ b/docs/additional-features/caching.md @@ -1,6 +1,6 @@ # Caching -NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../../configuration/optional-settings/#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. +NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache. If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database. diff --git a/docs/additional-features/change-logging.md b/docs/additional-features/change-logging.md index d580ccc6c..3eb99c94c 100644 --- a/docs/additional-features/change-logging.md +++ b/docs/additional-features/change-logging.md @@ -1,6 +1,6 @@ # Change Logging -Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log. +Every time an object in NetBox is created, updated, or deleted, a serialized copy of that object taken both before and after the change is saved to the database, along with meta data including the current time and the user associated with the change. These records form a persistent record of changes both for each individual object as well as NetBox as a whole. The global change log can be viewed by navigating to Other > Change Log. A serialized representation of the instance being modified is included in JSON format. This is similar to how objects are conveyed within the REST API, but does not include any nested representations. For instance, the `tenant` field of a site will record only the tenant's ID, not a representation of the tenant. diff --git a/docs/additional-features/custom-fields.md b/docs/additional-features/custom-fields.md index 91bf03379..5c74c6744 100644 --- a/docs/additional-features/custom-fields.md +++ b/docs/additional-features/custom-fields.md @@ -16,6 +16,7 @@ Custom fields must be created through the admin UI under Extras > Custom Fields. * Date: A date in ISO 8601 format (YYYY-MM-DD) * URL: This will be presented as a link in the web UI * Selection: A selection of one of several pre-defined custom choices +* Multiple selection: A selection field which supports the assignment of multiple values Each custom field must have a name; this should be a simple database-friendly string, e.g. `tps_report`. 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. @@ -39,6 +40,8 @@ Each custom selection field must have at least two choices. These are specified 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 Fields and the REST API When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined: diff --git a/docs/additional-features/custom-scripts.md b/docs/additional-features/custom-scripts.md index 28d1bf856..3ed910791 100644 --- a/docs/additional-features/custom-scripts.md +++ b/docs/additional-features/custom-scripts.md @@ -170,18 +170,13 @@ Similar to `ChoiceVar`, but allows for the selection of multiple choices. A particular object within NetBox. Each ObjectVar must specify a particular model, and allows the user to select one of the available instances. ObjectVar accepts several arguments, listed below. * `model` - The model class -* `display_field` - The name of the REST API object field to display in the selection list (default: `'name'`) +* `display_field` - The name of the REST API object field to display in the selection list (default: `'display'`) * `query_params` - A dictionary of query parameters to use when retrieving available options (optional) * `null_option` - A label representing a "null" or empty choice (optional) -The `display_field` argument is useful when referencing a model which does not have a `name` field. For example, when displaying a list of device types, you would likely use the `model` field: - -```python -device_type = ObjectVar( - model=DeviceType, - display_field='model' -) -``` +!!! warning + The `display_field` parameter is now deprecated, and will be removed in NetBox v2.12. All ObjectVar instances will + instead use the new standard `display` field for all serializers (introduced in NetBox v2.11). To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status: diff --git a/docs/additional-features/export-templates.md b/docs/additional-features/export-templates.md index b7bbc9842..950d02d4a 100644 --- a/docs/additional-features/export-templates.md +++ b/docs/additional-features/export-templates.md @@ -2,12 +2,12 @@ NetBox allows users to define custom templates that can be used when exporting objects. To create an export template, navigate to Extras > Export Templates under the admin interface. -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 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 may be written in Jinja2 or [Django's template language](https://docs.djangoproject.com/en/stable/ref/templates/language/), which is very similar to Jinja2. +!!! note + The name `table` is reserved for internal use. -!!! warning - Support for Django's native templating logic will be removed in NetBox v2.10. +Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/). 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: diff --git a/docs/additional-features/journaling.md b/docs/additional-features/journaling.md new file mode 100644 index 000000000..ce126bf27 --- /dev/null +++ b/docs/additional-features/journaling.md @@ -0,0 +1,5 @@ +# Journaling + +All primary objects in NetBox support journaling. A journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after a configurable period of time, journal entries persist for the life of their associated object. + +Each journal entry has a selectable kind (info, success, warning, or danger) and a user-populated `comments` field. Each entry automatically records the date, time, and associated user upon being created. diff --git a/docs/additional-features/napalm.md b/docs/additional-features/napalm.md index c8a3665b9..d2d69fc3c 100644 --- a/docs/additional-features/napalm.md +++ b/docs/additional-features/napalm.md @@ -22,7 +22,7 @@ GET /api/dcim/devices/1/napalm/?method=get_environment ## Authentication -By default, the [`NAPALM_USERNAME`](../../configuration/optional-settings/#napalm_username) and [`NAPALM_PASSWORD`](../../configuration/optional-settings/#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/optional-settings.md#napalm_username) and [`NAPALM_PASSWORD`](../configuration/optional-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. ``` $ curl "http://localhost/api/dcim/devices/1/napalm/?method=get_environment" \ diff --git a/docs/additional-features/prometheus-metrics.md b/docs/additional-features/prometheus-metrics.md index 048eb68b3..56365e336 100644 --- a/docs/additional-features/prometheus-metrics.md +++ b/docs/additional-features/prometheus-metrics.md @@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable. !!! warning - If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using Netbox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562). \ No newline at end of file + If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562). diff --git a/docs/additional-features/reports.md b/docs/additional-features/reports.md index 9d60f797e..bac1003fd 100644 --- a/docs/additional-features/reports.md +++ b/docs/additional-features/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/#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/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. !!! warning The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file. @@ -66,7 +66,7 @@ class DeviceConnectionsReport(Report): for power_port in PowerPort.objects.filter(device=device): if power_port.connected_endpoint is not None: connected_ports += 1 - if not power_port.connection_status: + if not power_port.path.is_active: self.log_warning( device, "Power connection for {} marked as planned".format(power_port.name) diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 845a6745d..f3dd80337 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -38,7 +38,8 @@ The following data is available as context for Jinja2 templates: * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format). * `username` - The name of the user account associated with the change. * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request. -* `data` - A serialized representation of the object _after_ the change was made. This is typically equivalent to the model's representation in NetBox's REST API. +* `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 ass 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 @@ -47,7 +48,7 @@ If no body template is specified, the request body will be populated with a JSON ```no-highlight { "event": "created", - "timestamp": "2020-02-25 15:10:26.010582+00:00", + "timestamp": "2021-03-09 17:55:33.968016+00:00", "model": "site", "username": "jstretch", "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a", @@ -62,13 +63,24 @@ If no body template is specified, the request body will be populated with a JSON }, "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", + ... + } } } ``` ## 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 Django RQ > Queues. +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. diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 23c1082bc..ee956edf5 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -12,13 +12,16 @@ NetBox employs a [PostgreSQL](https://www.postgresql.org/) database, so general Use the `pg_dump` utility to export the entire database to a file: ```no-highlight -pg_dump netbox > netbox.sql +pg_dump --username netbox --password --host localhost netbox > netbox.sql ``` +!!! note + You may need to change the username, host, and/or database in the command above to match your installation. + When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. ```no-highlight -pg_dump --exclude-table-data=extras_objectchange netbox > netbox.sql +pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql ``` ### Load an Exported Database @@ -41,7 +44,7 @@ Keep in mind that PostgreSQL user accounts and permissions are not included with If you want to export only the database schema, and not the data itself (e.g. for development reference), do the following: ```no-highlight -pg_dump -s netbox > netbox_schema.sql +pg_dump --username netbox --password --host localhost -s netbox > netbox_schema.sql ``` --- diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index fe43f0483..4ed3d946e 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -44,7 +44,7 @@ This defines custom content to be displayed on the login page above the login fo Default: None -The base URL path to use when accessing NetBox. Do not include the scheme or domain name. For example, if installed at http://example.com/netbox/, set: +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/' @@ -56,7 +56,7 @@ BASE_PATH = 'netbox/' Default: 900 -The number of seconds to cache entries will be retained before expiring. +The number of seconds that cache entries will be retained before expiring. --- @@ -281,6 +281,14 @@ Setting this to True will display a "maintenance mode" banner at the top of ever --- +## 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 @@ -301,7 +309,7 @@ The file path to the location where media files (such as image attachments) are Default: False -Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../../additional-features/prometheus-metrics/) documentation for more details. +Toggle the availability Prometheus-compatible metrics at `/metrics`. See the [Prometheus Metrics](../additional-features/prometheus-metrics.md) documentation for more details. --- @@ -318,7 +326,7 @@ NetBox will use these credentials when authenticating to remote devices via the ## 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](http://napalm.readthedocs.io/en/latest/support/#optional-arguments). An example: +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 = { diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index 2edf6c7c7..dba8cdc8c 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -5,7 +5,7 @@ This is a list of valid fully-qualified domain names (FQDNs) and/or IP addresses that can be used to reach the NetBox service. Usually this is the same as the hostname for the NetBox server, but can also be different; for example, when using a reverse proxy serving the NetBox website under a different FQDN than the hostname of the NetBox server. To help guard against [HTTP Host header attackes](https://docs.djangoproject.com/en/3.0/topics/security/#host-headers-virtual-hosting), NetBox will not permit access to the server via any other hostnames (or IPs). !!! note - This parameter must always be defined as a list or tuple, even if only value is provided. + This parameter must always be defined as a list or tuple, even if only a single value is provided. The value of this option is also used to set `CSRF_TRUSTED_ORIGINS`, which restricts POST requests to the same set of hosts (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS)). Keep in mind that NetBox, by default, sets `USE_X_FORWARDED_HOST` to true, which means that if you're using a reverse proxy, it's the FQDN used to reach that reverse proxy which needs to be in this list (more about this [here](https://docs.djangoproject.com/en/stable/ref/settings/#allowed-hosts)). @@ -101,7 +101,7 @@ REDIS = { If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal configuration necessary to convert NetBox to recognize it. It requires the removal of the `HOST` and `PORT` keys from -above and the addition of two new keys. +above and the addition of three new keys. * `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address of the Redis server and port for each sentinel instance to connect to diff --git a/docs/core-functionality/circuits.md b/docs/core-functionality/circuits.md index 43b911308..67388dba4 100644 --- a/docs/core-functionality/circuits.md +++ b/docs/core-functionality/circuits.md @@ -1,6 +1,7 @@ # Circuits {!docs/models/circuits/provider.md!} +{!docs/models/circuits/cloud.md!} --- diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 96dcf866d..e05d6efd3 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -8,6 +8,8 @@ ## Device Components +Device components represent discrete objects within a device which are used to terminate cables, house child devices, or track resources. + {!docs/models/dcim/consoleport.md!} {!docs/models/dcim/consoleserverport.md!} {!docs/models/dcim/powerport.md!} diff --git a/docs/core-functionality/sites-and-racks.md b/docs/core-functionality/sites-and-racks.md index 073756fa1..1b5ee3ad1 100644 --- a/docs/core-functionality/sites-and-racks.md +++ b/docs/core-functionality/sites-and-racks.md @@ -1,11 +1,12 @@ # Sites and Racks -{!docs/models/dcim/site.md!} {!docs/models/dcim/region.md!} +{!docs/models/dcim/sitegroup.md!} +{!docs/models/dcim/site.md!} +{!docs/models/dcim/location.md!} --- {!docs/models/dcim/rack.md!} -{!docs/models/dcim/rackgroup.md!} {!docs/models/dcim/rackrole.md!} {!docs/models/dcim/rackreservation.md!} diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 8745825ab..beeae2ffb 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -5,8 +5,8 @@ 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 PostgreSQL server, which can be installed locally [per the documentation](/installation/1-postgresql/) -* A Redis server, which can also be [installed locally](/installation/2-redis/) +* 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 ### Fork the Repo @@ -27,13 +27,13 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts ``` -The NetBox project utilizes three long-term branches: +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 -* `develop-x.y` - Tracks work on an upcoming major release +* `feature` - Tracks work on an upcoming major release -Typically, you'll base pull requests off of the `develop` branch, or off of `develop-x.y` if you're working on a new major release. **Never** base pull requests off of the master branch, which receives merged only from the `develop` branch. +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, which receives merged only from the `develop` branch. ### Enable Pre-Commit Hooks diff --git a/docs/development/index.md b/docs/development/index.md index c0aea0eeb..e9758e74b 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -4,12 +4,12 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n ## Communication -Communication among developers should always occur via public channels: +There are several official forums for communication among the developers and community members: * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. -* [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. -* [The mailing list](https://groups.google.com/g/netbox-discuss) - An alternative forum for general discussion (GitHub is preferred). -* [#netbox on NetworkToCode](http://slack.networktocode.com/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. +* [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://slack.netbox.dev/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. +* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions. ## Governance diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 22fa6f1e8..d8cb671f6 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -2,24 +2,9 @@ ## Minor Version Bumps -### Update Requirements +### Address Pinned Dependencies -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: - -``` -# 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. - -Every minor version release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this: - -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. +Check `base_requirements.txt` for any dependencies pinned to a specific version, and upgrade them to their most stable release (where possible). ### Update Static Libraries @@ -52,15 +37,33 @@ Close the release milestone on GitHub after ensuring there are no remaining open ### Merge the Release Branch -Submit a pull request to merge the release branch `develop-x.y` into the `develop` branch in preparation for its releases. - -!!! warning - No further releases for the current major version can be published once this pull request is merged. +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: + +``` +# 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. + +Every release should refresh `requirements.txt` so that it lists the most recent stable release of each package. To do this: + +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. + +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. + ### Verify CI Build Status Ensure that continuous integration testing on the `develop` branch is completing successfully. diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index bcc670e2b..1db490798 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -7,23 +7,24 @@ This section entails the installation and configuration of a local PostgreSQL da ## Installation -#### Ubuntu +## Installation -Install the PostgreSQL server and client development libraries using `apt`. +=== "Ubuntu" -```no-highlight -sudo apt update -sudo apt install -y postgresql libpq-dev -``` + ```no-highlight + sudo apt update + sudo apt install -y postgresql libpq-dev + ``` -#### CentOS +=== "CentOS" -PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). + ```no-highlight + sudo yum install -y postgresql-server libpq-devel + sudo postgresql-setup --initdb + ``` -```no-highlight -sudo yum install -y postgresql-server libpq-devel -sudo postgresql-setup --initdb -``` + !!! info + PostgreSQL 9.6 and later are available natively on CentOS 8.2. If using an earlier CentOS release, you may need to [install it from an RPM](https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/). CentOS configures ident host-based authentication for PostgreSQL by default. Because NetBox will need to authenticate using a username and password, modify `/var/lib/pgsql/data/pg_hba.conf` to support MD5 authentication by changing `ident` to `md5` for the lines below: diff --git a/docs/installation/2-redis.md b/docs/installation/2-redis.md index 19ec9073e..e31873d2b 100644 --- a/docs/installation/2-redis.md +++ b/docs/installation/2-redis.md @@ -7,19 +7,19 @@ !!! note NetBox v2.9.0 and later require Redis v4.0 or higher. If your distribution does not offer a recent enough release, you will need to build Redis from source. Please see [the Redis installation documentation](https://github.com/redis/redis) for further details. -### Ubuntu +=== "Ubuntu" -```no-highlight -sudo apt install -y redis-server -``` + ```no-highlight + sudo apt install -y redis-server + ``` -### CentOS +=== "CentOS" -```no-highlight -sudo yum install -y redis -sudo systemctl start redis -sudo systemctl enable redis -``` + ```no-highlight + sudo yum install -y redis + sudo systemctl start redis + sudo systemctl enable redis + ``` You may wish to modify the Redis configuration at `/etc/redis.conf` or `/etc/redis/redis.conf`, however in most cases the default configuration is sufficient. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 39189bbd1..2f8958b25 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,19 +7,19 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! note - NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. This documentation assumes Python 3.6. + NetBox v2.8.0 and later require Python 3.6, 3.7, or 3.8. -### Ubuntu +=== Ubuntu -```no-highlight -sudo apt install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev -``` + ```no-highlight + sudo apt install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev + ``` -### CentOS +=== CentOS -```no-highlight -sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config -``` + ```no-highlight + sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config + ``` Before continuing with either platform, update pip (Python's package management tool) to its latest release: @@ -57,17 +57,17 @@ sudo mkdir -p /opt/netbox/ && cd /opt/netbox/ If `git` is not already installed, install it: -#### Ubuntu +=== Ubuntu -```no-highlight -sudo apt install -y git -``` + ```no-highlight + sudo apt install -y git + ``` -#### CentOS +=== CentOS -```no-highlight -sudo yum install -y git -``` + ```no-highlight + sudo yum install -y git + ``` Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.) @@ -83,26 +83,26 @@ Checking connectivity... done. ``` !!! note - Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `develop-x.y` branch (if present) tracks progress on the next major release. + Installation via git also allows you to easily try out development versions of NetBox. The `develop` branch contains all work underway for the next minor release, and the `feature` branch tracks progress on the next major release. ## Create the NetBox System User Create a system user account named `netbox`. We'll configure the WSGI and HTTP services to run under this account. We'll also assign this user ownership of the media directory. This ensures that NetBox will be able to save uploaded files. -#### Ubuntu +=== Ubuntu -``` -sudo adduser --system --group netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo adduser --system --group netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` -#### CentOS +=== CentOS -``` -sudo groupadd --system netbox -sudo adduser --system -g netbox netbox -sudo chown --recursive netbox /opt/netbox/netbox/media/ -``` + ``` + sudo groupadd --system netbox + sudo adduser --system -g netbox netbox + sudo chown --recursive netbox /opt/netbox/netbox/media/ + ``` ## Configuration @@ -113,7 +113,7 @@ cd /opt/netbox/netbox/netbox/ sudo cp configuration.example.py configuration.py ``` -Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](/configuration/), but only the following four are required for new installations: +Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: * `ALLOWED_HOSTS` * `DATABASE` @@ -136,7 +136,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/#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-settings.md#database) for more detail on individual parameters. ```python DATABASE = { @@ -151,7 +151,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/#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-settings.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. @@ -203,7 +203,7 @@ sudo 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/#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/optional-settings.md#storage_backend) in `configuration.py`. ```no-highlight sudo echo django-storages >> /opt/netbox/local_requirements.txt diff --git a/docs/installation/5-http-server.md b/docs/installation/5-http-server.md index eba0db21b..907964554 100644 --- a/docs/installation/5-http-server.md +++ b/docs/installation/5-http-server.md @@ -1,6 +1,6 @@ # HTTP Server Setup -This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](http://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible. +This documentation provides example configurations for both [nginx](https://www.nginx.com/resources/wiki/) and [Apache](https://httpd.apache.org/docs/current/), though any HTTP server which supports WSGI should be compatible. !!! info For the sake of brevity, only Ubuntu 20.04 instructions are provided here. These tasks are not unique to NetBox and should carry over to other distributions with minimal changes. Please consult your distribution's documentation for assistance if needed. diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index ce6262531..5d5135b33 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -41,7 +41,7 @@ First, enable the LDAP authentication backend in `configuration.py`. (Be sure to REMOTE_AUTH_BACKEND = 'netbox.authentication.LDAPBackend' ``` -Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](http://django-auth-ldap.readthedocs.io/). +Next, create a file in the same directory as `configuration.py` (typically `/opt/netbox/netbox/netbox/`) named `ldap_config.py`. Define all of the parameters required below in `ldap_config.py`. Complete documentation of all `django-auth-ldap` configuration options is included in the project's [official documentation](https://django-auth-ldap.readthedocs.io/). ### General Server Configuration @@ -140,9 +140,9 @@ AUTH_LDAP_CACHE_TIMEOUT = 3600 ## Troubleshooting LDAP -`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`. +`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/optional-settings.md#logging) configuration to `configuration.py`: ```python LOGGING = { diff --git a/docs/installation/index.md b/docs/installation/index.md index 730176e0c..2ae5e5299 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -11,6 +11,10 @@ The following sections detail how to set up a new instance of NetBox: 5. [HTTP server](5-http-server.md) 6. [LDAP authentication](6-ldap.md) (optional) +The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference. + + + ## Requirements | Dependency | Minimum Version | @@ -19,6 +23,9 @@ The following sections detail how to set up a new instance of NetBox: | PostgreSQL | 9.6 | | Redis | 4.0 | +!!! note + Python 3.7 or later will be required in NetBox v2.12. Users are strongly encouraged to install NetBox using Python 3.7 or later for new deployments. + Below is a simplified overview of the NetBox application stack for reference: ![NetBox UI as seen by a non-authenticated user](../media/installation/netbox_application_stack.png) @@ -26,6 +33,3 @@ Below is a simplified overview of the NetBox application stack for reference: ## Upgrading If you are upgrading from an existing installation, please consult the [upgrading guide](upgrading.md). - -!!! note - Beginning with v2.5.9, the official documentation calls for systemd to be used for managing the WSGI workers in place of supervisord. Please see the instructions for [migrating to systemd](migrating-to-systemd.md) if you are still using supervisord. diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index cdd20f01b..e824ad7ab 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -2,7 +2,7 @@ ## Review the Release Notes -Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. +Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. ## Update Dependencies to Required Versions diff --git a/docs/models/circuits/circuittermination.md b/docs/models/circuits/circuittermination.md index 1c0dbfe18..c1ec09cae 100644 --- a/docs/models/circuits/circuittermination.md +++ b/docs/models/circuits/circuittermination.md @@ -2,9 +2,9 @@ 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 tied to a site, and 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 termination is attached to either a site or a cloud. 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. -In adherence with NetBox's philosophy of closely modeling the real world, a circuit may terminate 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. +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. + 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 cloud model is ideal for representing these networks. diff --git a/docs/models/circuits/cloud.md b/docs/models/circuits/cloud.md new file mode 100644 index 000000000..c4b3cec5e --- /dev/null +++ b/docs/models/circuits/cloud.md @@ -0,0 +1,5 @@ +# Clouds + +A cloud represents an abstract portion of network topology, just like in a topology diagram. For example, a cloud may be used to represent a provider's MPLS network. + +Each cloud must be assigned to a provider. A circuit may terminate to either a cloud or to a site. diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index df14c0e07..a99078472 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -8,7 +8,7 @@ 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 rack within a site. A platform, serial number, and asset tag may optionally be assigned to each device. +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. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 756e320af..bd9975a72 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -2,11 +2,15 @@ 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). -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. +!!! 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. + +### Interface Types + +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. 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. -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.) +### IP Address Assignment -!!! 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. +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.) diff --git a/docs/models/dcim/location.md b/docs/models/dcim/location.md new file mode 100644 index 000000000..16df208ac --- /dev/null +++ b/docs/models/dcim/location.md @@ -0,0 +1,5 @@ +# 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. + +The name and facility ID of each rack within a location must be unique. (Racks not assigned to the same location may have identical names and/or facility IDs.) diff --git a/docs/models/dcim/powerpanel.md b/docs/models/dcim/powerpanel.md index 3daecbacf..813321179 100644 --- a/docs/models/dcim/powerpanel.md +++ b/docs/models/dcim/powerpanel.md @@ -2,7 +2,7 @@ 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 rack group. +Each power panel must be assigned to a site, and may optionally be assigned to a particular location within that site. !!! 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. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index e5e52cc07..90c9cfe6e 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -1,6 +1,6 @@ # 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 rack group and/or tenant. Racks can also be organized by user-defined functional roles. +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. 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. diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md deleted file mode 100644 index 974285f71..000000000 --- a/docs/models/dcim/rackgroup.md +++ /dev/null @@ -1,7 +0,0 @@ -# Rack Groups - -Racks can be organized into groups, which can be nested into themselves similar to regions. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. - -Each rack group must be assigned to a parent site, and rack groups may optionally be nested within a site to model a multi-level hierarchy. For example, you might have a tier of rooms beneath a tier of floors, all belonging to the same parent building (site). - -The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.) diff --git a/docs/models/dcim/sitegroup.md b/docs/models/dcim/sitegroup.md new file mode 100644 index 000000000..3c1ed11bd --- /dev/null +++ b/docs/models/dcim/sitegroup.md @@ -0,0 +1,3 @@ +# 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. diff --git a/docs/models/extras/configcontext.md b/docs/models/extras/configcontext.md index af81cfbf9..bb4a22e0d 100644 --- a/docs/models/extras/configcontext.md +++ b/docs/models/extras/configcontext.md @@ -3,11 +3,13 @@ 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: * Region +* Site group * Site +* Device type (devices only) * Role * Platform -* Cluster group -* Cluster +* Cluster group (VMs only) +* Cluster (VMs only) * Tenant group * Tenant * Tag diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index 40e9ef2c0..de9b5f214 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -11,4 +11,6 @@ Like devices, each VM can be assigned a platform and/or functional role, and mus * Failed * Decommissioning -Additional fields are available for annotating the vCPU count, memory (GB), and disk (GB) allocated to each VM. 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. +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). + +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. diff --git a/docs/plugins/development.md b/docs/plugins/development.md index d65e7d830..f008da2fb 100644 --- a/docs/plugins/development.md +++ b/docs/plugins/development.md @@ -63,11 +63,15 @@ setup( install_requires=[], packages=find_packages(), include_package_data=True, + zip_safe=False, ) ``` Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). +!!! note + `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) + ### Define a PluginConfig The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 8990f83e0..f7f6f36e9 120000 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1 +1 @@ -version-2.10.md \ No newline at end of file +version-2.11.md \ No newline at end of file diff --git a/docs/release-notes/version-2.1.md b/docs/release-notes/version-2.1.md index 59f23c090..e5fa41d82 100644 --- a/docs/release-notes/version-2.1.md +++ b/docs/release-notes/version-2.1.md @@ -121,7 +121,7 @@ A new API endpoint has been added at `/api/ipam/prefixes//available-ips/`. A #### NAPALM Integration ([#1348](https://github.com/netbox-community/netbox/issues/1348)) -The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](http://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. +The [NAPALM automation](https://napalm-automation.net/) library provides an abstracted interface for pulling live data (e.g. uptime, software version, running config, LLDP neighbors, etc.) from network devices. The NetBox API has been extended to support executing read-only NAPALM methods on devices defined in NetBox. To enable this functionality, ensure that NAPALM has been installed (`pip install napalm`) and the `NETBOX_USERNAME` and `NETBOX_PASSWORD` [configuration parameters](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#netbox_username) have been set in configuration.py. ### Enhancements diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 15ac1fc04..7031f7fb8 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,162 @@ # NetBox v2.10 +## v2.10.8 (2021-03-26) + +### Bug Fixes + +* [#6060](https://github.com/netbox-community/netbox/issues/6060) - Fix exception on cable trace in UI (regression from #5650) + +--- + +## v2.10.7 (2021-03-25) + +### Enhancements + +* [#5641](https://github.com/netbox-community/netbox/issues/5641) - Allow filtering device components by label +* [#5723](https://github.com/netbox-community/netbox/issues/5723) - Allow customization of the geographic mapping service via `MAPS_URL` config parameter +* [#5736](https://github.com/netbox-community/netbox/issues/5736) - Allow changing site assignment when bulk editing devices +* [#5953](https://github.com/netbox-community/netbox/issues/5953) - Support Markdown rendering for custom script descriptions +* [#6040](https://github.com/netbox-community/netbox/issues/6040) - Add UI search fields for asset tag for devices and racks + +### Bug Fixes + +* [#5595](https://github.com/netbox-community/netbox/issues/5595) - Restore ability to delete an uploaded device type image +* [#5650](https://github.com/netbox-community/netbox/issues/5650) - Denote when the total length of a cable trace may exceed the indicated value +* [#5962](https://github.com/netbox-community/netbox/issues/5962) - Ensure consistent display of change log action labels +* [#5966](https://github.com/netbox-community/netbox/issues/5966) - Skip Markdown reference link when tabbing through form fields +* [#5977](https://github.com/netbox-community/netbox/issues/5977) - Correct validation of `RELEASE_CHECK_URL` config parameter +* [#6006](https://github.com/netbox-community/netbox/issues/6006) - Fix VLAN group/site association for bulk prefix import +* [#6010](https://github.com/netbox-community/netbox/issues/6010) - Eliminate duplicate virtual chassis search results +* [#6012](https://github.com/netbox-community/netbox/issues/6012) - Pre-populate attributes when creating an available child prefix via the UI +* [#6023](https://github.com/netbox-community/netbox/issues/6023) - Fix display of bottom banner with uBlock Origin enabled + +--- + +## v2.10.6 (2021-03-09) + +### Enhancements + +* [#5592](https://github.com/netbox-community/netbox/issues/5592) - Add IP addresses count to VRF view +* [#5630](https://github.com/netbox-community/netbox/issues/5630) - Add QSFP+ (64GFC) FibreChannel Interface option +* [#5884](https://github.com/netbox-community/netbox/issues/5884) - Enable custom links for device components +* [#5914](https://github.com/netbox-community/netbox/issues/5914) - Add edit/delete buttons for IP addresses on interface view +* [#5942](https://github.com/netbox-community/netbox/issues/5942) - Add button to add a new IP address on interface view + +### Bug Fixes + +* [#5703](https://github.com/netbox-community/netbox/issues/5703) - Fix VRF and Tenant field population when adding IP addresses from prefix +* [#5819](https://github.com/netbox-community/netbox/issues/5819) - Enable ordering of virtual machines by primary IP address +* [#5872](https://github.com/netbox-community/netbox/issues/5872) - Ordering of devices by primary IP should respect `PREFER_IPV4` configuration parameter +* [#5922](https://github.com/netbox-community/netbox/issues/5922) - Fix options for filtering object permissions in admin UI +* [#5935](https://github.com/netbox-community/netbox/issues/5935) - Fix filtering prefixes list by multiple prefix values +* [#5948](https://github.com/netbox-community/netbox/issues/5948) - Invalidate cached queries when running `renaturalize` + +--- + +## v2.10.5 (2021-02-24) + +### Bug Fixes + +* [#5315](https://github.com/netbox-community/netbox/issues/5315) - Fix site unassignment from VLAN when using "None" option +* [#5626](https://github.com/netbox-community/netbox/issues/5626) - Fix REST API representation for circuit terminations connected to non-interface endpoints +* [#5716](https://github.com/netbox-community/netbox/issues/5716) - Fix filtering rack reservations by custom field +* [#5718](https://github.com/netbox-community/netbox/issues/5718) - Fix bulk editing of services when no port(s) are defined +* [#5735](https://github.com/netbox-community/netbox/issues/5735) - Ensure consistent treatment of duplicate IP addresses +* [#5738](https://github.com/netbox-community/netbox/issues/5738) - Fix redirect to device components view after disconnecting a cable +* [#5753](https://github.com/netbox-community/netbox/issues/5753) - Fix Redis Sentinel password application for caching +* [#5786](https://github.com/netbox-community/netbox/issues/5786) - Allow setting null tenant group on tenant via REST API +* [#5841](https://github.com/netbox-community/netbox/issues/5841) - Disallow the creation of available prefixes/IP addresses in violation of assigned permission constraints + +--- + +## v2.10.4 (2021-01-26) + +### Enhancements + +* [#5542](https://github.com/netbox-community/netbox/issues/5542) - Show cable trace lengths in both meters and feet +* [#5570](https://github.com/netbox-community/netbox/issues/5570) - Add "management only" filter widget for interfaces list +* [#5586](https://github.com/netbox-community/netbox/issues/5586) - Allow filtering virtual chassis by name and master +* [#5612](https://github.com/netbox-community/netbox/issues/5612) - Add GG45 and TERA port types, and CAT7a and CAT8 cable types +* [#5678](https://github.com/netbox-community/netbox/issues/5678) - Show available type choices for all device component import forms + +### Bug Fixes + +* [#5232](https://github.com/netbox-community/netbox/issues/5232) - Correct swagger definition for ip_prefixes_available-ips_create API +* [#5574](https://github.com/netbox-community/netbox/issues/5574) - Restrict the creation of device bay templates on non-parent device types +* [#5584](https://github.com/netbox-community/netbox/issues/5584) - Restore power utilization panel under device view +* [#5597](https://github.com/netbox-community/netbox/issues/5597) - Fix ordering devices by primary IP address +* [#5603](https://github.com/netbox-community/netbox/issues/5603) - Fix display of white cables in trace view +* [#5639](https://github.com/netbox-community/netbox/issues/5639) - Fix filtering connection lists by device name +* [#5640](https://github.com/netbox-community/netbox/issues/5640) - Fix permissions assessment when adding VM interfaces in bulk +* [#5648](https://github.com/netbox-community/netbox/issues/5648) - Include VC member interfaces on interfaces tab count when viewing VC master +* [#5665](https://github.com/netbox-community/netbox/issues/5665) - Validate rack group is assigned to same site when creating a rack +* [#5683](https://github.com/netbox-community/netbox/issues/5683) - Correct rack elevation displayed when viewing a reservation + +--- + +## v2.10.3 (2021-01-05) + +### Bug Fixes + +* [#5049](https://github.com/netbox-community/netbox/issues/5049) - Add check for LLDP neighbor chassis name to lldp_neighbors +* [#5301](https://github.com/netbox-community/netbox/issues/5301) - Fix misleading error when racking a device with invalid parameters +* [#5311](https://github.com/netbox-community/netbox/issues/5311) - Update child objects when a rack group is moved to a new site +* [#5518](https://github.com/netbox-community/netbox/issues/5518) - Fix persistent vertical scrollbar +* [#5533](https://github.com/netbox-community/netbox/issues/5533) - Fix bulk editing of objects with required custom fields +* [#5540](https://github.com/netbox-community/netbox/issues/5540) - Fix exception when viewing a provider with one or more tags assigned +* [#5543](https://github.com/netbox-community/netbox/issues/5543) - Fix rendering of config contexts with cluster assignment for devices +* [#5546](https://github.com/netbox-community/netbox/issues/5546) - Add custom field bulk edit support for cables, power panels, rack reservations, and virtual chassis +* [#5547](https://github.com/netbox-community/netbox/issues/5547) - Add custom field bulk import support for cables, power panels, rack reservations, and virtual chassis +* [#5551](https://github.com/netbox-community/netbox/issues/5551) - Restore missing import button on services list +* [#5557](https://github.com/netbox-community/netbox/issues/5557) - Fix VRF route target assignment via REST API +* [#5558](https://github.com/netbox-community/netbox/issues/5558) - Fix regex validation support for custom URL fields +* [#5563](https://github.com/netbox-community/netbox/issues/5563) - Fix power feed cable trace link +* [#5564](https://github.com/netbox-community/netbox/issues/5564) - Raise validation error if a power port template's `allocated_draw` exceeds its `maximum_draw` +* [#5569](https://github.com/netbox-community/netbox/issues/5569) - Ensure consistent labeling of interface `mgmt_only` field +* [#5573](https://github.com/netbox-community/netbox/issues/5573) - Report inconsistent values when migrating custom field data + +--- + +## v2.10.2 (2020-12-21) + +### Enhancements + +* [#5489](https://github.com/netbox-community/netbox/issues/5489) - Add filters for type and width to racks list +* [#5496](https://github.com/netbox-community/netbox/issues/5496) - Add form field to filter rack reservation by user + +### Bug Fixes + +* [#5254](https://github.com/netbox-community/netbox/issues/5254) - Require plugin authors to set zip_safe=False +* [#5468](https://github.com/netbox-community/netbox/issues/5468) - Fix unlocking secrets from device/VM view +* [#5473](https://github.com/netbox-community/netbox/issues/5473) - Fix alignment of rack names in elevations list +* [#5478](https://github.com/netbox-community/netbox/issues/5478) - Fix display of route target description +* [#5484](https://github.com/netbox-community/netbox/issues/5484) - Fix "tagged" indication in VLAN members list +* [#5486](https://github.com/netbox-community/netbox/issues/5486) - Optimize retrieval of config context data for device/VM REST API views +* [#5487](https://github.com/netbox-community/netbox/issues/5487) - Support filtering rack type/width with multiple values +* [#5488](https://github.com/netbox-community/netbox/issues/5488) - Fix caching error when viewing cable trace after toggling cable status +* [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username +* [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex +* [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets +* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields + +--- + +## v2.10.1 (2020-12-15) + +### Bug Fixes + +* [#5444](https://github.com/netbox-community/netbox/issues/5444) - Don't force overwriting of boolean fields when bulk editing interfaces +* [#5450](https://github.com/netbox-community/netbox/issues/5450) - API serializer foreign count fields do not have a default value +* [#5453](https://github.com/netbox-community/netbox/issues/5453) - Correct change log representation when creating a cable +* [#5458](https://github.com/netbox-community/netbox/issues/5458) - Creating a component template throws an exception +* [#5461](https://github.com/netbox-community/netbox/issues/5461) - Rack Elevations throw reverse match exception +* [#5463](https://github.com/netbox-community/netbox/issues/5463) - Back-to-back Circuit Termination throws AttributeError exception +* [#5465](https://github.com/netbox-community/netbox/issues/5465) - Correct return URL when disconnecting a cable from a device +* [#5466](https://github.com/netbox-community/netbox/issues/5466) - Fix validation for required custom fields +* [#5470](https://github.com/netbox-community/netbox/issues/5470) - Fix exception when making `OPTIONS` request for a REST API list endpoint + +--- + ## v2.10.0 (2020-12-14) **NOTE:** This release completely removes support for embedded graphs. diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md new file mode 100644 index 000000000..edd58a0e8 --- /dev/null +++ b/docs/release-notes/version-2.11.md @@ -0,0 +1,160 @@ +# NetBox v2.11 + +## v2.11-beta1 (FUTURE) + +**WARNING:** This is a beta release and is not suitable for production use. It is intended for development and evaluation purposes only. No upgrade path to the final v2.11 release will be provided from this beta, and users should assume that all data entered into the application will be lost. + +**Note:** NetBox v2.11 is the last major release that will support Python 3.6. Beginning with NetBox v2.12, Python 3.7 or later will be required. + +### Breaking Changes + +* All objects now use numeric IDs in their UI view URLs instead of slugs. You may need to update external references to NetBox objects. (Note that this does _not_ affect the REST API.) +* The UI now uses numeric IDs when filtering object lists. You may need to update external links to filtered object lists. (Note that the slug- and name-based filters will continue to work, however the filter selection fields within the UI will not be automatically populated.) +* The RackGroup model has been renamed to Location (see [#4971](https://github.com/netbox-community/netbox/issues/4971)). Its REST API endpoint has changed from `/api/dcim/rack-groups/` to `/api/dcim/locations/`. +* The foreign key field `group` on dcim.Rack has been renamed to `location`. +* The foreign key field `site` on ipam.VLANGroup has been replaced with the `scope` generic foreign key (see [#5284](https://github.com/netbox-community/netbox/issues/5284)). +* Custom script ObjectVars no longer support the `queryset` parameter: Use `model` instead (see [#5995](https://github.com/netbox-community/netbox/issues/5995)). + +### New Features + +#### Journaling Support ([#151](https://github.com/netbox-community/netbox/issues/151)) + +NetBox now supports journaling for all primary objects. The journal is a collection of human-generated notes and comments about an object maintained for historical context. It supplements NetBox's change log to provide additional information about why changes have been made or to convey events which occur outside NetBox. Unlike the change log, in which records typically expire after some time, journal entries persist for the life of the associated object. + +#### Parent Interface Assignments ([#1519](https://github.com/netbox-community/netbox/issues/1519)) + +Virtual interfaces can now be assigned to a "parent" physical interface by setting the `parent` field on the interface object. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 as children of the physical interface Gi0/0. + +#### Pre- and Post-Change Snapshots in Webhooks ([#3451](https://github.com/netbox-community/netbox/issues/3451)) + +In conjunction with the newly improved change logging functionality ([#5913](https://github.com/netbox-community/netbox/issues/5913)), outgoing webhooks now include both pre- and post-change representations of the modified object. These are available in the rendering context as a dictionary named `snapshots` with keys `prechange` and `postchange`. For example, here are the abridged snapshots resulting from renaming a site and changing its status: + +```json +"snapshots": { + "prechange": { + "name": "Site 1", + "slug": "site-1", + "status": "active", + ... + }, + "postchange": { + "name": "Site 2", + "slug": "site-2", + "status": "planned", + ... + } +} +``` + +Note: The pre-change snapshot for a newly created will always be null, as will the post-change snapshot for a deleted object. + +#### Mark as Connected Without a Cable ([#3648](https://github.com/netbox-community/netbox/issues/3648)) + +Cable termination objects (circuit terminations, power feeds, and most device components) can now be marked as "connected" without actually attaching a cable. This helps simplify the process of modeling an infrastructure boundary where we don't necessarily know or care what is connected to an attachment point, but still need to reflect the termination as being occupied. + +In addition to the new `mark_connected` boolean field, the REST API representation of these objects now also includes a read-only boolean field named `_occupied`. This conveniently returns true if either a cable is attached or `mark_connected` is true. + +#### Allow Assigning Devices to Locations ([#4971](https://github.com/netbox-community/netbox/issues/4971)) + +Devices can now be assigned to locations (formerly known as rack groups) within a site without needing to be assigned to a particular rack. This is handy for assigning devices to rooms or floors within a building where racks are not used. The `location` foreign key field has been added to the Device model to support this. + +#### Dynamic Object Exports ([#4999](https://github.com/netbox-community/netbox/issues/4999)) + +When exporting a list of objects in NetBox, users now have the option of selecting the "current view". This will render CSV output matching the current configuration of the table being viewed. For example, if you modify the sites list to display only the site name, tenant, and status, the rendered CSV will include only these columns, and they will appear in the order chosen. + +The legacy static export behavior has been retained to ensure backward compatibility for dependent integrations. However, users are strongly encouraged to adapt custom export templates where needed as this functionality will be removed in v2.12. + +#### Variable Scope Support for VLAN Groups ([#5284](https://github.com/netbox-community/netbox/issues/5284)) + +In previous releases, VLAN groups could be assigned only to a site. To afford more flexibility in conveying the true scope of an L2 domain, a VLAN group can now be assigned to a region, site group (new in v2.11), site, location, or rack. VLANs assigned to a group will be available only to devices and virtual machines which exist within its scope. + +For example, a VLAN within a group assigned to a location will be available only to devices assigned to that location (or one of its child locations), or to a rack within that location. + +#### New Site Group Model ([#5892](https://github.com/netbox-community/netbox/issues/5892)) + +This release introduces the new SiteGroup model, which can be used to organize sites similar to the existing Region model. Whereas regions are intended for geographically arranging sites into countries, states, and so on, the new site group model can be used to organize sites by functional role or other arbitrary classification. Using regions and site groups in conjunction provides two dimensions along which sites can be organized, offering greater flexibility to the user. + +#### Improved Change Logging ([#5913](https://github.com/netbox-community/netbox/issues/5913)) + +The ObjectChange model (which is used to record the creation, modification, and deletion of NetBox objects) now explicitly records the pre-change and post-change state of each object, rather than only the post-change state. This was done to present a more clear depiction of each change being made, and to prevent the erroneous association of a previous unlogged change with its successor. + +#### Cloud Modeling for Circuits ([#5986](https://github.com/netbox-community/netbox/issues/5986)) + +A new Cloud model has been introduced to represent the boundary of a network that exists outside the scope of NetBox. This is analogous to using a cloud icon on a topology drawing to represent an abstracted network. Each cloud must be assigned to a provider, and circuits can terminate to either clouds or sites. The use of this model will likely be extended by future releases to support overlay and virtual circuit modeling. + +### Enhancements + +* [#4833](https://github.com/netbox-community/netbox/issues/4833) - Allow assigning config contexts by device type +* [#5370](https://github.com/netbox-community/netbox/issues/5370) - Extend custom field support to organizational models +* [#5375](https://github.com/netbox-community/netbox/issues/5375) - Add `speed` attribute to console port models +* [#5401](https://github.com/netbox-community/netbox/issues/5401) - Extend custom field support to device component models +* [#5425](https://github.com/netbox-community/netbox/issues/5425) - Create separate tabs for VMs and devices under the cluster view +* [#5451](https://github.com/netbox-community/netbox/issues/5451) - Add support for multiple-selection custom fields +* [#5608](https://github.com/netbox-community/netbox/issues/5608) - Add REST API endpoint for custom links +* [#5610](https://github.com/netbox-community/netbox/issues/5610) - Add REST API endpoint for webhooks +* [#5891](https://github.com/netbox-community/netbox/issues/5891) - Add `display` field to all REST API serializers +* [#5894](https://github.com/netbox-community/netbox/issues/5894) - Use primary keys when filtering object lists by related objects in the UI +* [#5895](https://github.com/netbox-community/netbox/issues/5895) - Rename RackGroup to Location +* [#5901](https://github.com/netbox-community/netbox/issues/5901) - Add `created` and `last_updated` fields to device component models +* [#5971](https://github.com/netbox-community/netbox/issues/5971) - Add dedicated views for organizational models +* [#5972](https://github.com/netbox-community/netbox/issues/5972) - Enable bulk editing for organizational models +* [#5975](https://github.com/netbox-community/netbox/issues/5975) - Allow partial (decimal) vCPU allocations for virtual machines +* [#6038](https://github.com/netbox-community/netbox/issues/6038) - Include tagged objects list on tag view + +### Other Changes + +* [#1638](https://github.com/netbox-community/netbox/issues/1638) - Migrate all primary keys to 64-bit integers +* [#5873](https://github.com/netbox-community/netbox/issues/5873) - Use numeric IDs in all object URLs +* [#5938](https://github.com/netbox-community/netbox/issues/5938) - Deprecated support for Python 3.6 +* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields +* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) +* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view + +### REST API Changes + +* All primary keys are now 64-bit integers +* All model serializers now include a `display` field to be used for the presentation of an object to a human user +* All device components + * Added support for custom fields + * Added `created` and `last_updated` fields to track object creation and modification +* All device component templates + * Added `created` and `last_updated` fields to track object creation and modification +* All organizational models + * Added support for custom fields +* All cable termination models (cabled device components, power feeds, and circuit terminations) + * Added `mark_connected` boolean field to force connection status + * Added `_occupied` read-only boolean field as common attribute for determining whether an object is occupied +* Renamed RackGroup to Location + * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` +* circuits.CircuitTermination + * Added the `cloud` field +* circuits.Cloud + * Added the `/api/circuits/clouds/` endpoint +* dcim.Device + * Added the `location` field +* dcim.Interface + * Added the `parent` field +* dcim.PowerPanel + * Renamed `rack_group` field to `location` +* dcim.Rack + * Renamed `group` field to `location` +* dcim.Site + * Added the `group` foreign key field to SiteGroup +* dcim.SiteGroup + * Added the `/api/dcim/site-groups/` endpoint +* extras.ConfigContext + * Added the `site_groups` many-to-many field to track the assignment of ConfigContexts to SiteGroups +* extras.CustomField + * Added new custom field type: `multi-select` +* extras.CustomLink + * Added the `/api/extras/custom-links/` endpoint +* extras.ObjectChange + * Added the `prechange_data` field + * Renamed `object_data` to `postchange_data` +* extras.Webhook + * Added the `/api/extras/webhooks/` endpoint +* ipam.VLANGroup + * Added the `scope_type`, `scope_id`, and `scope` fields (`scope` is a generic foreign key) + * Dropped the `site` foreign key field +* virtualization.VirtualMachine + * `vcpus` has been changed from an integer to a decimal value diff --git a/docs/release-notes/version-2.2.md b/docs/release-notes/version-2.2.md index 905b7a8d1..e13c4fe69 100644 --- a/docs/release-notes/version-2.2.md +++ b/docs/release-notes/version-2.2.md @@ -196,7 +196,7 @@ Our second-most popular feature request has arrived! NetBox now supports the cre #### Custom Validation Reports ([#1511](https://github.com/netbox-community/netbox/issues/1511)) -Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](http://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. +Users can now create custom reports which are run to validate data in NetBox. Reports work very similar to Python unit tests: Each report inherits from NetBox's Report class and contains one or more test method. Reports can be run and retrieved via the web UI, API, or CLI. See [the docs](https://netbox.readthedocs.io/en/stable/miscellaneous/reports/) for more info. ### Enhancements diff --git a/docs/requirements.txt b/docs/requirements.txt index 9c863fec7..18b55d37b 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,2 +1,2 @@ -mkdocs==1.1 +mkdocs-material git+https://github.com/cmacmackin/markdown-include.git diff --git a/docs/rest-api/authentication.md b/docs/rest-api/authentication.md index 5d5777483..7fb789e0f 100644 --- a/docs/rest-api/authentication.md +++ b/docs/rest-api/authentication.md @@ -20,7 +20,7 @@ http://netbox/api/dcim/sites/ } ``` -A token is not required for read-only operations which have been exempted from permissions enforcement (using the [`EXEMPT_VIEW_PERMISSIONS`](../../configuration/optional-settings/#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: +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 http://netbox/api/dcim/sites/ diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index 1ec6752a4..b77513297 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -78,8 +78,8 @@ String based (char) fields (Name, Address, etc) support these lookup expressions - `nisw` - negated case insensitive starts with - `iew` - case insensitive ends with - `niew` - negated case insensitive ends with -- `ie` - case sensitive exact match -- `nie` - negated case sensitive exact match +- `ie` - case insensitive exact match +- `nie` - negated case insensitive exact match ### Foreign Keys & Other Fields diff --git a/docs/rest-api/overview.md b/docs/rest-api/overview.md index a3c8143eb..735e0713b 100644 --- a/docs/rest-api/overview.md +++ b/docs/rest-api/overview.md @@ -2,7 +2,7 @@ ## What is a REST API? -REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](http://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: +REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb: * `GET`: Retrieve an object or list of objects * `POST`: Create an object @@ -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/) 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 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. ## Pagination @@ -308,7 +308,7 @@ Vary: Accept } ``` -The default page is determined by the [`PAGINATE_COUNT`](../../configuration/optional-settings/#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/optional-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: ``` 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/optional-settings/#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/optional-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. !!! 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/) 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 documentation](../authentication/index.md) for more information. Also be sure to set the `Content-Type` HTTP header to `application/json`. ```no-highlight curl -s -X POST \ diff --git a/docs/rest-api/working-with-secrets.md b/docs/rest-api/working-with-secrets.md index dafbb7239..5fbbf7355 100644 --- a/docs/rest-api/working-with-secrets.md +++ b/docs/rest-api/working-with-secrets.md @@ -4,7 +4,7 @@ As with most other objects, the REST API can be used to view, create, modify, an ## Generating a Session Key -In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../../core-functionality/secrets/#user-keys). The private key must be POSTed with the name `private_key`. +In order to encrypt or decrypt secret data, a session key must be attached to the API request. To generate a session key, send an authenticated request to the `/api/secrets/get-session-key/` endpoint with the private RSA key which matches your [UserKey](../core-functionality/secrets.md#user-keys). The private key must be POSTed with the name `private_key`. ```no-highlight $ curl -X POST http://netbox/api/secrets/get-session-key/ \ diff --git a/mkdocs.yml b/mkdocs.yml index 092cb559a..3cc502c1d 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,14 +5,15 @@ python: install: - requirements: docs/requirements.txt theme: - name: readthedocs - navigation_depth: 3 + name: material extra_css: - extra.css markdown_extensions: - - admonition: + - admonition - markdown_include.include: headingOffset: 1 + - pymdownx.superfences + - pymdownx.tabbed nav: - Introduction: 'index.md' - Installation: @@ -49,6 +50,7 @@ nav: - Custom Links: 'additional-features/custom-links.md' - Custom Scripts: 'additional-features/custom-scripts.md' - Export Templates: 'additional-features/export-templates.md' + - Journaling: 'additional-features/journaling.md' - NAPALM: 'additional-features/napalm.md' - Prometheus Metrics: 'additional-features/prometheus-metrics.md' - Reports: 'additional-features/reports.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 2d3457d2c..0fd07d31b 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -1,16 +1,29 @@ from rest_framework import serializers -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from netbox.api import WritableNestedSerializer __all__ = [ 'NestedCircuitSerializer', 'NestedCircuitTerminationSerializer', 'NestedCircuitTypeSerializer', + 'NestedCloudSerializer', 'NestedProviderSerializer', ] +# +# Clouds +# + +class NestedCloudSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') + + class Meta: + model = Provider + fields = ['id', 'url', 'display', 'name'] + + # # Providers # @@ -21,7 +34,7 @@ class NestedProviderSerializer(WritableNestedSerializer): class Meta: model = Provider - fields = ['id', 'url', 'name', 'slug', 'circuit_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count'] # @@ -34,7 +47,7 @@ class NestedCircuitTypeSerializer(WritableNestedSerializer): class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug', 'circuit_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'circuit_count'] class NestedCircuitSerializer(WritableNestedSerializer): @@ -42,7 +55,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class Meta: model = Circuit - fields = ['id', 'url', 'cid'] + fields = ['id', 'url', 'display', 'cid'] class NestedCircuitTerminationSerializer(WritableNestedSerializer): @@ -51,4 +64,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer): class Meta: model = CircuitTermination - fields = ['id', 'url', 'circuit', 'term_side', 'cable'] + fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 88890bf95..5469049db 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,12 +1,13 @@ from rest_framework import serializers from circuits.choices import CircuitStatusChoices -from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.nested_serializers import NestedCableSerializer, NestedInterfaceSerializer, NestedSiteSerializer +from circuits.models import * +from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer -from netbox.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer +from netbox.api import ChoiceField +from netbox.api.serializers import ( + BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer +) from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -15,15 +16,31 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ProviderSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ - 'id', 'url', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', - 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', + 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + ] + + +# +# Clouds +# + +class CloudSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='circuits-api:cloud-detail') + provider = NestedProviderSerializer() + + class Meta: + model = Cloud + fields = [ + 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] @@ -31,26 +48,32 @@ class ProviderSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Circuits # -class CircuitTypeSerializer(ValidatedModelSerializer): +class CircuitTypeSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) class Meta: model = CircuitType - fields = ['id', 'url', 'name', 'slug', 'description', 'circuit_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'circuit_count', + ] -class CircuitCircuitTerminationSerializer(WritableNestedSerializer): +class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') site = NestedSiteSerializer() - connected_endpoint = NestedInterfaceSerializer() + cloud = NestedCloudSerializer() class Meta: model = CircuitTermination - fields = ['id', 'url', 'site', 'connected_endpoint', 'port_speed', 'upstream_speed', 'xconnect_id'] + fields = [ + 'id', 'url', 'display', 'site', 'cloud', 'port_speed', 'upstream_speed', 'xconnect_id', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', + ] -class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CircuitSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) @@ -62,21 +85,23 @@ class CircuitSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Circuit fields = [ - 'id', 'url', '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', 'commit_rate', + 'description', 'termination_a', 'termination_z', 'comments', 'tags', 'custom_fields', 'created', + 'last_updated', ] -class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpointSerializer): +class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') circuit = NestedCircuitSerializer() - site = NestedSiteSerializer() + site = NestedSiteSerializer(required=False) + cloud = NestedCloudSerializer(required=False) cable = NestedCableSerializer(read_only=True) class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable' + 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'cloud', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', ] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index b496796fe..4f31806bd 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -13,5 +13,8 @@ router.register('circuit-types', views.CircuitTypeViewSet) router.register('circuits', views.CircuitViewSet) router.register('circuit-terminations', views.CircuitTerminationViewSet) +# Clouds +router.register('clouds', views.CloudViewSet) + app_name = 'circuits-api' urlpatterns = router.urls diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index ad497ee5f..0adbfcb0e 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -2,11 +2,11 @@ from django.db.models import Prefetch from rest_framework.routers import APIRootView from circuits import filters -from circuits.models import Provider, CircuitTermination, CircuitType, Circuit +from circuits.models import * from dcim.api.views import PathEndpointMixin from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet -from utilities.utils import get_subquery +from utilities.utils import count_related from . import serializers @@ -24,7 +24,7 @@ class CircuitsRootView(APIRootView): class ProviderViewSet(CustomFieldModelViewSet): queryset = Provider.objects.prefetch_related('tags').annotate( - circuit_count=get_subquery(Circuit, 'provider') + circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer filterset_class = filters.ProviderFilterSet @@ -34,9 +34,9 @@ class ProviderViewSet(CustomFieldModelViewSet): # Circuit Types # -class CircuitTypeViewSet(ModelViewSet): +class CircuitTypeViewSet(CustomFieldModelViewSet): queryset = CircuitType.objects.annotate( - circuit_count=get_subquery(Circuit, 'type') + circuit_count=count_related(Circuit, 'type') ) serializer_class = serializers.CircuitTypeSerializer filterset_class = filters.CircuitTypeFilterSet @@ -48,8 +48,7 @@ class CircuitTypeViewSet(ModelViewSet): class CircuitViewSet(CustomFieldModelViewSet): queryset = Circuit.objects.prefetch_related( - Prefetch('terminations', queryset=CircuitTermination.objects.prefetch_related('site')), - 'type', 'tenant', 'provider', + 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') serializer_class = serializers.CircuitSerializer filterset_class = filters.CircuitFilterSet @@ -65,3 +64,14 @@ class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): ) serializer_class = serializers.CircuitTerminationSerializer filterset_class = filters.CircuitTerminationFilterSet + brief_prefetch_fields = ['circuit'] + + +# +# Clouds +# + +class CloudViewSet(CustomFieldModelViewSet): + queryset = Cloud.objects.prefetch_related('tags') + serializer_class = serializers.CloudSerializer + filterset_class = filters.CloudFilterSet diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index fa563881c..0efd2f331 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -2,19 +2,20 @@ import django_filters from django.db.models import Q from dcim.filters import CableTerminationFilterSet, PathEndpointFilterSet -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( BaseFilterSet, NameSlugSearchFilterSet, TagFilter, TreeNodeMultipleChoiceFilter ) from .choices import * -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * __all__ = ( 'CircuitFilterSet', 'CircuitTerminationFilterSet', 'CircuitTypeFilterSet', + 'CloudFilterSet', 'ProviderFilterSet', ) @@ -37,6 +38,19 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='circuits__terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), @@ -66,6 +80,36 @@ class ProviderFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdated ) +class CloudFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + provider_id = django_filters.ModelMultipleChoiceFilter( + queryset=Provider.objects.all(), + label='Provider (ID)', + ) + provider = django_filters.ModelMultipleChoiceFilter( + field_name='provider__slug', + queryset=Provider.objects.all(), + to_field_name='slug', + label='Provider (slug)', + ) + tag = TagFilter() + + class Meta: + model = Cloud + fields = ['id', 'name'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(description__icontains=value) | + Q(comments__icontains=value) + ).distinct() + + class CircuitTypeFilterSet(BaseFilterSet, NameSlugSearchFilterSet): class Meta: @@ -88,6 +132,11 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Provider (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__cloud', + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) type_id = django_filters.ModelMultipleChoiceFilter( queryset=CircuitType.objects.all(), label='Circuit type (ID)', @@ -102,17 +151,6 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe choices=CircuitStatusChoices, null_value=None ) - site_id = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site', - queryset=Site.objects.all(), - label='Site (ID)', - ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='terminations__site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label='Site (slug)', - ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='terminations__site__region', @@ -126,6 +164,30 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='terminations__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='terminations__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) tag = TagFilter() class Meta: @@ -164,6 +226,10 @@ class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, Path to_field_name='slug', label='Site (slug)', ) + cloud_id = django_filters.ModelMultipleChoiceFilter( + queryset=Cloud.objects.all(), + label='Cloud (ID)', + ) class Meta: model = CircuitTermination diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4731c9adb..d818ec0f6 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,6 +1,7 @@ from django import forms +from django.utils.translation import gettext as _ -from dcim.models import Region, Site +from dcim.models import Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldFilterForm, CustomFieldModelForm, CustomFieldModelCSVForm, ) @@ -8,12 +9,12 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DatePicker, - DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, SlugField, StaticSelect2, - StaticSelect2Multiple, TagFilterField, + add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, CSVModelChoiceField, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, SelectSpeedWidget, SmallTextarea, SlugField, + StaticSelect2, StaticSelect2Multiple, TagFilterField, ) from .choices import CircuitStatusChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -33,6 +34,10 @@ class ProviderForm(BootstrapMixin, CustomFieldModelForm): fields = [ 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', ] + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -101,24 +106,101 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Provider q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) asn = forms.IntegerField( required=False, - label='ASN' + label=_('ASN') + ) + tag = TagFilterField(model) + + +# +# Clouds +# + +class CloudForm(BootstrapMixin, CustomFieldModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all() + ) + comments = CommentField() + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = Cloud + fields = [ + 'provider', 'name', 'description', 'comments', 'tags', + ] + fieldsets = ( + ('Cloud', ('provider', 'name', 'description', 'tags')), + ) + + +class CloudCSVForm(CustomFieldModelCSVForm): + provider = CSVModelChoiceField( + queryset=Provider.objects.all(), + to_field_name='name', + help_text='Assigned provider' + ) + + class Meta: + model = Cloud + fields = [ + 'provider', 'name', 'description', 'comments', + ] + + +class CloudBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Cloud.objects.all(), + widget=forms.MultipleHiddenInput + ) + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False + ) + description = forms.CharField( + max_length=100, + required=False + ) + comments = CommentField( + widget=SmallTextarea, + label='Comments' + ) + + class Meta: + nullable_fields = [ + 'description', 'comments', + ] + + +class CloudFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Cloud + field_order = ['q', 'provider_id'] + q = forms.CharField( + required=False, + label=_('Search') + ) + provider_id = DynamicModelMultipleChoiceField( + queryset=Provider.objects.all(), + required=False, + label=_('Provider') ) tag = TagFilterField(model) @@ -127,7 +209,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit types # -class CircuitTypeForm(BootstrapMixin, forms.ModelForm): +class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -137,7 +219,21 @@ class CircuitTypeForm(BootstrapMixin, forms.ModelForm): ] -class CircuitTypeCSVForm(CSVModelForm): +class CircuitTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=CircuitType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + +class CircuitTypeCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -171,6 +267,10 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", @@ -178,6 +278,7 @@ class CircuitForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): widgets = { 'status': StaticSelect2(), 'install_date': DatePicker(), + 'commit_rate': SelectSpeedWidget(), } @@ -256,44 +357,53 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit field_order = [ - 'q', 'type', 'provider', 'status', 'region', 'site', 'tenant_group', 'tenant', 'commit_rate', + 'q', 'type_id', 'provider_id', 'cloud_id', 'status', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id', + 'commit_rate', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - type = DynamicModelMultipleChoiceField( + type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Type') ) - provider = DynamicModelMultipleChoiceField( + provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Provider') + ) + cloud_id = DynamicModelMultipleChoiceField( + queryset=Cloud.objects.all(), + required=False, + query_params={ + 'provider_id': '$provider_id' + }, + label=_('Cloud') ) status = forms.MultipleChoiceField( choices=CircuitStatusChoices, required=False, widget=StaticSelect2Multiple() ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) commit_rate = forms.IntegerField( required=False, min_value=0, - label='Commit rate (Kbps)' + label=_('Commit rate (Kbps)') ) tag = TagFilterField(model) @@ -310,17 +420,31 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' - } + 'region_id': '$region', + 'group_id': '$site_group', + }, + required=False + ) + cloud = DynamicModelChoiceField( + queryset=Cloud.objects.all(), + required=False ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'term_side', 'region', 'site_group', 'site', 'cloud', 'mark_connected', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -329,4 +453,11 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): } widgets = { 'term_side': forms.HiddenInput(), + 'port_speed': SelectSpeedWidget(), + 'upstream_speed': SelectSpeedWidget(), } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.fields['cloud'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py new file mode 100644 index 000000000..42745f35b --- /dev/null +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -0,0 +1,47 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0024_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='circuittype', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AddField( + model_name='circuittermination', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='circuittermination', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0026_mark_connected.py b/netbox/circuits/migrations/0026_mark_connected.py new file mode 100644 index 000000000..8531e9715 --- /dev/null +++ b/netbox/circuits/migrations/0026_mark_connected.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0025_standardize_models'), + ] + + operations = [ + migrations.AddField( + model_name='circuittermination', + name='mark_connected', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/circuits/migrations/0027_cloud.py b/netbox/circuits/migrations/0027_cloud.py new file mode 100644 index 000000000..893371f8f --- /dev/null +++ b/netbox/circuits/migrations/0027_cloud.py @@ -0,0 +1,65 @@ +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', '0058_journalentry'), + ('circuits', '0026_mark_connected'), + ] + + operations = [ + # Create the new Cloud model + migrations.CreateModel( + name='Cloud', + fields=[ + ('created', models.DateField(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)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('description', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='clouds', to='circuits.provider')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('provider', 'name'), + }, + ), + migrations.AddConstraint( + model_name='cloud', + constraint=models.UniqueConstraint(fields=('provider', 'name'), name='circuits_cloud_provider_name'), + ), + migrations.AlterUniqueTogether( + name='cloud', + unique_together={('provider', 'name')}, + ), + + # Add cloud FK to CircuitTermination + migrations.AddField( + model_name='circuittermination', + name='cloud', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='circuits.cloud'), + ), + migrations.AlterField( + model_name='circuittermination', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', to='dcim.site'), + ), + + # Add FKs to CircuitTermination on Circuit + migrations.AddField( + model_name='circuit', + name='termination_a', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + migrations.AddField( + model_name='circuit', + name='termination_z', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='circuits.circuittermination'), + ), + ] diff --git a/netbox/circuits/migrations/0028_cache_circuit_terminations.py b/netbox/circuits/migrations/0028_cache_circuit_terminations.py new file mode 100644 index 000000000..49631da07 --- /dev/null +++ b/netbox/circuits/migrations/0028_cache_circuit_terminations.py @@ -0,0 +1,37 @@ +import sys + +from django.db import migrations + + +def cache_circuit_terminations(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + if 'test' not in sys.argv: + print(f"\n Caching circuit terminations...", flush=True) + + a_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='A') + } + z_terminations = { + ct.circuit_id: ct.pk for ct in CircuitTermination.objects.filter(term_side='Z') + } + for circuit in Circuit.objects.all(): + Circuit.objects.filter(pk=circuit.pk).update( + termination_a_id=a_terminations.get(circuit.pk), + termination_z_id=z_terminations.get(circuit.pk), + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0027_cloud'), + ] + + operations = [ + migrations.RunPython( + code=cache_circuit_terminations, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 3d6d5d232..b163834e6 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,27 +1,27 @@ +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.fields import ASNField from dcim.models import CableTermination, PathEndpoint -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from extras.models import ObjectChange from extras.utils import extras_features +from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from .choices import * -from .querysets import CircuitQuerySet __all__ = ( 'Circuit', 'CircuitTermination', 'CircuitType', + 'Cloud', 'Provider', ) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Provider(ChangeLoggedModel, CustomFieldModel): +class Provider(PrimaryModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -60,7 +60,6 @@ class Provider(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -78,7 +77,7 @@ class Provider(ChangeLoggedModel, CustomFieldModel): return self.name def get_absolute_url(self): - return reverse('circuits:provider', args=[self.slug]) + return reverse('circuits:provider', args=[self.pk]) def to_csv(self): return ( @@ -93,7 +92,61 @@ class Provider(ChangeLoggedModel, CustomFieldModel): ) -class CircuitType(ChangeLoggedModel): +# +# Clouds +# + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Cloud(PrimaryModel): + name = models.CharField( + max_length=100 + ) + provider = models.ForeignKey( + to='circuits.Provider', + on_delete=models.PROTECT, + related_name='clouds' + ) + description = models.CharField( + max_length=200, + blank=True + ) + comments = models.TextField( + blank=True + ) + + csv_headers = [ + 'provider', 'name', 'description', 'comments', + ] + + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ('provider', 'name') + constraints = ( + models.UniqueConstraint( + fields=('provider', 'name'), + name='circuits_cloud_provider_name' + ), + ) + unique_together = ('provider', 'name') + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:cloud', args=[self.pk]) + + def to_csv(self): + return ( + self.provider.name, + self.name, + self.description, + self.comments, + ) + + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named "Long Haul," "Metro," or "Out-of-Band". @@ -122,7 +175,7 @@ class CircuitType(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug) + return reverse('circuits:circuittype', args=[self.pk]) def to_csv(self): return ( @@ -133,7 +186,7 @@ class CircuitType(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Circuit(ChangeLoggedModel, CustomFieldModel): +class Circuit(PrimaryModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -182,8 +235,25 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): blank=True ) - objects = CircuitQuerySet.as_manager() - tags = TaggableManager(through=TaggedItem) + # Cache associated CircuitTerminations + termination_a = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + termination_z = models.ForeignKey( + to='circuits.CircuitTermination', + on_delete=models.SET_NULL, + related_name='+', + editable=False, + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -218,22 +288,9 @@ class Circuit(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return CircuitStatusChoices.CSS_CLASSES.get(self.status) - def _get_termination(self, side): - for ct in self.terminations.all(): - if ct.term_side == side: - return ct - return None - @property - def termination_a(self): - return self._get_termination('A') - - @property - def termination_z(self): - return self._get_termination('Z') - - -class CircuitTermination(PathEndpoint, CableTermination): +@extras_features('webhooks') +class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -247,7 +304,16 @@ class CircuitTermination(PathEndpoint, CableTermination): site = models.ForeignKey( to='dcim.Site', on_delete=models.PROTECT, - related_name='circuit_terminations' + related_name='circuit_terminations', + blank=True, + null=True + ) + cloud = models.ForeignKey( + to=Cloud, + on_delete=models.PROTECT, + related_name='circuit_terminations', + blank=True, + null=True ) port_speed = models.PositiveIntegerField( verbose_name='Port speed (Kbps)', @@ -282,26 +348,35 @@ class CircuitTermination(PathEndpoint, CableTermination): unique_together = ['circuit', 'term_side'] def __str__(self): - return 'Side {}'.format(self.get_term_side_display()) + if self.site: + return str(self.site) + return str(self.cloud) + + def get_absolute_url(self): + if self.site: + return self.site.get_absolute_url() + return self.cloud.get_absolute_url() + + def clean(self): + super().clean() + + # Must define either site *or* cloud + if self.site is None and self.cloud is None: + raise ValidationError("A circuit termination must attach to either a site or a cloud.") + if self.site and self.cloud: + raise ValidationError("A circuit termination cannot attach to both a site and a cloud.") def to_objectchange(self, action): # Annotate the parent Circuit try: - related_object = self.circuit + circuit = self.circuit except Circuit.DoesNotExist: # Parent circuit has been deleted - related_object = None - - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=related_object, - object_data=serialize_object(self) - ) + circuit = None + return super().to_objectchange(action, related_object=circuit) @property - def parent(self): + def parent_object(self): return self.circuit def get_peer_termination(self): diff --git a/netbox/circuits/querysets.py b/netbox/circuits/querysets.py deleted file mode 100644 index 8a9bd50a4..000000000 --- a/netbox/circuits/querysets.py +++ /dev/null @@ -1,17 +0,0 @@ -from django.db.models import OuterRef, Subquery - -from utilities.querysets import RestrictedQuerySet - - -class CircuitQuerySet(RestrictedQuerySet): - - def annotate_sites(self): - """ - Annotate the A and Z termination site names for ordering. - """ - from circuits.models import CircuitTermination - _terminations = CircuitTermination.objects.filter(circuit=OuterRef('pk')) - return self.annotate( - a_side=Subquery(_terminations.filter(term_side='A').values('site__name')[:1]), - z_side=Subquery(_terminations.filter(term_side='Z').values('site__name')[:1]), - ) diff --git a/netbox/circuits/signals.py b/netbox/circuits/signals.py index 86db21400..7c9832d5b 100644 --- a/netbox/circuits/signals.py +++ b/netbox/circuits/signals.py @@ -1,17 +1,17 @@ -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone from .models import Circuit, CircuitTermination -@receiver((post_save, post_delete), sender=CircuitTermination) +@receiver(post_save, sender=CircuitTermination) def update_circuit(instance, **kwargs): """ - When a CircuitTermination has been modified, update the last_updated time of its parent Circuit. + When a CircuitTermination has been modified, update its parent Circuit. """ - circuits = Circuit.objects.filter(pk=instance.circuit_id) - time = timezone.now() - for circuit in circuits: - circuit.last_updated = time - circuit.save() + fields = { + 'last_updated': timezone.now(), + f'termination_{instance.term_side.lower()}': instance.pk, + } + Circuit.objects.filter(pk=instance.circuit_id).update(**fields) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 782b02394..ba113de8c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,9 +1,9 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .models import Circuit, CircuitType, Provider +from .models import * # @@ -29,6 +29,28 @@ class ProviderTable(BaseTable): default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') +# +# Clouds +# + +class CloudTable(BaseTable): + pk = ToggleColumn() + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + tags = TagColumn( + url_name='circuits:cloud_list' + ) + + class Meta(BaseTable.Meta): + model = Cloud + fields = ('pk', 'name', 'provider', 'description', 'tags') + default_columns = ('pk', 'name', 'provider', 'description') + + # # Circuit types # @@ -39,7 +61,7 @@ class CircuitTypeTable(BaseTable): circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType, pk_field='slug') + actions = ButtonsColumn(CircuitType) class Meta(BaseTable.Meta): model = CircuitType @@ -56,19 +78,18 @@ class CircuitTable(BaseTable): cid = tables.LinkColumn( verbose_name='ID' ) - provider = tables.LinkColumn( - viewname='circuits:provider', - args=[Accessor('provider__slug')] + provider = tables.Column( + linkify=True ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT + tenant = TenantColumn() + termination_a = tables.Column( + linkify=True, + verbose_name='Side A' ) - a_side = tables.Column( - verbose_name='A Side' - ) - z_side = tables.Column( - verbose_name='Z Side' + termination_z = tables.Column( + linkify=True, + verbose_name='Side Z' ) tags = TagColumn( url_name='circuits:circuit_list' @@ -77,7 +98,9 @@ class CircuitTable(BaseTable): class Meta(BaseTable.Meta): model = Circuit fields = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'install_date', 'commit_rate', - 'description', 'tags', + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'tags', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', ) - default_columns = ('pk', 'cid', 'provider', 'type', 'status', 'tenant', 'a_side', 'z_side', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 6df931553..01e228f76 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -1,7 +1,7 @@ from django.urls import reverse from circuits.choices import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from circuits.models import * from dcim.models import Site from utilities.testing import APITestCase, APIViewTestCases @@ -17,7 +17,7 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider - brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Provider 4', @@ -49,7 +49,7 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType - brief_fields = ['circuit_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] create_data = ( { 'name': 'Circuit Type 4', @@ -81,7 +81,7 @@ class CircuitTypeTest(APIViewTestCases.APIViewTestCase): class CircuitTest(APIViewTestCases.APIViewTestCase): model = Circuit - brief_fields = ['cid', 'id', 'url'] + brief_fields = ['cid', 'display', 'id', 'url'] bulk_update_data = { 'status': 'planned', } @@ -129,7 +129,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase): class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): model = CircuitTermination - brief_fields = ['cable', 'circuit', 'id', 'term_side', 'url'] + brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] @classmethod def setUpTestData(cls): @@ -178,3 +178,43 @@ class CircuitTerminationTest(APIViewTestCases.APIViewTestCase): cls.bulk_update_data = { 'port_speed': 123456 } + + +class CloudTest(APIViewTestCases.APIViewTestCase): + model = Cloud + brief_fields = ['display', 'id', 'name', 'url'] + + @classmethod + def setUpTestData(cls): + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ) + Cloud.objects.bulk_create(clouds) + + cls.create_data = [ + { + 'name': 'Cloud 4', + 'provider': providers[0].pk, + }, + { + 'name': 'Cloud 5', + 'provider': providers[0].pk, + }, + { + 'name': 'Cloud 6', + 'provider': providers[0].pk, + }, + ] + + cls.bulk_update_data = { + 'provider': providers[1].pk, + 'description': 'New description', + } diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 9477bfbac..880139baf 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -2,8 +2,8 @@ from django.test import TestCase from circuits.choices import * from circuits.filters import * -from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Cable, Region, Site +from circuits.models import * +from dcim.models import Cable, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup @@ -27,13 +27,20 @@ class ProviderTestCase(TestCase): Region(name='Test Region 1', slug='test-region-1'), Region(name='Test Region 2', slug='test-region-2'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), + 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.objects.bulk_create(sites) @@ -74,13 +81,6 @@ class ProviderTestCase(TestCase): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'site': [sites[0].slug, sites[1].slug]} - 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]} @@ -88,6 +88,20 @@ class ProviderTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_site(self): + sites = Site.objects.all()[:2] + params = {'site_id': [sites[0].pk, sites[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': [sites[0].slug, sites[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class CircuitTypeTestCase(TestCase): queryset = CircuitType.objects.all() @@ -127,14 +141,21 @@ class CircuitTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + 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.objects.bulk_create(sites) @@ -165,6 +186,13 @@ class CircuitTestCase(TestCase): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[1]), + Cloud(name='Cloud 2', provider=providers[1]), + Cloud(name='Cloud 3', provider=providers[1]), + ) + Cloud.objects.bulk_create(clouds) + 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), 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), @@ -179,6 +207,9 @@ class CircuitTestCase(TestCase): CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -205,6 +236,11 @@ class CircuitTestCase(TestCase): params = {'provider': [provider.slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): circuit_type = CircuitType.objects.first() params = {'type_id': [circuit_type.pk]} @@ -223,6 +259,13 @@ class CircuitTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -253,14 +296,14 @@ class CircuitTerminationTestCase(TestCase): def setUpTestData(cls): 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) circuit_types = ( - CircuitType(name='Test Circuit Type 1', slug='test-circuit-type-1'), + CircuitType(name='Circuit Type 1', slug='circuit-type-1'), ) CircuitType.objects.bulk_create(circuit_types) @@ -269,10 +312,20 @@ class CircuitTerminationTestCase(TestCase): ) Provider.objects.bulk_create(providers) + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ) + Cloud.objects.bulk_create(clouds) + circuits = ( - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 1'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 2'), - Circuit(provider=providers[0], type=circuit_types[0], cid='Test Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), ) Circuit.objects.bulk_create(circuits) @@ -283,6 +336,9 @@ class CircuitTerminationTestCase(TestCase): CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[0], term_side='Z', port_speed=3000, upstream_speed=3000, xconnect_id='PQR'), + CircuitTermination(circuit=circuits[3], cloud=clouds[0], term_side='A'), + CircuitTermination(circuit=circuits[4], cloud=clouds[1], term_side='A'), + CircuitTermination(circuit=circuits[5], cloud=clouds[2], term_side='A'), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -290,7 +346,7 @@ class CircuitTerminationTestCase(TestCase): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -316,6 +372,11 @@ class CircuitTerminationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_cloud(self): + clouds = Cloud.objects.all()[:2] + params = {'cloud_id': [clouds[0].pk, clouds[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -324,4 +385,41 @@ class CircuitTerminationTestCase(TestCase): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + + +class CloudTestCase(TestCase): + queryset = Cloud.objects.all() + filterset = CloudFilterSet + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + Provider(name='Provider 3', slug='provider-3'), + ) + Provider.objects.bulk_create(providers) + + clouds = ( + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[1]), + Cloud(name='Cloud 3', provider=providers[2]), + ) + Cloud.objects.bulk_create(clouds) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Cloud 1', 'Cloud 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_provider(self): + providers = Provider.objects.all()[:2] + params = {'provider_id': [providers[0].pk, providers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'provider': [providers[0].slug, providers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 3356fca8f..ba2d4fc22 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -1,7 +1,7 @@ import datetime from circuits.choices import * -from circuits.models import Circuit, CircuitType, Provider +from circuits.models import * from utilities.testing import ViewTestCases @@ -73,6 +73,10 @@ class CircuitTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Circuit Type 6,circuit-type-6", ) + cls.bulk_edit_data = { + 'description': 'Foo', + } + class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Circuit @@ -129,3 +133,45 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', 'comments': 'New comments', } + + +class CloudTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = Cloud + + @classmethod + def setUpTestData(cls): + + providers = ( + Provider(name='Provider 1', slug='provider-1'), + Provider(name='Provider 2', slug='provider-2'), + ) + Provider.objects.bulk_create(providers) + + Cloud.objects.bulk_create([ + Cloud(name='Cloud 1', provider=providers[0]), + Cloud(name='Cloud 2', provider=providers[0]), + Cloud(name='Cloud 3', provider=providers[0]), + ]) + + tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Cloud X', + 'provider': providers[1].pk, + 'description': 'A new cloud', + 'comments': 'Longer description goes here', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,provider,description", + "Cloud 4,Provider 1,Foo", + "Cloud 5,Provider 1,Bar", + "Cloud 6,Provider 1,Baz", + ) + + cls.bulk_edit_data = { + 'provider': providers[1].pk, + 'description': 'New description', + 'comments': 'New comments', + } diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index d757fd90d..58f52bb42 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,9 +1,9 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView from . import views -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * app_name = 'circuits' urlpatterns = [ @@ -14,19 +14,34 @@ urlpatterns = [ path('providers/import/', views.ProviderBulkImportView.as_view(), name='provider_import'), path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), - path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), - path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), - path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//', views.ProviderView.as_view(), name='provider'), + path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), + path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), + path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), + path('providers//journal/', ObjectJournalView.as_view(), name='provider_journal', kwargs={'model': Provider}), + + # Clouds + path('clouds/', views.CloudListView.as_view(), name='cloud_list'), + path('clouds/add/', views.CloudEditView.as_view(), name='cloud_add'), + path('clouds/import/', views.CloudBulkImportView.as_view(), name='cloud_import'), + path('clouds/edit/', views.CloudBulkEditView.as_view(), name='cloud_bulk_edit'), + path('clouds/delete/', views.CloudBulkDeleteView.as_view(), name='cloud_bulk_delete'), + path('clouds//', views.CloudView.as_view(), name='cloud'), + path('clouds//edit/', views.CloudEditView.as_view(), name='cloud_edit'), + path('clouds//delete/', views.CloudDeleteView.as_view(), name='cloud_delete'), + path('clouds//changelog/', ObjectChangeLogView.as_view(), name='cloud_changelog', kwargs={'model': Cloud}), + path('clouds//journal/', ObjectJournalView.as_view(), name='cloud_journal', kwargs={'model': Cloud}), # Circuit types path('circuit-types/', views.CircuitTypeListView.as_view(), name='circuittype_list'), path('circuit-types/add/', views.CircuitTypeEditView.as_view(), name='circuittype_add'), path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'), + path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'), path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'), - path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), - path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), - path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), + path('circuit-types//', views.CircuitTypeView.as_view(), name='circuittype'), + path('circuit-types//edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'), + path('circuit-types//delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'), + path('circuit-types//changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}), # Circuits path('circuits/', views.CircuitListView.as_view(), name='circuit_list'), @@ -38,6 +53,7 @@ urlpatterns = [ path('circuits//edit/', views.CircuitEditView.as_view(), name='circuit_edit'), path('circuits//delete/', views.CircuitDeleteView.as_view(), name='circuit_delete'), path('circuits//changelog/', ObjectChangeLogView.as_view(), name='circuit_changelog', kwargs={'model': Circuit}), + path('circuits//journal/', ObjectJournalView.as_view(), name='circuit_journal', kwargs={'model': Circuit}), path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 653c881a9..6f3c5b2be 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,15 +1,15 @@ from django.contrib import messages from django.db import transaction +from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.utils import get_subquery +from utilities.tables import paginate_table +from utilities.utils import count_related from . import filters, forms, tables from .choices import CircuitTerminationSideChoices -from .models import Circuit, CircuitTermination, CircuitType, Provider +from .models import * # @@ -18,7 +18,7 @@ from .models import Circuit, CircuitTermination, CircuitType, Provider class ProviderListView(generic.ObjectListView): queryset = Provider.objects.annotate( - count_circuits=get_subquery(Circuit, 'provider') + count_circuits=count_related(Circuit, 'provider') ) filterset = filters.ProviderFilterSet filterset_form = forms.ProviderFilterForm @@ -33,16 +33,11 @@ class ProviderView(generic.ObjectView): provider=instance ).prefetch_related( 'type', 'tenant', 'terminations__site' - ).annotate_sites() + ) circuits_table = tables.CircuitTable(circuits) circuits_table.columns.hide('provider') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(circuits_table) + paginate_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -52,7 +47,6 @@ class ProviderView(generic.ObjectView): class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() model_form = forms.ProviderForm - template_name = 'circuits/provider_edit.html' class ProviderDeleteView(generic.ObjectDeleteView): @@ -67,7 +61,7 @@ class ProviderBulkImportView(generic.BulkImportView): class ProviderBulkEditView(generic.BulkEditView): queryset = Provider.objects.annotate( - count_circuits=get_subquery(Circuit, 'provider') + count_circuits=count_related(Circuit, 'provider') ) filterset = filters.ProviderFilterSet table = tables.ProviderTable @@ -76,23 +70,100 @@ class ProviderBulkEditView(generic.BulkEditView): class ProviderBulkDeleteView(generic.BulkDeleteView): queryset = Provider.objects.annotate( - count_circuits=get_subquery(Circuit, 'provider') + count_circuits=count_related(Circuit, 'provider') ) filterset = filters.ProviderFilterSet table = tables.ProviderTable +# +# Clouds +# + +class CloudListView(generic.ObjectListView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + filterset_form = forms.CloudFilterForm + table = tables.CloudTable + + +class CloudView(generic.ObjectView): + queryset = Cloud.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + Q(termination_a__cloud=instance.pk) | + Q(termination_z__cloud=instance.pk) + ).prefetch_related( + 'type', 'tenant', 'terminations__site' + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('termination_a') + circuits_table.columns.hide('termination_z') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + +class CloudEditView(generic.ObjectEditView): + queryset = Cloud.objects.all() + model_form = forms.CloudForm + + +class CloudDeleteView(generic.ObjectDeleteView): + queryset = Cloud.objects.all() + + +class CloudBulkImportView(generic.BulkImportView): + queryset = Cloud.objects.all() + model_form = forms.CloudCSVForm + table = tables.CloudTable + + +class CloudBulkEditView(generic.BulkEditView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + table = tables.CloudTable + form = forms.CloudBulkEditForm + + +class CloudBulkDeleteView(generic.BulkDeleteView): + queryset = Cloud.objects.all() + filterset = filters.CloudFilterSet + table = tables.CloudTable + + # # Circuit Types # class CircuitTypeListView(generic.ObjectListView): queryset = CircuitType.objects.annotate( - circuit_count=get_subquery(Circuit, 'type') + circuit_count=count_related(Circuit, 'type') ) table = tables.CircuitTypeTable +class CircuitTypeView(generic.ObjectView): + queryset = CircuitType.objects.all() + + def get_extra_context(self, request, instance): + circuits = Circuit.objects.restrict(request.user, 'view').filter( + type=instance + ) + + circuits_table = tables.CircuitTable(circuits) + circuits_table.columns.hide('type') + paginate_table(circuits_table, request) + + return { + 'circuits_table': circuits_table, + } + + class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() model_form = forms.CircuitTypeForm @@ -108,9 +179,18 @@ class CircuitTypeBulkImportView(generic.BulkImportView): table = tables.CircuitTypeTable +class CircuitTypeBulkEditView(generic.BulkEditView): + queryset = CircuitType.objects.annotate( + circuit_count=count_related(Circuit, 'type') + ) + filterset = filters.CircuitTypeFilterSet + table = tables.CircuitTypeTable + form = forms.CircuitTypeBulkEditForm + + class CircuitTypeBulkDeleteView(generic.BulkDeleteView): queryset = CircuitType.objects.annotate( - circuit_count=get_subquery(Circuit, 'type') + circuit_count=count_related(Circuit, 'type') ) table = tables.CircuitTypeTable @@ -121,8 +201,8 @@ class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitListView(generic.ObjectListView): queryset = Circuit.objects.prefetch_related( - 'provider', 'type', 'tenant', 'terminations' - ).annotate_sites() + 'provider', 'type', 'tenant', 'termination_a', 'termination_z' + ) filterset = filters.CircuitFilterSet filterset_form = forms.CircuitFilterForm table = tables.CircuitTable @@ -139,7 +219,7 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A ).first() - if termination_a and termination_a.connected_endpoint: + if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'): termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view') # Z-side termination @@ -148,7 +228,7 @@ class CircuitView(generic.ObjectView): ).filter( circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z ).first() - if termination_z and termination_z.connected_endpoint: + if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'): termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view') return { @@ -160,7 +240,6 @@ class CircuitView(generic.ObjectView): class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() model_form = forms.CircuitForm - template_name = 'circuits/circuit_edit.html' class CircuitDeleteView(generic.ObjectDeleteView): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index d63d32d68..80e003efc 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -1,7 +1,7 @@ from rest_framework import serializers from dcim import models -from netbox.api import WritableNestedSerializer +from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ 'NestedCableSerializer', @@ -27,7 +27,7 @@ __all__ = [ 'NestedPowerPanelSerializer', 'NestedPowerPortSerializer', 'NestedPowerPortTemplateSerializer', - 'NestedRackGroupSerializer', + 'NestedLocationSerializer', 'NestedRackReservationSerializer', 'NestedRackRoleSerializer', 'NestedRackSerializer', @@ -35,6 +35,7 @@ __all__ = [ 'NestedRearPortTemplateSerializer', 'NestedRegionSerializer', 'NestedSiteSerializer', + 'NestedSiteGroupSerializer', 'NestedVirtualChassisSerializer', ] @@ -50,7 +51,17 @@ class NestedRegionSerializer(WritableNestedSerializer): class Meta: model = models.Region - fields = ['id', 'url', 'name', 'slug', 'site_count', '_depth'] + fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth'] + + +class NestedSiteGroupSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + site_count = serializers.IntegerField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.SiteGroup + fields = ['id', 'url', 'display', 'name', 'slug', 'site_count', '_depth'] class NestedSiteSerializer(WritableNestedSerializer): @@ -58,21 +69,21 @@ class NestedSiteSerializer(WritableNestedSerializer): class Meta: model = models.Site - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'display', 'name', 'slug'] # # Racks # -class NestedRackGroupSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') +class NestedLocationSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') rack_count = serializers.IntegerField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: - model = models.RackGroup - fields = ['id', 'url', 'name', 'slug', 'rack_count', '_depth'] + model = models.Location + fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count', '_depth'] class NestedRackRoleSerializer(WritableNestedSerializer): @@ -81,7 +92,7 @@ class NestedRackRoleSerializer(WritableNestedSerializer): class Meta: model = models.RackRole - fields = ['id', 'url', 'name', 'slug', 'rack_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'rack_count'] class NestedRackSerializer(WritableNestedSerializer): @@ -90,7 +101,7 @@ class NestedRackSerializer(WritableNestedSerializer): class Meta: model = models.Rack - fields = ['id', 'url', 'name', 'display_name', 'device_count'] + fields = ['id', 'url', 'display', 'name', 'display_name', 'device_count'] class NestedRackReservationSerializer(WritableNestedSerializer): @@ -99,7 +110,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): class Meta: model = models.RackReservation - fields = ['id', 'url', 'user', 'units'] + fields = ['id', 'url', 'display', 'user', 'units'] def get_user(self, obj): return obj.user.username @@ -115,7 +126,7 @@ class NestedManufacturerSerializer(WritableNestedSerializer): class Meta: model = models.Manufacturer - fields = ['id', 'url', 'name', 'slug', 'devicetype_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'devicetype_count'] class NestedDeviceTypeSerializer(WritableNestedSerializer): @@ -125,7 +136,7 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): class Meta: model = models.DeviceType - fields = ['id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] + fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'device_count'] class NestedConsolePortTemplateSerializer(WritableNestedSerializer): @@ -133,7 +144,7 @@ class NestedConsolePortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ConsolePortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): @@ -141,7 +152,7 @@ class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ConsoleServerPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedPowerPortTemplateSerializer(WritableNestedSerializer): @@ -149,7 +160,7 @@ class NestedPowerPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.PowerPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): @@ -157,7 +168,7 @@ class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): class Meta: model = models.PowerOutletTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedInterfaceTemplateSerializer(WritableNestedSerializer): @@ -165,7 +176,7 @@ class NestedInterfaceTemplateSerializer(WritableNestedSerializer): class Meta: model = models.InterfaceTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedRearPortTemplateSerializer(WritableNestedSerializer): @@ -173,7 +184,7 @@ class NestedRearPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.RearPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedFrontPortTemplateSerializer(WritableNestedSerializer): @@ -181,7 +192,7 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): class Meta: model = models.FrontPortTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): @@ -189,7 +200,7 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): class Meta: model = models.DeviceBayTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] # @@ -203,7 +214,7 @@ class NestedDeviceRoleSerializer(WritableNestedSerializer): class Meta: model = models.DeviceRole - fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedPlatformSerializer(WritableNestedSerializer): @@ -213,7 +224,7 @@ class NestedPlatformSerializer(WritableNestedSerializer): class Meta: model = models.Platform - fields = ['id', 'url', 'name', 'slug', 'device_count', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] class NestedDeviceSerializer(WritableNestedSerializer): @@ -221,7 +232,7 @@ class NestedDeviceSerializer(WritableNestedSerializer): class Meta: model = models.Device - fields = ['id', 'url', 'name', 'display_name'] + fields = ['id', 'url', 'display', 'name', 'display_name'] class NestedConsoleServerPortSerializer(WritableNestedSerializer): @@ -230,7 +241,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): class Meta: model = models.ConsoleServerPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedConsolePortSerializer(WritableNestedSerializer): @@ -239,7 +250,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer): class Meta: model = models.ConsolePort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedPowerOutletSerializer(WritableNestedSerializer): @@ -248,7 +259,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): class Meta: model = models.PowerOutlet - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedPowerPortSerializer(WritableNestedSerializer): @@ -257,7 +268,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer): class Meta: model = models.PowerPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedInterfaceSerializer(WritableNestedSerializer): @@ -266,7 +277,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer): class Meta: model = models.Interface - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedRearPortSerializer(WritableNestedSerializer): @@ -275,7 +286,7 @@ class NestedRearPortSerializer(WritableNestedSerializer): class Meta: model = models.RearPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedFrontPortSerializer(WritableNestedSerializer): @@ -284,7 +295,7 @@ class NestedFrontPortSerializer(WritableNestedSerializer): class Meta: model = models.FrontPort - fields = ['id', 'url', 'device', 'name', 'cable'] + fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] class NestedDeviceBaySerializer(WritableNestedSerializer): @@ -293,7 +304,7 @@ class NestedDeviceBaySerializer(WritableNestedSerializer): class Meta: model = models.DeviceBay - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'display', 'device', 'name'] class NestedInventoryItemSerializer(WritableNestedSerializer): @@ -303,19 +314,19 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): class Meta: model = models.InventoryItem - fields = ['id', 'url', 'device', 'name', '_depth'] + fields = ['id', 'url', 'display', 'device', 'name', '_depth'] # # Cables # -class NestedCableSerializer(serializers.ModelSerializer): +class NestedCableSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') class Meta: model = models.Cable - fields = ['id', 'url', 'label'] + fields = ['id', 'url', 'display', 'label'] # @@ -342,7 +353,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer): class Meta: model = models.PowerPanel - fields = ['id', 'url', 'name', 'powerfeed_count'] + fields = ['id', 'url', 'display', 'name', 'powerfeed_count'] class NestedPowerFeedSerializer(WritableNestedSerializer): @@ -350,4 +361,4 @@ class NestedPowerFeedSerializer(WritableNestedSerializer): class Meta: model = models.PowerFeed - fields = ['id', 'url', 'name', 'cable'] + fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6f497bfa6..df7985d82 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -3,23 +3,16 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator +from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceType, DeviceRole, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) -from dcim.utils import decompile_path_node -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from dcim.models import * from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN -from netbox.api import ( - ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField, ValidatedModelSerializer, +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import ( + NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer @@ -50,7 +43,7 @@ class CableTerminationSerializer(serializers.ModelSerializer): return None -class ConnectedEndpointSerializer(ValidatedModelSerializer): +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) @@ -82,23 +75,39 @@ class ConnectedEndpointSerializer(ValidatedModelSerializer): # Regions/sites # -class RegionSerializer(serializers.ModelSerializer): +class RegionSerializer(NestedGroupModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail') parent = NestedRegionSerializer(required=False, allow_null=True) site_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = Region - fields = ['id', 'url', 'name', 'slug', 'parent', 'description', 'site_count', '_depth'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] -class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class SiteGroupSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail') + parent = NestedRegionSerializer(required=False, allow_null=True) + site_count = serializers.IntegerField(read_only=True) + + class Meta: + model = SiteGroup + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'site_count', '_depth', + ] + + +class SiteSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) + group = NestedSiteGroupSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) - time_zone = TimeZoneField(required=False) + time_zone = TimeZoneSerializerField(required=False) circuit_count = serializers.IntegerField(read_only=True) device_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) @@ -109,10 +118,10 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', - 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', + 'circuit_count', 'device_count', 'prefix_count', 'rack_count', 'virtualmachine_count', 'vlan_count', ] @@ -120,31 +129,36 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Racks # -class RackGroupSerializer(ValidatedModelSerializer): - url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackgroup-detail') +class LocationSerializer(NestedGroupModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') site = NestedSiteSerializer() - parent = NestedRackGroupSerializer(required=False, allow_null=True) + parent = NestedLocationSerializer(required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) class Meta: - model = RackGroup - fields = ['id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'rack_count', '_depth'] + model = Location + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', + 'last_updated', 'rack_count', '_depth', + ] -class RackRoleSerializer(ValidatedModelSerializer): +class RackRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) class Meta: model = RackRole - fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'rack_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', + 'rack_count', + ] -class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() - group = NestedRackGroupSerializer(required=False, allow_null=True, default=None) + location = NestedLocationSerializer(required=False, allow_null=True, default=None) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=RackStatusChoices, required=False) role = NestedRackRoleSerializer(required=False, allow_null=True) @@ -157,21 +171,22 @@ class RackSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Rack fields = [ - 'id', 'url', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count', + 'id', 'url', 'display', 'name', 'facility_id', 'display_name', 'site', 'location', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', + 'powerfeed_count', ] - # Omit the UniqueTogetherValidator that would be automatically added to validate (group, facility_id). This + # Omit the UniqueTogetherValidator that would be automatically added to validate (location, facility_id). This # prevents facility_id from being interpreted as a required field. validators = [ - UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'name')) + UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'name')) ] def validate(self, data): - # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. + # Validate uniqueness of (location, facility_id) since we omitted the automatically-created validator from Meta. if data.get('facility_id', None): - validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) + validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('location', 'facility_id')) validator(data, self) # Enforce model validation @@ -191,7 +206,7 @@ class RackUnitSerializer(serializers.Serializer): occupied = serializers.BooleanField(read_only=True) -class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RackReservationSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -199,7 +214,10 @@ class RackReservationSerializer(TaggedObjectSerializer, CustomFieldModelSerializ class Meta: model = RackReservation - fields = ['id', 'url', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', 'custom_fields'] + fields = [ + 'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', + 'custom_fields', + ] class RackElevationDetailFilterSerializer(serializers.Serializer): @@ -242,7 +260,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # Device types # -class ManufacturerSerializer(ValidatedModelSerializer): +class ManufacturerSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -251,11 +269,12 @@ class ManufacturerSerializer(ValidatedModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'name', 'slug', 'description', 'devicetype_count', 'inventoryitem_count', 'platform_count', + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'devicetype_count', 'inventoryitem_count', 'platform_count', ] -class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceTypeSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -264,9 +283,9 @@ class DeviceTypeSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = DeviceType fields = [ - 'id', 'url', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', + 'id', 'url', 'display', 'manufacturer', 'model', 'slug', 'display_name', 'part_number', 'u_height', + 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', ] @@ -281,7 +300,9 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -295,7 +316,9 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -309,7 +332,10 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'created', 'last_updated', + ] class PowerOutletTemplateSerializer(ValidatedModelSerializer): @@ -331,7 +357,10 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'created', 'last_updated', + ] class InterfaceTemplateSerializer(ValidatedModelSerializer): @@ -341,7 +370,10 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', + 'last_updated', + ] class RearPortTemplateSerializer(ValidatedModelSerializer): @@ -351,7 +383,10 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', + 'last_updated', + ] class FrontPortTemplateSerializer(ValidatedModelSerializer): @@ -362,7 +397,10 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'created', 'last_updated', + ] class DeviceBayTemplateSerializer(ValidatedModelSerializer): @@ -371,14 +409,14 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'description'] + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] # # Devices # -class DeviceRoleSerializer(ValidatedModelSerializer): +class DeviceRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -386,11 +424,12 @@ class DeviceRoleSerializer(ValidatedModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', + 'last_updated', 'device_count', 'virtualmachine_count', ] -class PlatformSerializer(ValidatedModelSerializer): +class PlatformSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -399,18 +438,19 @@ class PlatformSerializer(ValidatedModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'device_count', - 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', + 'custom_fields', 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] -class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class DeviceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) platform = NestedPlatformSerializer(required=False, allow_null=True) site = NestedSiteSerializer() + location = NestedLocationSerializer(required=False, allow_null=True, default=None) rack = NestedRackSerializer(required=False, allow_null=True) face = ChoiceField(choices=DeviceFaceChoices, allow_blank=True, required=False) status = ChoiceField(choices=DeviceStatusChoices, required=False) @@ -424,10 +464,10 @@ class DeviceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Device fields = [ - 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', + 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -460,10 +500,10 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): class Meta(DeviceSerializer.Meta): fields = [ - 'id', 'url', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', - 'asset_tag', 'site', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'local_context_data', - 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', + 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'comments', 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -475,7 +515,11 @@ class DeviceNAPALMSerializer(serializers.Serializer): method = serializers.DictField() -class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +# +# Device components +# + +class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -483,17 +527,23 @@ class ConsoleServerPortSerializer(TaggedObjectSerializer, CableTerminationSerial allow_blank=True, required=False ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_blank=True, + required=False + ) cable = NestedCableSerializer(read_only=True) class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -501,17 +551,23 @@ class ConsolePortSerializer(TaggedObjectSerializer, CableTerminationSerializer, allow_blank=True, required=False ) + speed = ChoiceField( + choices=ConsolePortSpeedChoices, + allow_blank=True, + required=False + ) cable = NestedCableSerializer(read_only=True) class Meta: model = ConsolePort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -534,13 +590,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() type = ChoiceField( @@ -553,16 +609,17 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = PowerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=InterfaceTypeChoices) + parent = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -578,10 +635,11 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = Interface fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'mgmt_only', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'cable', 'cable_peer', 'cable_peer_type', - 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'count_ipaddresses', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', + 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', + '_occupied', ] def validate(self, data): @@ -598,7 +656,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -607,8 +665,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Val class Meta: model = RearPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', - 'cable_peer_type', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -620,10 +678,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class Meta: model = RearPort - fields = ['id', 'url', 'name', 'label'] + fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, ValidatedModelSerializer): +class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -633,26 +691,30 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Va class Meta: model = FrontPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'cable_peer_type', 'tags', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', + 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class DeviceBaySerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) class Meta: model = DeviceBay - fields = ['id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags'] + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', + 'custom_fields', 'created', 'last_updated', + ] # # Inventory items # -class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator @@ -663,8 +725,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = InventoryItem fields = [ - 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', '_depth', + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] @@ -672,7 +734,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, ValidatedModelSerializer): # Cables # -class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class CableSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -688,7 +750,7 @@ class CableSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Cable fields = [ - 'id', 'url', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', + 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', 'custom_fields', ] @@ -802,24 +864,24 @@ class InterfaceConnectionSerializer(ValidatedModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualChassisSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) class Meta: model = VirtualChassis - fields = ['id', 'url', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] + fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] # # Power panels # -class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PowerPanelSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() - rack_group = NestedRackGroupSerializer( + location = NestedLocationSerializer( required=False, allow_null=True, default=None @@ -828,15 +890,10 @@ class PowerPanelSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'site', 'rack_group', 'name', 'tags', 'custom_fields', 'powerfeed_count'] + fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] -class PowerFeedSerializer( - TaggedObjectSerializer, - CableTerminationSerializer, - ConnectedEndpointSerializer, - CustomFieldModelSerializer -): +class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( @@ -865,8 +922,8 @@ class PowerFeedSerializer( class Meta: model = PowerFeed fields = [ - 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', - 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', + 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', + 'created', 'last_updated', '_occupied', ] diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 689cb7aa1..43f956cb2 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -7,10 +7,11 @@ router.APIRootView = views.DCIMRootView # Sites router.register('regions', views.RegionViewSet) +router.register('site-groups', views.SiteGroupViewSet) router.register('sites', views.SiteViewSet) # Racks -router.register('rack-groups', views.RackGroupViewSet) +router.register('locations', views.LocationViewSet) router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 071174c76..6aa4fd484 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -16,13 +16,7 @@ from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit from dcim import filters -from dcim.models import ( - Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - Manufacturer, InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import * from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet from ipam.models import Prefix, VLAN from netbox.api.views import ModelViewSet @@ -30,7 +24,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from utilities.api import get_serializer_for_model -from utilities.utils import get_subquery +from utilities.utils import count_related from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -99,7 +93,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(ModelViewSet): +class RegionViewSet(CustomFieldModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -111,6 +105,22 @@ class RegionViewSet(ModelViewSet): filterset_class = filters.RegionFilterSet +# +# Site groups +# + +class SiteGroupViewSet(CustomFieldModelViewSet): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + serializer_class = serializers.SiteGroupSerializer + filterset_class = filters.SiteGroupFilterSet + + # # Sites # @@ -119,12 +129,12 @@ class SiteViewSet(CustomFieldModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'site'), - rack_count=get_subquery(Rack, 'site'), - prefix_count=get_subquery(Prefix, 'site'), - vlan_count=get_subquery(VLAN, 'site'), - circuit_count=get_subquery(Circuit, 'terminations__site'), - virtualmachine_count=get_subquery(VirtualMachine, 'cluster__site'), + device_count=count_related(Device, 'site'), + rack_count=count_related(Rack, 'site'), + prefix_count=count_related(Prefix, 'site'), + vlan_count=count_related(VLAN, 'site'), + circuit_count=count_related(Circuit, 'terminations__site'), + virtualmachine_count=count_related(VirtualMachine, 'cluster__site') ) serializer_class = serializers.SiteSerializer filterset_class = filters.SiteFilterSet @@ -134,25 +144,25 @@ class SiteViewSet(CustomFieldModelViewSet): # Rack groups # -class RackGroupViewSet(ModelViewSet): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationViewSet(CustomFieldModelViewSet): + queryset = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).prefetch_related('site') - serializer_class = serializers.RackGroupSerializer - filterset_class = filters.RackGroupFilterSet + serializer_class = serializers.LocationSerializer + filterset_class = filters.LocationFilterSet # # Rack roles # -class RackRoleViewSet(ModelViewSet): +class RackRoleViewSet(CustomFieldModelViewSet): queryset = RackRole.objects.annotate( - rack_count=get_subquery(Rack, 'role') + rack_count=count_related(Rack, 'role') ) serializer_class = serializers.RackRoleSerializer filterset_class = filters.RackRoleFilterSet @@ -164,10 +174,10 @@ class RackRoleViewSet(ModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( - 'site', 'group__site', 'role', 'tenant', 'tags' + 'site', 'location__site', 'role', 'tenant', 'tags' ).annotate( - device_count=get_subquery(Device, 'rack'), - powerfeed_count=get_subquery(PowerFeed, 'rack') + device_count=count_related(Device, 'rack'), + powerfeed_count=count_related(PowerFeed, 'rack') ) serializer_class = serializers.RackSerializer filterset_class = filters.RackFilterSet @@ -238,11 +248,11 @@ class RackReservationViewSet(ModelViewSet): # Manufacturers # -class ManufacturerViewSet(ModelViewSet): +class ManufacturerViewSet(CustomFieldModelViewSet): queryset = Manufacturer.objects.annotate( - devicetype_count=get_subquery(DeviceType, 'manufacturer'), - inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), - platform_count=get_subquery(Platform, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer'), + inventoryitem_count=count_related(InventoryItem, 'manufacturer'), + platform_count=count_related(Platform, 'manufacturer') ) serializer_class = serializers.ManufacturerSerializer filterset_class = filters.ManufacturerFilterSet @@ -254,10 +264,11 @@ class ManufacturerViewSet(ModelViewSet): class DeviceTypeViewSet(CustomFieldModelViewSet): queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( - device_count=get_subquery(Device, 'device_type') + device_count=count_related(Device, 'device_type') ) serializer_class = serializers.DeviceTypeSerializer filterset_class = filters.DeviceTypeFilterSet + brief_prefetch_fields = ['manufacturer'] # @@ -316,10 +327,10 @@ class DeviceBayTemplateViewSet(ModelViewSet): # Device roles # -class DeviceRoleViewSet(ModelViewSet): +class DeviceRoleViewSet(CustomFieldModelViewSet): queryset = DeviceRole.objects.annotate( - device_count=get_subquery(Device, 'device_role'), - virtualmachine_count=get_subquery(VirtualMachine, 'role') + device_count=count_related(Device, 'device_role'), + virtualmachine_count=count_related(VirtualMachine, 'role') ) serializer_class = serializers.DeviceRoleSerializer filterset_class = filters.DeviceRoleFilterSet @@ -329,10 +340,10 @@ class DeviceRoleViewSet(ModelViewSet): # Platforms # -class PlatformViewSet(ModelViewSet): +class PlatformViewSet(CustomFieldModelViewSet): queryset = Platform.objects.annotate( - device_count=get_subquery(Device, 'platform'), - virtualmachine_count=get_subquery(VirtualMachine, 'platform') + device_count=count_related(Device, 'platform'), + virtualmachine_count=count_related(VirtualMachine, 'platform') ) serializer_class = serializers.PlatformSerializer filterset_class = filters.PlatformFilterSet @@ -342,9 +353,9 @@ class PlatformViewSet(ModelViewSet): # Devices # -class DeviceViewSet(CustomFieldModelViewSet, ConfigContextQuerySetMixin): +class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): queryset = Device.objects.prefetch_related( - 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'rack', 'parent_bay', + 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', ) filterset_class = filters.DeviceFilterSet @@ -493,6 +504,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.ConsolePortSerializer filterset_class = filters.ConsolePortFilterSet + brief_prefetch_fields = ['device'] class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): @@ -501,50 +513,58 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filters.ConsoleServerPortFilterSet + brief_prefetch_fields = ['device'] class PowerPortViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerPortSerializer filterset_class = filters.PowerPortFilterSet + brief_prefetch_fields = ['device'] class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags') serializer_class = serializers.PowerOutletSerializer filterset_class = filters.PowerOutletFilterSet + brief_prefetch_fields = ['device'] class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' + 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filters.InterfaceFilterSet + brief_prefetch_fields = ['device'] class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') serializer_class = serializers.FrontPortSerializer filterset_class = filters.FrontPortFilterSet + brief_prefetch_fields = ['device'] class RearPortViewSet(PassThroughPortMixin, ModelViewSet): queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') serializer_class = serializers.RearPortSerializer filterset_class = filters.RearPortFilterSet + brief_prefetch_fields = ['device'] class DeviceBayViewSet(ModelViewSet): queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filters.DeviceBayFilterSet + brief_prefetch_fields = ['device'] class InventoryItemViewSet(ModelViewSet): queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filters.InventoryItemFilterSet + brief_prefetch_fields = ['device'] # @@ -596,10 +616,11 @@ class CableViewSet(ModelViewSet): class VirtualChassisViewSet(ModelViewSet): queryset = VirtualChassis.objects.prefetch_related('tags').annotate( - member_count=get_subquery(Device, 'virtual_chassis') + member_count=count_related(Device, 'virtual_chassis') ) serializer_class = serializers.VirtualChassisSerializer filterset_class = filters.VirtualChassisFilterSet + brief_prefetch_fields = ['master'] # @@ -608,9 +629,9 @@ class VirtualChassisViewSet(ModelViewSet): class PowerPanelViewSet(ModelViewSet): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( - powerfeed_count=get_subquery(PowerFeed, 'power_panel') + powerfeed_count=count_related(PowerFeed, 'power_panel') ) serializer_class = serializers.PowerPanelSerializer filterset_class = filters.PowerPanelFilterSet diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d8f8e3e27..5bea88fe3 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -217,6 +217,29 @@ class ConsolePortTypeChoices(ChoiceSet): ) +class ConsolePortSpeedChoices(ChoiceSet): + + SPEED_1200 = 1200 + SPEED_2400 = 2400 + SPEED_4800 = 4800 + SPEED_9600 = 9600 + SPEED_19200 = 19200 + SPEED_38400 = 38400 + SPEED_57600 = 57600 + SPEED_115200 = 115200 + + CHOICES = ( + (SPEED_1200, '1200 bps'), + (SPEED_2400, '2400 bps'), + (SPEED_4800, '4800 bps'), + (SPEED_9600, '9600 bps'), + (SPEED_19200, '19.2 kbps'), + (SPEED_38400, '38.4 kbps'), + (SPEED_57600, '57.6 kbps'), + (SPEED_115200, '115.2 kbps'), + ) + + # # PowerPorts # @@ -686,6 +709,7 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_8GFC_SFP_PLUS = '8gfc-sfpp' TYPE_16GFC_SFP_PLUS = '16gfc-sfpp' TYPE_32GFC_SFP28 = '32gfc-sfp28' + TYPE_64GFC_QSFP_PLUS = '64gfc-qsfpp' TYPE_128GFC_QSFP28 = '128gfc-sfp28' # InfiniBand @@ -801,6 +825,7 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_8GFC_SFP_PLUS, 'SFP+ (8GFC)'), (TYPE_16GFC_SFP_PLUS, 'SFP+ (16GFC)'), (TYPE_32GFC_SFP28, 'SFP28 (32GFC)'), + (TYPE_64GFC_QSFP_PLUS, 'QSFP+ (64GFC)'), (TYPE_128GFC_QSFP28, 'QSFP28 (128GFC)'), ) ), @@ -873,6 +898,10 @@ class PortTypeChoices(ChoiceSet): TYPE_8P6C = '8p6c' TYPE_8P4C = '8p4c' TYPE_8P2C = '8p2c' + TYPE_GG45 = 'gg45' + TYPE_TERA4P = 'tera-4p' + TYPE_TERA2P = 'tera-2p' + TYPE_TERA1P = 'tera-1p' TYPE_110_PUNCH = '110-punch' TYPE_BNC = 'bnc' TYPE_MRJ21 = 'mrj21' @@ -898,6 +927,10 @@ class PortTypeChoices(ChoiceSet): (TYPE_8P6C, '8P6C'), (TYPE_8P4C, '8P4C'), (TYPE_8P2C, '8P2C'), + (TYPE_GG45, 'GG45'), + (TYPE_TERA4P, 'TERA 4P'), + (TYPE_TERA2P, 'TERA 2P'), + (TYPE_TERA1P, 'TERA 1P'), (TYPE_110_PUNCH, '110 Punch'), (TYPE_BNC, 'BNC'), (TYPE_MRJ21, 'MRJ21'), @@ -936,6 +969,8 @@ class CableTypeChoices(ChoiceSet): TYPE_CAT6 = 'cat6' TYPE_CAT6A = 'cat6a' TYPE_CAT7 = 'cat7' + TYPE_CAT7A = 'cat7a' + TYPE_CAT8 = 'cat8' TYPE_DAC_ACTIVE = 'dac-active' TYPE_DAC_PASSIVE = 'dac-passive' TYPE_MRJ21_TRUNK = 'mrj21-trunk' @@ -960,6 +995,8 @@ class CableTypeChoices(ChoiceSet): (TYPE_CAT6, 'CAT6'), (TYPE_CAT6A, 'CAT6a'), (TYPE_CAT7, 'CAT7'), + (TYPE_CAT7A, 'CAT7a'), + (TYPE_CAT8, 'CAT8'), (TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'), (TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'), (TYPE_MRJ21_TRUNK, 'MRJ21 Trunk'), diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 3f104ef18..d8586eb33 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,6 +1,5 @@ import django_filters from django.contrib.auth.models import User -from django.db.models import Count from extras.filters import CustomFieldModelFilterSet, LocalConfigContextFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet @@ -13,13 +12,7 @@ from utilities.filters import ( from virtualization.models import Cluster from .choices import * from .constants import * -from .models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, - PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from .models import * __all__ = ( @@ -41,6 +34,7 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'LocationFilterSet', 'ManufacturerFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -52,13 +46,13 @@ __all__ = ( 'PowerPortFilterSet', 'PowerPortTemplateFilterSet', 'RackFilterSet', - 'RackGroupFilterSet', 'RackReservationFilterSet', 'RackRoleFilterSet', 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', 'SiteFilterSet', + 'SiteGroupFilterSet', 'VirtualChassisFilterSet', ) @@ -80,6 +74,23 @@ class RegionFilterSet(BaseFilterSet, NameSlugSearchFilterSet): fields = ['id', 'name', 'slug', 'description'] +class SiteGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + label='Parent site group (ID)', + ) + parent = django_filters.ModelMultipleChoiceFilter( + field_name='parent__slug', + queryset=SiteGroup.objects.all(), + to_field_name='slug', + label='Parent site group (slug)', + ) + + class Meta: + model = SiteGroup + fields = ['id', 'name', 'slug', 'description'] + + class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', @@ -97,11 +108,22 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='region', lookup_expr='in', to_field_name='slug', label='Region (slug)', ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='group', + lookup_expr='in', + label='Group (ID)', + ) + group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + lookup_expr='in', + to_field_name='slug', + label='Group (slug)', + ) tag = TagFilter() class Meta: @@ -132,7 +154,7 @@ class SiteFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, return queryset.filter(qs_filter) -class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): +class LocationFilterSet(BaseFilterSet, NameSlugSearchFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -146,6 +168,19 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -157,18 +192,18 @@ class RackGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): label='Site (slug)', ) parent_id = django_filters.ModelMultipleChoiceFilter( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), label='Rack group (ID)', ) parent = django_filters.ModelMultipleChoiceFilter( field_name='parent__slug', - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), to_field_name='slug', label='Rack group (slug)', ) class Meta: - model = RackGroup + model = Location fields = ['id', 'name', 'slug', 'description'] @@ -197,6 +232,19 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -207,23 +255,29 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Site (slug)', ) - group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) - group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='group', + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) status = django_filters.MultipleChoiceFilter( choices=RackStatusChoices, null_value=None ) + type = django_filters.MultipleChoiceFilter( + choices=RackTypeChoices + ) + width = django_filters.MultipleChoiceFilter( + choices=RackWidthChoices + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=RackRole.objects.all(), label='Role (ID)', @@ -242,8 +296,8 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, class Meta: model = Rack fields = [ - 'id', 'name', 'facility_id', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', - 'outer_width', 'outer_depth', 'outer_unit', + 'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', ] def search(self, queryset, name, value): @@ -258,7 +312,7 @@ class RackFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, ) -class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): +class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -278,25 +332,25 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='rack__location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) - group = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='rack__location', lookup_expr='in', to_field_name='slug', - label='Rack group (slug)', + label='Location (slug)', ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label='User (ID)', ) user = django_filters.ModelMultipleChoiceFilter( - field_name='user', + field_name='user__username', queryset=User.objects.all(), to_field_name='username', label='User (name)', @@ -560,6 +614,19 @@ class DeviceFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -570,11 +637,11 @@ class DeviceFilterSet( to_field_name='slug', label='Site name (slug)', ) - rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack__group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', - label='Rack group (ID)', + label='Location (ID)', ) rack_id = django_filters.ModelMultipleChoiceFilter( field_name='rack', @@ -698,7 +765,7 @@ class DeviceFilterSet( return queryset.exclude(devicebays__isnull=value) -class DeviceComponentFilterSet(django_filters.FilterSet): +class DeviceComponentFilterSet(CustomFieldModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -716,6 +783,19 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='device__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='device__site', queryset=Site.objects.all(), @@ -777,7 +857,7 @@ class ConsolePortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina class Meta: model = ConsolePort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class ConsoleServerPortFilterSet( @@ -793,7 +873,7 @@ class ConsoleServerPortFilterSet( class Meta: model = ConsoleServerPort - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -804,7 +884,7 @@ class PowerPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = PowerPort - fields = ['id', 'name', 'maximum_draw', 'allocated_draw', 'description'] + fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -815,7 +895,7 @@ class PowerOutletFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTermina class Meta: model = PowerOutlet - fields = ['id', 'name', 'feed_leg', 'description'] + fields = ['id', 'name', 'label', 'feed_leg', 'description'] class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): @@ -839,6 +919,11 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati method='filter_kind', label='Kind of interface', ) + parent_id = django_filters.ModelMultipleChoiceFilter( + field_name='parent', + queryset=Interface.objects.all(), + label='Parent interface (ID)', + ) lag_id = django_filters.ModelMultipleChoiceFilter( field_name='lag', queryset=Interface.objects.all(), @@ -861,7 +946,7 @@ class InterfaceFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = Interface - fields = ['id', 'name', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] + fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description'] def filter_device(self, queryset, name, value): try: @@ -915,21 +1000,21 @@ class FrontPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminati class Meta: model = FrontPort - fields = ['id', 'name', 'type', 'description'] + fields = ['id', 'name', 'label', 'type', 'description'] class RearPortFilterSet(BaseFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): class Meta: model = RearPort - fields = ['id', 'name', 'type', 'positions', 'description'] + fields = ['id', 'name', 'label', 'type', 'positions', 'description'] class DeviceBayFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'label', 'description'] class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): @@ -990,7 +1075,7 @@ class InventoryItemFilterSet(BaseFilterSet, DeviceComponentFilterSet): class Meta: model = InventoryItem - fields = ['id', 'name', 'part_id', 'asset_tag', 'discovered'] + fields = ['id', 'name', 'label', 'part_id', 'asset_tag', 'discovered'] def search(self, queryset, name, value): if not value.strip(): @@ -1010,6 +1095,16 @@ class VirtualChassisFilterSet(BaseFilterSet): method='search', label='Search', ) + master_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Master (ID)', + ) + master = django_filters.ModelMultipleChoiceFilter( + field_name='master__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Master (name)', + ) region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='master__site__region', @@ -1023,6 +1118,19 @@ class VirtualChassisFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='master__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), @@ -1049,7 +1157,7 @@ class VirtualChassisFilterSet(BaseFilterSet): class Meta: model = VirtualChassis - fields = ['id', 'domain'] + fields = ['id', 'domain', 'name'] def search(self, queryset, name, value): if not value.strip(): @@ -1059,7 +1167,7 @@ class VirtualChassisFilterSet(BaseFilterSet): Q(members__name__icontains=value) | Q(domain__icontains=value) ) - return queryset.filter(qs_filter) + return queryset.filter(qs_filter).distinct() class CableFilterSet(BaseFilterSet): @@ -1136,7 +1244,7 @@ class ConnectionFilterSet: def filter_device(self, queryset, name, value): if not value: return queryset - return queryset.filter(device_id__in=value) + return queryset.filter(**{f'{name}__in': value}) class ConsoleConnectionFilterSet(ConnectionFilterSet, BaseFilterSet): @@ -1211,6 +1319,19 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -1221,9 +1342,9 @@ class PowerPanelFilterSet(BaseFilterSet): to_field_name='slug', label='Site name (slug)', ) - rack_group_id = TreeNodeMultipleChoiceFilter( - queryset=RackGroup.objects.all(), - field_name='rack_group', + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='location', lookup_expr='in', label='Rack group (ID)', ) @@ -1266,6 +1387,19 @@ class PowerFeedFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='power_panel__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index ca7602bf2..dca1b86c3 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -6,23 +6,24 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms.array import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ from netaddr import EUI from netaddr.core import AddrFormatError from timezone_field import TimeZoneFormField from circuits.models import Circuit, CircuitTermination, Provider from extras.forms import ( - AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldFilterForm, CustomFieldModelForm, - LocalConfigContextFilterForm, + AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldForm, CustomFieldModelCSVForm, CustomFieldFilterForm, + CustomFieldModelForm, LocalConfigContextFilterForm, ) from extras.models import Tag from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN from ipam.models import IPAddress, VLAN from tenancy.forms import TenancyFilterForm, TenancyForm -from tenancy.models import Tenant, TenantGroup +from tenancy.models import Tenant from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelForm, + APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, + ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, @@ -30,12 +31,7 @@ from utilities.forms import ( from virtualization.models import Cluster, ClusterGroup from .choices import * from .constants import * -from .models import ( - Cable, DeviceBay, DeviceBayTemplate, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, - Device, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, - Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from .models import * DEVICE_BY_PK_RE = r'{\d+\}' @@ -58,34 +54,45 @@ def get_device_by_name_or_pk(name): return device -class DeviceComponentFilterForm(BootstrapMixin, forms.Form): +class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ - 'q', 'region', 'site' + 'q', 'name', 'label', 'region_id', 'site_group_id', 'site_id', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - to_field_name='slug', + name = forms.CharField( required=False ) - site = DynamicModelMultipleChoiceField( + label = forms.CharField( + required=False + ) + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) @@ -119,7 +126,7 @@ class InterfaceCommonForm(forms.Form): }) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentForm(forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -134,6 +141,7 @@ class ComponentForm(BootstrapMixin, forms.Form): ) def clean(self): + super().clean() # Validate that the number of components being created from both the name_pattern and label_pattern are equal if self.cleaned_data['label_pattern']: @@ -172,7 +180,7 @@ class MACAddressField(forms.Field): # Regions # -class RegionForm(BootstrapMixin, forms.ModelForm): +class RegionForm(BootstrapMixin, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -186,7 +194,7 @@ class RegionForm(BootstrapMixin, forms.ModelForm): ) -class RegionCSVForm(CSVModelForm): +class RegionCSVForm(CustomFieldModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -199,11 +207,86 @@ class RegionCSVForm(CSVModelForm): fields = Region.csv_headers +class RegionBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Region.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=Region.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + class RegionFilterForm(BootstrapMixin, forms.Form): model = Site q = forms.CharField( required=False, - label='Search' + label=_('Search') + ) + + +# +# Site groups +# + +class SiteGroupForm(BootstrapMixin, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + slug = SlugField() + + class Meta: + model = SiteGroup + fields = ( + 'parent', 'name', 'slug', 'description', + ) + + +class SiteGroupCSVForm(CustomFieldModelCSVForm): + parent = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Name of parent site group' + ) + + class Meta: + model = SiteGroup + fields = SiteGroup.csv_headers + + +class SiteGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class SiteGroupFilterForm(BootstrapMixin, forms.Form): + model = Site + q = forms.CharField( + required=False, + label=_('Search') ) @@ -216,6 +299,10 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) slug = SlugField() comments = CommentField() tags = DynamicModelMultipleChoiceField( @@ -226,10 +313,20 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Site fields = [ - 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', 'tags', + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', + 'contact_phone', 'contact_email', 'comments', 'tags', ] + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ( + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', + )), + ) widgets = { 'physical_address': SmallTextarea( attrs={ @@ -269,6 +366,12 @@ class SiteCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Assigned region' ) + group = CSVModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + to_field_name='name', + help_text='Assigned group' + ) tenant = CSVModelChoiceField( queryset=Tenant.objects.all(), required=False, @@ -301,6 +404,10 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Region.objects.all(), required=False ) + group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -323,35 +430,40 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'region', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', ] class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site - field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] + field_order = ['q', 'status', 'region_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) status = forms.MultipleChoiceField( choices=SiteStatusChoices, required=False, widget=StaticSelect2Multiple() ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') + ) + group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Group') ) tag = TagFilterField(model) # -# Rack groups +# Locations # -class RackGroupForm(BootstrapMixin, forms.ModelForm): +class LocationForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -359,14 +471,22 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) parent = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -375,20 +495,20 @@ class RackGroupForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: - model = RackGroup + model = Location fields = ( - 'region', 'site', 'parent', 'name', 'slug', 'description', + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', ) -class RackGroupCSVForm(CSVModelForm): +class LocationCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Assigned site' ) parent = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, to_field_name='name', help_text='Parent rack group', @@ -398,32 +518,57 @@ class RackGroupCSVForm(CSVModelForm): ) class Meta: - model = RackGroup - fields = RackGroup.csv_headers + model = Location + fields = Location.csv_headers -class RackGroupFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( - queryset=Region.objects.all(), - to_field_name='slug', +class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Location.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), required=False ) - site = DynamicModelMultipleChoiceField( - queryset=Site.objects.all(), - to_field_name='slug', + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ - 'region': '$region' + 'site_id': '$site' } ) - parent = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), - to_field_name='slug', + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + +class LocationFilterForm(BootstrapMixin, forms.Form): + region_id = DynamicModelMultipleChoiceField( + queryset=Region.objects.all(), + required=False, + label=_('Region') + ) + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), required=False, query_params={ - 'region': '$region', - 'site': '$site', - } + 'region_id': '$region_id' + }, + label=_('Site') + ) + parent = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'region_id': '$region_id', + 'site_id': '$site_id', + }, + label=_('Parent') ) @@ -431,7 +576,7 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): # Rack roles # -class RackRoleForm(BootstrapMixin, forms.ModelForm): +class RackRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -441,7 +586,7 @@ class RackRoleForm(BootstrapMixin, forms.ModelForm): ] -class RackRoleCSVForm(CSVModelForm): +class RackRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -452,6 +597,25 @@ class RackRoleCSVForm(CSVModelForm): } +class RackRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RackRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Racks # @@ -464,14 +628,22 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -490,9 +662,9 @@ class RackForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Rack fields = [ - 'region', 'site', 'group', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'comments', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', + 'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'comments', 'tags', ] help_texts = { 'site': "The site at which the rack exists", @@ -513,8 +685,8 @@ class RackCSVForm(CustomFieldModelCSVForm): queryset=Site.objects.all(), to_field_name='name' ) - group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), required=False, to_field_name='name' ) @@ -559,9 +731,9 @@ class RackCSVForm(CustomFieldModelCSVForm): if data: - # Limit group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -576,15 +748,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -652,49 +832,62 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'group', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack - field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Location') ) status = forms.MultipleChoiceField( choices=RackStatusChoices, required=False, widget=StaticSelect2Multiple() ) - role = DynamicModelMultipleChoiceField( - queryset=RackRole.objects.all(), - to_field_name='slug', + type = forms.MultipleChoiceField( + choices=RackTypeChoices, required=False, - null_option='None' + widget=StaticSelect2Multiple() + ) + width = forms.MultipleChoiceField( + choices=RackWidthChoices, + required=False, + widget=StaticSelect2Multiple() + ) + role_id = DynamicModelMultipleChoiceField( + queryset=RackRole.objects.all(), + required=False, + null_option='None', + label=_('Role') + ) + asset_tag = forms.CharField( + required=False ) tag = TagFilterField(model) @@ -704,15 +897,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackElevationFilterForm(RackFilterForm): - field_order = ['q', 'region', 'site', 'group_id', 'id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = [ + 'q', 'region_id', 'site_id', 'location_id', 'id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + ] id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), - label='Rack', + label=_('Rack'), required=False, - display_field='display_name', query_params={ - 'site': '$site', - 'group_id': '$group_id', + 'site_id': '$site_id', + 'location_id': '$location_id', } ) @@ -729,15 +923,23 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -745,10 +947,9 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': '$rack_group', + 'location_id': '$location', } ) units = NumericArrayField( @@ -769,21 +970,26 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = RackReservation fields = [ - 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', + 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', + 'description', 'tags', ] + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) -class RackReservationCSVForm(CSVModelForm): +class RackReservationCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Parent site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Rack's location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), @@ -804,26 +1010,26 @@ class RackReservationCSVForm(CSVModelForm): class Meta: model = RackReservation - fields = ('site', 'rack_group', 'rack', 'units', 'tenant', 'description') + fields = ('site', 'location', 'rack', 'units', 'tenant', 'description') def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) if data: - # Limit rack_group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by assigned site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackReservation.objects.all(), widget=forms.MultipleHiddenInput() @@ -850,30 +1056,38 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditFor class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): model = RackReservation - field_order = ['q', 'region', 'site', 'group_id', 'tenant_group', 'tenant'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Region') ) - group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.prefetch_related('site'), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.prefetch_related('site'), required=False, - label='Rack group', + label='Location', null_option='None' ) + user_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label='User', + widget=APISelectMultiple( + api_url='/api/users/users/', + ) + ) tag = TagFilterField(model) @@ -881,7 +1095,7 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): # Manufacturers # -class ManufacturerForm(BootstrapMixin, forms.ModelForm): +class ManufacturerForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -891,13 +1105,27 @@ class ManufacturerForm(BootstrapMixin, forms.ModelForm): ] -class ManufacturerCSVForm(CSVModelForm): +class ManufacturerCSVForm(CustomFieldModelCSVForm): class Meta: model = Manufacturer fields = Manufacturer.csv_headers +class ManufacturerBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Device types # @@ -921,13 +1149,19 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'front_image', 'rear_image', 'comments', 'tags', ] + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'tags', + )), + ('Images', ('front_image', 'rear_image')), + ) widgets = { 'subdevice_role': StaticSelect2(), # Exclude SVG images (unsupported by PIL) - 'front_image': forms.FileInput(attrs={ + 'front_image': forms.ClearableFileInput(attrs={ 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' }), - 'rear_image': forms.FileInput(attrs={ + 'rear_image': forms.ClearableFileInput(attrs={ 'accept': 'image/bmp,image/gif,image/jpeg,image/png,image/tiff' }) } @@ -974,12 +1208,12 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): model = DeviceType q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - manufacturer = DynamicModelMultipleChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Manufacturer') ) subdevice_role = forms.MultipleChoiceField( choices=add_blank_choice(SubdeviceRoleChoices), @@ -1035,7 +1269,7 @@ class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): # Device component templates # -class ComponentTemplateCreateForm(ComponentForm): +class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): """ Base form for the creation of device component templates (subclassed from ComponentTemplateModel). """ @@ -1048,7 +1282,6 @@ class ComponentTemplateCreateForm(ComponentForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1419,6 +1652,7 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): self.fields['rear_port_set'].choices = choices def clean(self): + super().clean() # Validate that the number of ports being created equals the number of selected (rear port, position) tuples front_port_count = len(self.cleaned_data['name_pattern']) @@ -1666,7 +1900,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): # Device roles # -class DeviceRoleForm(BootstrapMixin, forms.ModelForm): +class DeviceRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -1676,7 +1910,7 @@ class DeviceRoleForm(BootstrapMixin, forms.ModelForm): ] -class DeviceRoleCSVForm(CSVModelForm): +class DeviceRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -1687,11 +1921,35 @@ class DeviceRoleCSVForm(CSVModelForm): } +class DeviceRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + color = forms.CharField( + max_length=6, # RGB color code + required=False, + widget=ColorSelect() + ) + vm_role = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='VM role' + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['color', 'description'] + + # # Platforms # -class PlatformForm(BootstrapMixin, forms.ModelForm): +class PlatformForm(BootstrapMixin, CustomFieldModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -1710,7 +1968,7 @@ class PlatformForm(BootstrapMixin, forms.ModelForm): } -class PlatformCSVForm(CSVModelForm): +class PlatformCSVForm(CustomFieldModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -1724,6 +1982,29 @@ class PlatformCSVForm(CSVModelForm): fields = Platform.csv_headers +class PlatformBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Platform.objects.all(), + widget=forms.MultipleHiddenInput + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + napalm_driver = forms.CharField( + max_length=50, + required=False + ) + # TODO: Bulk edit support for napalm_args + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['manufacturer', 'napalm_driver', 'description'] + + # # Devices # @@ -1736,16 +2017,24 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) - site = DynamicModelChoiceField( - queryset=Site.objects.all(), - query_params={ - 'region_id': '$region' + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False, + query_params={ + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site' }, @@ -1756,15 +2045,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site', - 'group_id': '$rack_group', + 'location_id': 'location', } ) - position = forms.TypedChoiceField( + position = forms.IntegerField( required=False, - empty_value=None, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( api_url='/api/dcim/racks/{{rack}}/elevation/', @@ -1783,7 +2070,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1826,9 +2112,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Device fields = [ - 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position', 'face', - 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', 'cluster', 'tenant_group', 'tenant', - 'comments', 'tags', 'local_context_data' + 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', + 'location', 'position', 'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'cluster_group', + 'cluster', 'tenant_group', 'tenant', 'comments', 'tags', 'local_context_data' ] help_texts = { 'device_role': "The function this device serves", @@ -1837,6 +2123,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): "config context", } widgets = { + 'face': StaticSelect2(), 'status': StaticSelect2(), 'primary_ip4': StaticSelect2(), 'primary_ip6': StaticSelect2(), @@ -1883,6 +2170,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): Q(manufacturer__isnull=True) | Q(manufacturer=self.instance.device_type.manufacturer) ) + # Disable rack assignment if this is a child device installed in a parent device + if self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): + self.fields['site'].disabled = True + self.fields['rack'].disabled = True + self.initial['site'] = self.instance.parent_bay.device.site_id + self.initial['rack'] = self.instance.parent_bay.device.rack_id + else: # An object that doesn't exist yet can't have any IPs assigned to it @@ -1892,31 +2186,9 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True # Rack position - pk = self.instance.pk if self.instance.pk else None - try: - if self.is_bound and self.data.get('rack') and str(self.data.get('face')): - position_choices = Rack.objects.get(pk=self.data['rack']) \ - .get_rack_units(face=self.data.get('face'), exclude=pk) - elif self.initial.get('rack') and str(self.initial.get('face')): - position_choices = Rack.objects.get(pk=self.initial['rack']) \ - .get_rack_units(face=self.initial.get('face'), exclude=pk) - else: - position_choices = [] - except Rack.DoesNotExist: - position_choices = [] - self.fields['position'].choices = [('', '---------')] + [ - (p['id'], { - 'label': p['name'], - 'disabled': bool(p['device'] and p['id'] != self.initial.get('position')), - }) for p in position_choices - ] - - # Disable rack assignment if this is a child device installed in a parent device - if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): - self.fields['site'].disabled = True - self.fields['rack'].disabled = True - self.initial['site'] = self.instance.parent_bay.device.site_id - self.initial['rack'] = self.instance.parent_bay.device.rack_id + position = self.data.get('position') or self.initial.get('position') + if position: + self.fields['position'].widget.choices = [(position, f'U{position}')] class BaseDeviceCSVForm(CustomFieldModelCSVForm): @@ -1978,17 +2250,17 @@ class DeviceCSVForm(BaseDeviceCSVForm): to_field_name='name', help_text='Assigned site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Assigned location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), to_field_name='name', required=False, - help_text="Assigned rack" + help_text="Assigned rack (if any)" ) face = CSVChoiceField( choices=DeviceFaceChoices, @@ -1999,7 +2271,7 @@ class DeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack', 'position', 'face', 'cluster', 'comments', + 'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -2007,14 +2279,14 @@ class DeviceCSVForm(BaseDeviceCSVForm): if data: - # Limit rack_group queryset by assigned site + # Limit location queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by assigned site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -2073,7 +2345,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -2082,6 +2353,17 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=DeviceRole.objects.all(), required=False ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) tenant = DynamicModelChoiceField( queryset=Tenant.objects.all(), required=False @@ -2110,75 +2392,74 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm): model = Device field_order = [ - 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', - 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + 'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', required=False ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' + 'region_id': '$region_id' } ) - rack_group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group', + label=_('Location'), query_params={ - 'site': '$site' + 'site_id': '$site_id' } ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', null_option='None', query_params={ - 'site': '$site', - 'group_id': '$rack_group_id', - } + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + label=_('Rack') ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), - to_field_name='slug', - required=False - ) - manufacturer = DynamicModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - to_field_name='slug', required=False, - label='Manufacturer' + label=_('Role') + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer') ) device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, - label='Model', - display_field='model', query_params={ - 'manufacturer': '$manufacturer' - } + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Model') ) - platform = DynamicModelMultipleChoiceField( + platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Platform') ) status = forms.MultipleChoiceField( choices=DeviceStatusChoices, required=False, widget=StaticSelect2Multiple() ) + asset_tag = forms.CharField( + required=False + ) mac_address = forms.CharField( required=False, label='MAC address' @@ -2246,13 +2527,12 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt # Device components # -class ComponentCreateForm(ComponentForm): +class ComponentCreateForm(BootstrapMixin, CustomFieldForm, ComponentForm): """ Base form for the creation of device components (models subclassed from ComponentModel). """ device = DynamicModelChoiceField( - queryset=Device.objects.all(), - display_field='display_name' + queryset=Device.objects.all() ) description = forms.CharField( max_length=100, @@ -2264,7 +2544,7 @@ class ComponentCreateForm(ComponentForm): ) -class DeviceBulkAddComponentForm(ComponentForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldForm, ComponentForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -2291,10 +2571,15 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) -class ConsolePortForm(BootstrapMixin, forms.ModelForm): +class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2303,7 +2588,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'label', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2311,41 +2596,64 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm): class ConsolePortCreateForm(ComponentCreateForm): + model = ConsolePort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, widget=StaticSelect2() ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags') + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect2() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsolePortBulkCreateForm( - form_from_model(ConsolePort, ['type']), + form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') + model = ConsolePort + field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'description']), + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class ConsolePortCSVForm(CSVModelForm): +class ConsolePortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + type = CSVChoiceField( + choices=ConsolePortTypeChoices, + required=False, + help_text='Port type' + ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) class Meta: model = ConsolePort @@ -2364,10 +2672,15 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) -class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2376,7 +2689,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'label', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2384,41 +2697,64 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class ConsoleServerPortCreateForm(ComponentCreateForm): + model = ConsoleServerPort type = forms.ChoiceField( choices=add_blank_choice(ConsolePortTypeChoices), required=False, widget=StaticSelect2() ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'description', 'tags') + speed = forms.ChoiceField( + choices=add_blank_choice(ConsolePortSpeedChoices), + required=False, + widget=StaticSelect2() + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') class ConsoleServerPortBulkCreateForm( - form_from_model(ConsoleServerPort, ['type']), + form_from_model(ConsoleServerPort, ['type', 'speed', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') + model = ConsoleServerPort + field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['label', 'type', 'description']), + form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class ConsoleServerPortCSVForm(CSVModelForm): +class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + type = CSVChoiceField( + choices=ConsolePortTypeChoices, + required=False, + help_text='Port type' + ) + speed = CSVTypedChoiceField( + choices=ConsolePortSpeedChoices, + coerce=int, + empty_value=None, + required=False, + help_text='Port speed in bps' + ) class Meta: model = ConsoleServerPort @@ -2440,7 +2776,7 @@ class PowerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerPortForm(BootstrapMixin, forms.ModelForm): +class PowerPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -2449,7 +2785,8 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort fields = [ - 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2457,6 +2794,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm): class PowerPortCreateForm(ComponentCreateForm): + model = PowerPort type = forms.ChoiceField( choices=add_blank_choice(PowerPortTypeChoices), required=False, @@ -2473,37 +2811,48 @@ class PowerPortCreateForm(ComponentCreateForm): help_text="Allocated draw in watts" ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags', + 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', ) class PowerPortBulkCreateForm( - form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw']), + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'mark_connected']), DeviceBulkAddComponentForm ): + model = PowerPort field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') class PowerPortBulkEditForm( - form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'description']), + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class PowerPortCSVForm(CSVModelForm): +class PowerPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + type = CSVChoiceField( + choices=PowerPortTypeChoices, + required=False, + help_text='Port type' + ) class Meta: model = PowerPort @@ -2525,7 +2874,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerOutletForm(BootstrapMixin, forms.ModelForm): +class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): power_port = forms.ModelChoiceField( queryset=PowerPort.objects.all(), required=False @@ -2538,7 +2887,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'tags', + 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2555,6 +2904,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm): class PowerOutletCreateForm(ComponentCreateForm): + model = PowerOutlet type = forms.ChoiceField( choices=add_blank_choice(PowerOutletTypeChoices), required=False, @@ -2568,7 +2918,10 @@ class PowerOutletCreateForm(ComponentCreateForm): choices=add_blank_choice(PowerOutletFeedLegChoices), required=False ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2581,17 +2934,18 @@ class PowerOutletCreateForm(ComponentCreateForm): class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg']), + form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']), DeviceBulkAddComponentForm ): + model = PowerOutlet field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'description']), + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=PowerOutlet.objects.all(), @@ -2603,9 +2957,13 @@ class PowerOutletBulkEditForm( disabled=True, widget=forms.HiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: - nullable_fields = ('label', 'type', 'feed_leg', 'power_port', 'description') + nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -2619,11 +2977,16 @@ class PowerOutletBulkEditForm( self.fields['power_port'].widget.attrs['disabled'] = True -class PowerOutletCSVForm(CSVModelForm): +class PowerOutletCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + type = CSVChoiceField( + choices=PowerOutletTypeChoices, + required=False, + help_text='Outlet type' + ) power_port = CSVModelChoiceField( queryset=PowerPort.objects.all(), required=False, @@ -2681,6 +3044,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=StaticSelect2( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) mac_address = forms.CharField( required=False, label='MAC address' @@ -2688,12 +3057,27 @@ class InterfaceFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): +class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='Parent interface', + query_params={ + 'kind': 'physical', + } + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + label='LAG interface', + query_params={ + 'type': 'lag', + } + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, label='Untagged VLAN', - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2703,7 +3087,6 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): queryset=VLAN.objects.all(), required=False, label='Tagged VLANs', - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2717,13 +3100,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', - 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', + 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect2(), - 'lag': StaticSelect2(), 'mode': StaticSelect2(), } labels = { @@ -2736,19 +3118,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - if self.is_bound: - device = Device.objects.get(pk=self.data['device']) - else: - device = self.instance.device + device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - # Limit LAG choices to interfaces belonging to this device or a peer VC member - device_query = Q(device=device) - if device.virtual_chassis: - device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter( - device_query, - type=InterfaceTypeChoices.TYPE_LAG - ).exclude(pk=self.instance.pk) + # Restrict parent/LAG interface assignment by device + self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) @@ -2756,6 +3130,7 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): + model = Interface type = forms.ChoiceField( choices=InterfaceTypeChoices, widget=StaticSelect2(), @@ -2764,11 +3139,21 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): required=False, initial=True ) - lag = forms.ModelChoiceField( + parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent LAG', - widget=StaticSelect2(), + query_params={ + 'device_id': '$device', + 'kind': 'physical', + } + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + 'type': 'lag', + } ) mtu = forms.IntegerField( required=False, @@ -2793,7 +3178,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2802,48 +3186,44 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', } ) field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'lag', 'mtu', 'mac_address', 'description', - 'mgmt_only', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' + 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address', + 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - # Limit LAG choices to interfaces belonging to this device or a peer VC member + # Add current site to VLANs query params device = Device.objects.get( pk=self.initial.get('device') or self.data.get('device') ) - device_query = Q(device=device) - if device.virtual_chassis: - device_query |= Q(device__virtual_chassis=device.virtual_chassis) - self.fields['lag'].queryset = Interface.objects.filter(device_query, type=InterfaceTypeChoices.TYPE_LAG) - - # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only']), + form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags') + model = Interface + field_order = ( + 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + ) class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode' + 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', ]), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), @@ -2855,10 +3235,36 @@ class InterfaceBulkEditForm( disabled=True, widget=forms.HiddenInput() ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + parent = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'kind': 'physical', + } + ) + lag = DynamicModelChoiceField( + queryset=Interface.objects.all(), + required=False, + query_params={ + 'type': 'lag', + } + ) + mgmt_only = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect, + label='Management only' + ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2867,7 +3273,6 @@ class InterfaceBulkEditForm( tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2875,26 +3280,25 @@ class InterfaceBulkEditForm( ) class Meta: - nullable_fields = ( - 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' - ) + nullable_fields = [ + 'label', 'parent', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces which belong to the parent device (or VC master) if 'device' in self.initial: device = Device.objects.filter(pk=self.initial['device']).first() - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], - type=InterfaceTypeChoices.TYPE_LAG - ) + + # Restrict parent/LAG interface assignment by device + self.fields['parent'].widget.add_query_param('device_id', device.pk) + self.fields['lag'].widget.add_query_param('device_id', device.pk) # Add current site to VLANs query params self.fields['untagged_vlan'].widget.add_query_param('site_id', device.site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', device.site.pk) + else: - # See 4523 + # See #4523 if 'pk' in self.initial: site = None interfaces = Interface.objects.filter(pk__in=self.initial['pk']).prefetch_related('device__site') @@ -2912,10 +3316,13 @@ class InterfaceBulkEditForm( self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + self.fields['parent'].choices = () + self.fields['parent'].widget.attrs['disabled'] = True self.fields['lag'].choices = () self.fields['lag'].widget.attrs['disabled'] = True def clean(self): + super().clean() # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']: @@ -2928,11 +3335,17 @@ class InterfaceBulkEditForm( self.cleaned_data['tagged_vlans'] = [] -class InterfaceCSVForm(CSVModelForm): +class InterfaceCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + parent = CSVModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Parent interface' + ) lag = CSVModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -2998,7 +3411,7 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortForm(BootstrapMixin, forms.ModelForm): +class FrontPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3007,7 +3420,8 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = FrontPort fields = [ - 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'tags', + 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3027,6 +3441,7 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm): # TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic class FrontPortCreateForm(ComponentCreateForm): + model = FrontPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3036,7 +3451,9 @@ class FrontPortCreateForm(ComponentCreateForm): label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'rear_port_set', 'mark_connected', 'description', 'tags', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -3064,6 +3481,7 @@ class FrontPortCreateForm(ComponentCreateForm): self.fields['rear_port_set'].choices = choices def clean(self): + super().clean() # Validate that the number of ports being created equals the number of selected (rear port, position) tuples front_port_count = len(self.cleaned_data['name_pattern']) @@ -3093,10 +3511,10 @@ class FrontPortCreateForm(ComponentCreateForm): class FrontPortBulkEditForm( - form_from_model(FrontPort, ['label', 'type', 'description']), + form_from_model(FrontPort, ['label', 'type', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -3104,10 +3522,10 @@ class FrontPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class FrontPortCSVForm(CSVModelForm): +class FrontPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3166,7 +3584,7 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortForm(BootstrapMixin, forms.ModelForm): +class RearPortForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3175,7 +3593,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPort fields = [ - 'device', 'name', 'label', 'type', 'positions', 'description', 'tags', + 'device', 'name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -3184,6 +3602,7 @@ class RearPortForm(BootstrapMixin, forms.ModelForm): class RearPortCreateForm(ComponentCreateForm): + model = RearPort type = forms.ChoiceField( choices=PortTypeChoices, widget=StaticSelect2(), @@ -3194,21 +3613,24 @@ class RearPortCreateForm(ComponentCreateForm): initial=1, help_text='The number of front ports which may be mapped to each rear port' ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags', + ) class RearPortBulkCreateForm( - form_from_model(RearPort, ['type', 'positions']), + form_from_model(RearPort, ['type', 'positions', 'mark_connected']), DeviceBulkAddComponentForm ): - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') + model = RearPort + field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') class RearPortBulkEditForm( - form_from_model(RearPort, ['label', 'type', 'description']), + form_from_model(RearPort, ['label', 'type', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -3216,10 +3638,10 @@ class RearPortBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class RearPortCSVForm(CSVModelForm): +class RearPortCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3246,7 +3668,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class DeviceBayForm(BootstrapMixin, forms.ModelForm): +class DeviceBayForm(BootstrapMixin, CustomFieldModelForm): tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -3263,6 +3685,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm): class DeviceBayCreateForm(ComponentCreateForm): + model = DeviceBay field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') @@ -3288,6 +3711,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): + model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') @@ -3295,7 +3719,7 @@ class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=DeviceBay.objects.all(), @@ -3303,10 +3727,10 @@ class DeviceBayBulkEditForm( ) class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ['label', 'description'] -class DeviceBayCSVForm(CSVModelForm): +class DeviceBayCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3356,10 +3780,9 @@ class DeviceBayCSVForm(CSVModelForm): # Inventory items # -class InventoryItemForm(BootstrapMixin, forms.ModelForm): +class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all(), - display_field='display_name' + queryset=Device.objects.all() ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), @@ -3386,6 +3809,7 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class InventoryItemCreateForm(ComponentCreateForm): + model = InventoryItem manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -3416,7 +3840,7 @@ class InventoryItemCreateForm(ComponentCreateForm): ) -class InventoryItemCSVForm(CSVModelForm): +class InventoryItemCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -3436,6 +3860,7 @@ class InventoryItemBulkCreateForm( form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): + model = InventoryItem field_order = ( 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', @@ -3446,7 +3871,7 @@ class InventoryItemBulkEditForm( form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), BootstrapMixin, AddRemoveTagsForm, - BulkEditForm + CustomFieldBulkEditForm ): pk = forms.ModelMultipleChoiceField( queryset=InventoryItem.objects.all(), @@ -3458,15 +3883,15 @@ class InventoryItemBulkEditForm( ) class Meta: - nullable_fields = ('label', 'manufacturer', 'part_id', 'description') + nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem - manufacturer = DynamicModelMultipleChoiceField( + manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Manufacturer') ) serial = forms.CharField( required=False @@ -3496,19 +3921,24 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = 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' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), label='Rack', required=False, - display_field='display_name', null_option='None', query_params={ 'site_id': '$termination_b_site' @@ -3518,7 +3948,6 @@ class ConnectCableToDeviceForm(BootstrapMixin, CustomFieldModelForm): queryset=Device.objects.all(), label='Device', required=False, - display_field='display_name', query_params={ 'site_id': '$termination_b_site', 'rack_id': '$termination_b_rack', @@ -3550,7 +3979,7 @@ class ConnectCableToConsolePortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=ConsolePort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3561,7 +3990,7 @@ class ConnectCableToConsoleServerPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=ConsoleServerPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3572,7 +4001,7 @@ class ConnectCableToPowerPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=PowerPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3583,7 +4012,7 @@ class ConnectCableToPowerOutletForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=PowerOutlet.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3594,7 +4023,7 @@ class ConnectCableToInterfaceForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=Interface.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device', 'kind': 'physical', @@ -3606,7 +4035,7 @@ class ConnectCableToFrontPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=FrontPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3617,7 +4046,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): termination_b_id = DynamicModelChoiceField( queryset=RearPort.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'device_id': '$termination_b_device' } @@ -3635,18 +4064,23 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) label='Region', required=False ) + termination_b_site_group = 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' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) termination_b_circuit = DynamicModelChoiceField( queryset=Circuit.objects.all(), label='Circuit', - display_field='cid', query_params={ 'provider_id': '$termination_b_provider', 'site_id': '$termination_b_site', @@ -3655,8 +4089,7 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) termination_b_id = DynamicModelChoiceField( queryset=CircuitTermination.objects.all(), label='Side', - display_field='term_side', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'circuit_id': '$termination_b_circuit' } @@ -3684,19 +4117,24 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): label='Region', required=False ) + termination_b_site_group = 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' + 'region_id': '$termination_b_region', + 'group_id': '$termination_b_site_group', } ) - termination_b_rackgroup = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), - label='Rack Group', + termination_b_location = DynamicModelChoiceField( + queryset=Location.objects.all(), + label='Location', required=False, - display_field='cid', query_params={ 'site_id': '$termination_b_site' } @@ -3707,13 +4145,13 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): required=False, query_params={ 'site_id': '$termination_b_site', - 'rack_group_id': '$termination_b_rackgroup', + 'location_id': '$termination_b_location', } ) termination_b_id = DynamicModelChoiceField( queryset=PowerFeed.objects.all(), label='Name', - disabled_indicator='cable', + disabled_indicator='_occupied', query_params={ 'power_panel_id': '$termination_b_powerpanel' } @@ -3726,7 +4164,7 @@ class ConnectCableToPowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = Cable fields = [ - 'termination_b_rackgroup', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', + 'termination_b_location', 'termination_b_powerpanel', 'termination_b_id', 'type', 'status', 'label', 'color', 'length', 'length_unit', 'tags', ] @@ -3758,7 +4196,7 @@ class CableForm(BootstrapMixin, CustomFieldModelForm): } -class CableCSVForm(CSVModelForm): +class CableCSVForm(CustomFieldModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -3853,7 +4291,7 @@ class CableCSVForm(CSVModelForm): return length_unit if length_unit is not None else '' -class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Cable.objects.all(), widget=forms.MultipleHiddenInput @@ -3896,6 +4334,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ] def clean(self): + super().clean() # Validate length/unit length = self.cleaned_data.get('length') @@ -3910,25 +4349,25 @@ class CableFilterForm(BootstrapMixin, forms.Form): model = Cable q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - tenant = DynamicModelMultipleChoiceField( + tenant_id = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Tenant') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), @@ -3936,7 +4375,7 @@ class CableFilterForm(BootstrapMixin, forms.Form): label='Rack', null_option='None', query_params={ - 'site': '$site' + 'site_id': '$site_id' } ) type = forms.MultipleChoiceField( @@ -3957,12 +4396,12 @@ class CableFilterForm(BootstrapMixin, forms.Form): device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site', - 'tenant': '$tenant', + 'site_id': '$site_id', + 'tenant_id': '$tenant_id', 'rack_id': '$rack_id', - } + }, + label=_('Device') ) tag = TagFilterField(model) @@ -3972,74 +4411,74 @@ class CableFilterForm(BootstrapMixin, forms.Form): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - label='Device', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Device') ) @@ -4062,18 +4501,25 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, null_option='None', - display_field='display_name', query_params={ 'site_id': '$site' } @@ -4081,7 +4527,6 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): members = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -4100,7 +4545,7 @@ class VirtualChassisCreateForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = VirtualChassis fields = [ - 'name', 'domain', 'region', 'site', 'rack', 'members', 'initial_position', 'tags', + 'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', ] def save(self, *args, **kwargs): @@ -4204,25 +4649,31 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, null_option='None', - display_field='display_name', query_params={ 'site_id': '$site' } ) device = DynamicModelChoiceField( queryset=Device.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -4239,7 +4690,7 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): return device -class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=VirtualChassis.objects.all(), widget=forms.MultipleHiddenInput() @@ -4253,7 +4704,7 @@ class VirtualChassisBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm nullable_fields = ['domain'] -class VirtualChassisCSVForm(CSVModelForm): +class VirtualChassisCSVForm(CustomFieldModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -4266,39 +4717,30 @@ class VirtualChassisCSVForm(CSVModelForm): fields = VirtualChassis.csv_headers -class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualChassis + field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } - ) - tenant_group = DynamicModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - to_field_name='slug', - required=False, - null_option='None' - ) - tenant = DynamicModelMultipleChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - required=False, - null_option='None', - query_params={ - 'group': '$tenant_group' - } + 'region_id': '$region_id' + }, + label=_('Site') ) tag = TagFilterField(model) @@ -4315,14 +4757,22 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -4336,18 +4786,21 @@ class PowerPanelForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerPanel fields = [ - 'region', 'site', 'rack_group', 'name', 'tags', + 'region', 'site_group', 'site', 'location', 'name', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), + ) -class PowerPanelCSVForm(CSVModelForm): +class PowerPanelCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', help_text='Name of parent site' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), required=False, to_field_name='name' ) @@ -4363,10 +4816,10 @@ class PowerPanelCSVForm(CSVModelForm): # Limit group queryset by assigned site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPanel.objects.all(), widget=forms.MultipleHiddenInput @@ -4378,15 +4831,23 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) - rack_group = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + location = DynamicModelChoiceField( + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -4394,38 +4855,41 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): ) class Meta: - nullable_fields = ( - 'rack_group', - ) + nullable_fields = ['location'] class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): model = PowerPanel q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - rack_group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, - label='Rack group (ID)', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Location') ) tag = TagFilterField(model) @@ -4442,6 +4906,13 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'sites__powerpanel': '$power_panel' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -4449,7 +4920,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'powerpanel': '$power_panel' }, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) power_panel = DynamicModelChoiceField( @@ -4461,7 +4933,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site' } @@ -4475,9 +4946,14 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'tags', + 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', + 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), + ) widgets = { 'status': StaticSelect2(), 'type': StaticSelect2(), @@ -4497,11 +4973,11 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): to_field_name='name', help_text='Upstream power panel' ) - rack_group = CSVModelChoiceField( - queryset=RackGroup.objects.all(), + location = CSVModelChoiceField( + queryset=Location.objects.all(), to_field_name='name', required=False, - help_text="Rack's group (if any)" + help_text="Rack's location (if any)" ) rack = CSVModelChoiceField( queryset=Rack.objects.all(), @@ -4543,14 +5019,14 @@ class PowerFeedCSVForm(CustomFieldModelCSVForm): params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} self.fields['power_panel'].queryset = self.fields['power_panel'].queryset.filter(**params) - # Limit rack_group queryset by site + # Limit location queryset by site params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['rack_group'].queryset = self.fields['rack_group'].queryset.filter(**params) + self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) # Limit rack queryset by site and group params = { f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['rack_group'].to_field_name}": data.get('rack_group'), + f"location__{self.fields['location'].to_field_name}": data.get('location'), } self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) @@ -4567,7 +5043,6 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name' ) status = forms.ChoiceField( choices=add_blank_choice(PowerFeedStatusChoices), @@ -4602,6 +5077,10 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd max_utilization = forms.IntegerField( required=False ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) comments = CommentField( widget=SmallTextarea, label='Comments' @@ -4609,7 +5088,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class Meta: nullable_fields = [ - 'rackgroup', 'comments', + 'location', 'comments', ] @@ -4617,38 +5096,43 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): model = PowerFeed q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) power_panel_id = DynamicModelMultipleChoiceField( queryset=PowerPanel.objects.all(), required=False, - label='Power panel', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Power panel') ) rack_id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), required=False, - label='Rack', null_option='None', query_params={ - 'site': '$site' - } + 'site_id': '$site_id' + }, + label=_('Rack') ) status = forms.MultipleChoiceField( choices=PowerFeedStatusChoices, diff --git a/netbox/dcim/migrations/0123_standardize_models.py b/netbox/dcim/migrations/0123_standardize_models.py new file mode 100644 index 000000000..48e9e6503 --- /dev/null +++ b/netbox/dcim/migrations/0123_standardize_models.py @@ -0,0 +1,422 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0122_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='consoleserverport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebay', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='devicebay', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='devicebaytemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='devicerole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='frontport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='frontporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='interface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='interfacetemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='inventoryitem', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='manufacturer', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='platform', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlet', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='poweroutlet', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='powerport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='powerporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rackgroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rackrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearport', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='rearport', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='rearporttemplate', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='region', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackgroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/dcim/migrations/0124_mark_connected.py b/netbox/dcim/migrations/0124_mark_connected.py new file mode 100644 index 000000000..d7bf40a24 --- /dev/null +++ b/netbox/dcim/migrations/0124_mark_connected.py @@ -0,0 +1,51 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0123_standardize_models'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='consoleserverport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='frontport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='interface', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='powerfeed', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='poweroutlet', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='powerport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='rearport', + name='mark_connected', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/dcim/migrations/0125_console_port_speed.py b/netbox/dcim/migrations/0125_console_port_speed.py new file mode 100644 index 000000000..1a7f455d6 --- /dev/null +++ b/netbox/dcim/migrations/0125_console_port_speed.py @@ -0,0 +1,21 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0124_mark_connected'), + ] + + operations = [ + migrations.AddField( + model_name='consoleport', + name='speed', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='consoleserverport', + name='speed', + field=models.PositiveSmallIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0126_rename_rackgroup_location.py b/netbox/dcim/migrations/0126_rename_rackgroup_location.py new file mode 100644 index 000000000..8755097bd --- /dev/null +++ b/netbox/dcim/migrations/0126_rename_rackgroup_location.py @@ -0,0 +1,39 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0125_console_port_speed'), + ] + + operations = [ + migrations.RenameModel( + old_name='RackGroup', + new_name='Location', + ), + migrations.AlterModelOptions( + name='rack', + options={'ordering': ('site', 'location', '_name', 'pk')}, + ), + migrations.AlterField( + model_name='location', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations', to='dcim.site'), + ), + migrations.RenameField( + model_name='powerpanel', + old_name='rack_group', + new_name='location', + ), + migrations.RenameField( + model_name='rack', + old_name='group', + new_name='location', + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together={('location', 'facility_id'), ('location', 'name')}, + ), + ] diff --git a/netbox/dcim/migrations/0127_device_location.py b/netbox/dcim/migrations/0127_device_location.py new file mode 100644 index 000000000..479f9cea9 --- /dev/null +++ b/netbox/dcim/migrations/0127_device_location.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0126_rename_rackgroup_location'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.location'), + ), + ] diff --git a/netbox/dcim/migrations/0128_device_location_populate.py b/netbox/dcim/migrations/0128_device_location_populate.py new file mode 100644 index 000000000..06a172ac3 --- /dev/null +++ b/netbox/dcim/migrations/0128_device_location_populate.py @@ -0,0 +1,24 @@ +from django.db import migrations +from django.db.models import Subquery, OuterRef + + +def populate_device_location(apps, schema_editor): + Device = apps.get_model('dcim', 'Device') + Device.objects.filter(rack__isnull=False).update( + location_id=Subquery( + Device.objects.filter(pk=OuterRef('pk')).values('rack__location_id')[:1] + ) + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0127_device_location'), + ] + + operations = [ + migrations.RunPython( + code=populate_device_location + ), + ] diff --git a/netbox/dcim/migrations/0129_interface_parent.py b/netbox/dcim/migrations/0129_interface_parent.py new file mode 100644 index 000000000..37e722f0a --- /dev/null +++ b/netbox/dcim/migrations/0129_interface_parent.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0128_device_location_populate'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='dcim.interface'), + ), + ] diff --git a/netbox/dcim/migrations/0130_sitegroup.py b/netbox/dcim/migrations/0130_sitegroup.py new file mode 100644 index 000000000..3b3bdcf10 --- /dev/null +++ b/netbox/dcim/migrations/0130_sitegroup.py @@ -0,0 +1,39 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0129_interface_parent'), + ] + + operations = [ + migrations.CreateModel( + name='SiteGroup', + fields=[ + ('created', models.DateField(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)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(max_length=100, unique=True)), + ('description', models.CharField(blank=True, max_length=200)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.sitegroup')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='site', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='sites', to='dcim.sitegroup'), + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 513c07438..ee19d553d 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -34,12 +34,13 @@ __all__ = ( 'PowerPort', 'PowerPortTemplate', 'Rack', - 'RackGroup', + 'Location', 'RackReservation', 'RackRole', 'RearPort', 'RearPortTemplate', 'Region', 'Site', + 'SiteGroup', 'VirtualChassis', ) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7a9f2dc2c..9e5aea725 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -6,14 +6,13 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db import models from django.db.models import Sum from django.urls import reverse -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import PathField from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet from utilities.utils import to_meters @@ -32,7 +31,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Cable(ChangeLoggedModel, CustomFieldModel): +class Cable(PrimaryModel): """ A physical connection between two endpoints. """ @@ -107,7 +106,6 @@ class Cable(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -147,7 +145,8 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return instance def __str__(self): - return self.label or '#{}'.format(self._pk) + pk = self.pk or self._pk + return self.label or f'#{pk}' def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) @@ -243,6 +242,16 @@ class Cable(ChangeLoggedModel, CustomFieldModel): ): raise ValidationError("A front port cannot be connected to it corresponding rear port") + # A CircuitTermination attached to a Cloud cannot have a Cable + if isinstance(self.termination_a, CircuitTermination) and self.termination_a.cloud is not None: + raise ValidationError({ + 'termination_a_id': "Circuit terminations attached to a cloud may not be cabled." + }) + if isinstance(self.termination_b, CircuitTermination) and self.termination_b.cloud is not None: + raise ValidationError({ + 'termination_b_id': "Circuit terminations attached to a cloud 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( @@ -304,7 +313,7 @@ class Cable(ChangeLoggedModel, CustomFieldModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(models.Model): +class CablePath(BigIDModel): """ 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 @@ -479,17 +488,23 @@ class CablePath(models.Model): def get_total_length(self): """ - Return the sum of the length of each cable in the path. + Return a tuple containing the sum of the length of each cable in the path + and a flag indicating whether the length is definitive. """ cable_ids = [ # Starting from the first element, every third element in the path should be a Cable decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3) ] - return Cable.objects.filter(id__in=cable_ids).aggregate(total=Sum('_abs_length'))['total'] + cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) + total_length = cables.aggregate(total=Sum('_abs_length'))['total'] + is_definitive = len(cables) == len(cable_ids) + + return total_length, is_definitive def get_split_nodes(self): """ Return all available next segments in a split cable path. """ rearport = path_node_to_object(self.path[-1]) + return FrontPort.objects.filter(rear_port=rearport) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 7a94b3e1b..d99d6b7c3 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,11 +4,11 @@ from django.db import models from dcim.choices import * from dcim.constants import * -from extras.models import ObjectChange +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet from utilities.ordering import naturalize_interface -from utilities.utils import serialize_object from .device_components import ( ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, ) @@ -26,7 +26,7 @@ __all__ = ( ) -class ComponentTemplateModel(models.Model): +class ComponentTemplateModel(ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -73,15 +73,10 @@ class ComponentTemplateModel(models.Model): except ObjectDoesNotExist: # The parent DeviceType has already been deleted device_type = None - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=device_type, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=device_type) +@extras_features('webhooks') class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -105,6 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -128,6 +124,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -164,7 +161,17 @@ class PowerPortTemplate(ComponentTemplateModel): allocated_draw=self.allocated_draw ) + def clean(self): + super().clean() + if self.maximum_draw is not None and self.allocated_draw is not None: + if self.allocated_draw > self.maximum_draw: + raise ValidationError({ + 'allocated_draw': f"Allocated draw cannot exceed the maximum draw ({self.maximum_draw}W)." + }) + + +@extras_features('webhooks') class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -193,6 +200,7 @@ class PowerOutletTemplate(ComponentTemplateModel): unique_together = ('device_type', 'name') def clean(self): + super().clean() # Validate power port assignment if self.power_port and self.power_port.device_type != self.device_type: @@ -215,6 +223,7 @@ class PowerOutletTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -249,6 +258,7 @@ class InterfaceTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class FrontPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -278,6 +288,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) def clean(self): + super().clean() # Validate rear port assignment if self.rear_port.device_type != self.device_type: @@ -308,6 +319,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class RearPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -338,6 +350,7 @@ class RearPortTemplate(ComponentTemplateModel): ) +@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. @@ -352,3 +365,9 @@ class DeviceBayTemplate(ComponentTemplateModel): name=self.name, label=self.label ) + + def clean(self): + if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: + raise ValidationError( + f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." + ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5d5825f5f..1f2911d2d 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -6,19 +6,17 @@ from django.db import models from django.db.models import Sum from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField -from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.fields import NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from utilities.querysets import RestrictedQuerySet from utilities.query_functions import CollateAsChar -from utilities.utils import serialize_object __all__ = ( @@ -37,7 +35,7 @@ __all__ = ( ) -class ComponentModel(models.Model): +class ComponentModel(PrimaryModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -81,17 +79,11 @@ class ComponentModel(models.Model): except ObjectDoesNotExist: # The parent Device has already been deleted device = None - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=device, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=device) @property - def parent(self): - return getattr(self, 'device', None) + def parent_object(self): + return self.device class CableTermination(models.Model): @@ -125,6 +117,10 @@ class CableTermination(models.Model): ct_field='_cable_peer_type', fk_field='_cable_peer_id' ) + 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( @@ -141,9 +137,25 @@ class CableTermination(models.Model): class Meta: abstract = True + def clean(self): + super().clean() + + if self.mark_connected and self.cable_id: + raise ValidationError({ + "mark_connected": "Cannot mark as connected with a cable attached." + }) + def get_cable_peer(self): return self._cable_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()") + class PathEndpoint(models.Model): """ @@ -198,8 +210,8 @@ class PathEndpoint(models.Model): # Console ports # -@extras_features('export_templates', 'webhooks') -class ConsolePort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ConsolePort(ComponentModel, CableTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -209,9 +221,14 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - tags = TaggableManager(through=TaggedItem) + speed = models.PositiveSmallIntegerField( + choices=ConsolePortSpeedChoices, + blank=True, + null=True, + help_text='Port speed in bits per second' + ) - csv_headers = ['device', 'name', 'label', 'type', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] class Meta: ordering = ('device', '_name') @@ -226,6 +243,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, + self.mark_connected, self.description, ) @@ -234,8 +253,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): # Console server ports # -@extras_features('webhooks') -class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -245,9 +264,14 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text='Physical port type' ) - tags = TaggableManager(through=TaggedItem) + speed = models.PositiveSmallIntegerField( + choices=ConsolePortSpeedChoices, + blank=True, + null=True, + help_text='Port speed in bits per second' + ) - csv_headers = ['device', 'name', 'label', 'type', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] class Meta: ordering = ('device', '_name') @@ -262,6 +286,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, + self.mark_connected, self.description, ) @@ -270,8 +296,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): # Power ports # -@extras_features('export_templates', 'webhooks') -class PowerPort(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class PowerPort(ComponentModel, CableTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -293,9 +319,10 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): validators=[MinValueValidator(1)], help_text="Allocated power draw (watts)" ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description'] + csv_headers = [ + 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', + ] class Meta: ordering = ('device', '_name') @@ -310,12 +337,14 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.maximum_draw, self.allocated_draw, self.description, ) def clean(self): + super().clean() if self.maximum_draw is not None and self.allocated_draw is not None: if self.allocated_draw > self.maximum_draw: @@ -378,8 +407,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): # Power outlets # -@extras_features('webhooks') -class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class PowerOutlet(ComponentModel, CableTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -402,9 +431,8 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text="Phase (for three-phase feeds)" ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description'] class Meta: ordering = ('device', '_name') @@ -419,12 +447,14 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.power_port.name if self.power_port else None, self.get_feed_leg_display(), self.description, ) def clean(self): + super().clean() # Validate power port assignment if self.power_port and self.power_port.device != self.device: @@ -477,8 +507,8 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) -@extras_features('export_templates', 'webhooks') -class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -489,6 +519,14 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): max_length=100, blank=True ) + parent = models.ForeignKey( + to='self', + on_delete=models.SET_NULL, + related_name='child_interfaces', + null=True, + blank=True, + verbose_name='Parent interface' + ) lag = models.ForeignKey( to='self', on_delete=models.SET_NULL, @@ -503,7 +541,7 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): ) mgmt_only = models.BooleanField( default=False, - verbose_name='OOB Management', + verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) untagged_vlan = models.ForeignKey( @@ -526,10 +564,10 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='interface' ) - tags = TaggableManager(through=TaggedItem) csv_headers = [ - 'device', 'name', 'label', 'lag', 'type', 'enabled', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode', + 'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu', + 'mgmt_only', 'description', 'mode', ] class Meta: @@ -544,9 +582,11 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): self.device.identifier if self.device else None, self.name, self.label, + self.parent.name if self.parent else None, self.lag.name if self.lag else None, self.get_type_display(), self.enabled, + self.mark_connected, self.mac_address, self.mtu, self.mgmt_only, @@ -555,16 +595,41 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): ) def clean(self): + super().clean() # Virtual interfaces cannot be connected - if self.type in NONCONNECTABLE_IFACE_TYPES and ( - self.cable or getattr(self, 'circuit_termination', False) - ): + if not self.is_connectable and self.cable: raise ValidationError({ - 'type': "Virtual and wireless interfaces cannot be connected to another interface or circuit. " - "Disconnect the interface or choose a suitable type." + 'type': f"{self.get_type_display()} interfaces cannot have a cable attached." }) + # Non-connectable interfaces cannot be marked as connected + if not self.is_connectable and self.mark_connected: + raise ValidationError({ + 'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected." + }) + + # An interface's parent must belong to the same device or virtual chassis + if self.parent and self.parent.device != self.device: + if self.device.virtual_chassis is None: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to a different device " + f"({self.parent.device})." + }) + elif self.parent.device.virtual_chassis != self.parent.virtual_chassis: + raise ValidationError({ + 'parent': f"The selected parent interface ({self.parent}) belongs to {self.parent.device}, which " + f"is not part of virtual chassis {self.device.virtual_chassis}." + }) + + # A physical interface cannot have a parent interface + if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None: + raise ValidationError({'parent': "Only virtual interfaces may be assigned to a parent interface."}) + + # A virtual interface cannot be a parent interface + if self.parent is not None and self.parent.type == InterfaceTypeChoices.TYPE_VIRTUAL: + raise ValidationError({'parent': "Virtual interfaces may not be parents of other interfaces."}) + # An interface's LAG must belong to the same device or virtual chassis if self.lag and self.lag.device != self.device: if self.device.virtual_chassis is None: @@ -586,16 +651,12 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): raise ValidationError({'lag': "A LAG interface cannot be its own parent."}) # Validate untagged VLAN - if self.untagged_vlan and self.untagged_vlan.site not in [self.parent.site, None]: + if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]: raise ValidationError({ 'untagged_vlan': "The untagged VLAN ({}) must belong to the same site as the interface's parent " "device, or it must be global".format(self.untagged_vlan) }) - @property - def parent(self): - return self.device - @property def is_connectable(self): return self.type not in NONCONNECTABLE_IFACE_TYPES @@ -621,8 +682,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): # Pass-through ports # -@extras_features('webhooks') -class FrontPort(CableTermination, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class FrontPort(ComponentModel, CableTermination): """ A pass-through port on the front of a Device. """ @@ -642,9 +703,10 @@ class FrontPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description'] + csv_headers = [ + 'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description', + ] class Meta: ordering = ('device', '_name') @@ -662,12 +724,14 @@ class FrontPort(CableTermination, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.rear_port.name, self.rear_port_position, self.description, ) def clean(self): + super().clean() # Validate rear port assignment if self.rear_port.device != self.device: @@ -683,8 +747,8 @@ class FrontPort(CableTermination, ComponentModel): }) -@extras_features('webhooks') -class RearPort(CableTermination, ComponentModel): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RearPort(ComponentModel, CableTermination): """ A pass-through port on the rear of a Device. """ @@ -699,9 +763,8 @@ class RearPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) - csv_headers = ['device', 'name', 'label', 'type', 'positions', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description'] class Meta: ordering = ('device', '_name') @@ -711,6 +774,7 @@ class RearPort(CableTermination, ComponentModel): return reverse('dcim:rearport', kwargs={'pk': self.pk}) def clean(self): + super().clean() # Check that positions count is greater than or equal to the number of associated FrontPorts frontport_count = self.frontports.count() @@ -726,6 +790,7 @@ class RearPort(CableTermination, ComponentModel): self.name, self.label, self.get_type_display(), + self.mark_connected, self.positions, self.description, ) @@ -735,7 +800,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # -@extras_features('webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -747,7 +812,6 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'installed_device', 'description'] @@ -768,6 +832,7 @@ class DeviceBay(ComponentModel): ) def clean(self): + super().clean() # Validate that the parent Device can have DeviceBays if not self.device.device_type.is_parent_device: @@ -794,7 +859,7 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('export_templates', 'webhooks') +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -839,8 +904,6 @@ class InventoryItem(MPTTModel, ComponentModel): help_text='This item was automatically discovered' ) - tags = TaggableManager(through=TaggedItem) - objects = TreeManager() csv_headers = [ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 5b685057e..a5efadac5 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -9,13 +9,13 @@ from django.db import models from django.db.models import F, ProtectedError from django.urls import reverse from django.utils.safestring import mark_safe -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, ConfigContextModel, CustomFieldModel, TaggedItem +from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet @@ -36,8 +36,8 @@ __all__ = ( # Device Types # -@extras_features('export_templates', 'webhooks') -class Manufacturer(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. """ @@ -65,7 +65,7 @@ class Manufacturer(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) + return reverse('dcim:manufacturer', args=[self.pk]) def to_csv(self): return ( @@ -76,7 +76,7 @@ class Manufacturer(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class DeviceType(ChangeLoggedModel, CustomFieldModel): +class DeviceType(PrimaryModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -135,7 +135,6 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -338,7 +337,8 @@ class DeviceType(ChangeLoggedModel, CustomFieldModel): # Devices # -class DeviceRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a color to be used when displaying rack elevations. The vm_role field determines whether the role is applicable to @@ -375,6 +375,9 @@ class DeviceRole(ChangeLoggedModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('dcim:devicerole', args=[self.pk]) + def to_csv(self): return ( self.name, @@ -385,7 +388,8 @@ class DeviceRole(ChangeLoggedModel): ) -class Platform(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". NetBox uses Platforms to determine how to interact with devices when pulling inventory data or other information by @@ -435,7 +439,7 @@ class Platform(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?platform={}".format(reverse('dcim:device_list'), self.slug) + return reverse('dcim:platform', args=[self.pk]) def to_csv(self): return ( @@ -449,7 +453,7 @@ class Platform(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): +class Device(PrimaryModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -514,6 +518,13 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): on_delete=models.PROTECT, related_name='devices' ) + location = models.ForeignKey( + to='dcim.Location', + on_delete=models.PROTECT, + related_name='devices', + blank=True, + null=True + ) rack = models.ForeignKey( to='dcim.Rack', on_delete=models.PROTECT, @@ -591,16 +602,15 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): object_id_field='assigned_object_id', related_query_name='device' ) - tags = TaggableManager(through=TaggedItem) objects = ConfigContextModelQuerySet.as_manager() csv_headers = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'rack_group', 'rack_name', 'position', 'face', 'comments', + 'site', 'location', 'rack_name', 'position', 'face', 'comments', ] clone_fields = [ - 'device_type', 'device_role', 'tenant', 'platform', 'site', 'rack', 'status', 'cluster', + 'device_type', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'status', 'cluster', ] class Meta: @@ -637,11 +647,17 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): def clean(self): super().clean() - # Validate site/rack combination + # Validate site/location/rack combination if self.rack and self.site != self.rack.site: raise ValidationError({ - 'rack': "Rack {} does not belong to site {}.".format(self.rack, self.site), + 'rack': f"Rack {self.rack} does not belong to site {self.site}.", }) + if self.rack and self.location and self.rack.location != self.location: + raise ValidationError({ + 'rack': f"Rack {self.rack} does not belong to location {self.location}.", + }) + elif self.rack: + self.location = self.rack.location if self.rack is None: if self.face: @@ -650,7 +666,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): }) if self.position: raise ValidationError({ - 'face': "Cannot select a rack position without assigning a rack.", + 'position': "Cannot select a rack position without assigning a rack.", }) # Validate position/face combination @@ -662,7 +678,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Prevent 0U devices from being assigned to a specific position if self.position and self.device_type.u_height == 0: raise ValidationError({ - 'position': "A U0 device type ({}) cannot be assigned to a rack position.".format(self.device_type) + 'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position." }) if self.rack: @@ -688,8 +704,8 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): ) if self.position and self.position not in available_units: raise ValidationError({ - 'position': "U{} is already occupied or does not have sufficient space to accommodate a(n) " - "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height) + 'position': f"U{self.position} is already occupied or does not have sufficient space to " + f"accommodate this device type: {self.device_type} ({self.device_type.u_height}U)" }) except DeviceType.DoesNotExist: @@ -796,7 +812,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): self.asset_tag, self.get_status_display(), self.site.name, - self.rack.group.name if self.rack and self.rack.group else None, + self.rack.location.name if self.rack and self.rack.location else None, self.rack.name if self.rack else None, self.position, self.get_face_display(), @@ -882,7 +898,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VirtualChassis(ChangeLoggedModel, CustomFieldModel): +class VirtualChassis(PrimaryModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ @@ -900,7 +916,6 @@ class VirtualChassis(ChangeLoggedModel, CustomFieldModel): max_length=30, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index 1215ced4c..06d234149 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -2,12 +2,11 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features +from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet from utilities.validators import ExclusionValidator from .device_components import CableTermination, PathEndpoint @@ -23,7 +22,7 @@ __all__ = ( # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerPanel(ChangeLoggedModel, CustomFieldModel): +class PowerPanel(PrimaryModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -31,8 +30,8 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): to='Site', on_delete=models.PROTECT ) - rack_group = models.ForeignKey( - to='RackGroup', + location = models.ForeignKey( + to='dcim.Location', on_delete=models.PROTECT, blank=True, null=True @@ -40,11 +39,10 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): name = models.CharField( max_length=100 ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() - csv_headers = ['site', 'rack_group', 'name'] + csv_headers = ['site', 'location', 'name'] class Meta: ordering = ['site', 'name'] @@ -59,22 +57,22 @@ class PowerPanel(ChangeLoggedModel, CustomFieldModel): def to_csv(self): return ( self.site.name, - self.rack_group.name if self.rack_group else None, + self.location.name if self.location else None, self.name, ) def clean(self): super().clean() - # RackGroup must belong to assigned Site - if self.rack_group and self.rack_group.site != self.site: + # Location must belong to assigned Site + if self.location and self.location.site != self.site: raise ValidationError("Rack group {} ({}) is in a different site than {}".format( - self.rack_group, self.rack_group.site, self.site + self.location, self.location.site, self.site )) @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldModel): +class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): """ An electrical circuit delivered from a PowerPanel. """ @@ -132,17 +130,16 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', - 'amperage', 'max_utilization', 'comments', + 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', + 'voltage', 'amperage', 'max_utilization', 'comments', ] clone_fields = [ - 'power_panel', 'rack', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', - 'available_power', + 'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', + 'max_utilization', 'available_power', ] class Meta: @@ -159,11 +156,12 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo return ( self.power_panel.site.name, self.power_panel.name, - self.rack.group.name if self.rack and self.rack.group else None, + self.rack.location.name if self.rack and self.rack.location else None, self.rack.name if self.rack else None, self.name, self.get_status_display(), self.get_type_display(), + self.mark_connected, self.get_supply_display(), self.get_phase_display(), self.voltage, @@ -199,7 +197,7 @@ class PowerFeed(ChangeLoggedModel, PathEndpoint, CableTermination, CustomFieldMo super().save(*args, **kwargs) @property - def parent(self): + def parent_object(self): return self.power_panel def get_type_class(self): diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index cad20241b..2869c4265 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,26 +10,22 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Sum from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.querysets import RestrictedQuerySet -from utilities.mptt import TreeManager -from utilities.utils import array_to_string, serialize_object +from utilities.utils import array_to_string from .device_components import PowerOutlet, PowerPort from .devices import Device from .power import PowerFeed __all__ = ( 'Rack', - 'RackGroup', 'RackReservation', 'RackRole', ) @@ -39,83 +35,8 @@ __all__ = ( # Racks # -@extras_features('export_templates') -class RackGroup(MPTTModel, ChangeLoggedModel): - """ - Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For - example, if a Site spans a corporate campus, a RackGroup might be defined to represent each building within that - campus. If a Site instead represents a single building, a RackGroup might represent a single room or floor. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.CASCADE, - related_name='rack_groups' - ) - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] - - class Meta: - ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) - - def to_csv(self): - return ( - self.site, - self.parent.name if self.parent else '', - self.name, - self.slug, - self.description, - ) - - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - - def clean(self): - - # Parent RackGroup (if any) must belong to the same Site - if self.parent and self.parent.site != self.site: - raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})") - - -class RackRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. """ @@ -146,7 +67,7 @@ class RackRole(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + return reverse('dcim:rackrole', args=[self.pk]) def to_csv(self): return ( @@ -158,10 +79,10 @@ class RackRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Rack(ChangeLoggedModel, CustomFieldModel): +class Rack(PrimaryModel): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. - Each Rack is assigned to a Site and (optionally) a RackGroup. + Each Rack is assigned to a Site and (optionally) a Location. """ name = models.CharField( max_length=100 @@ -183,13 +104,12 @@ class Rack(ChangeLoggedModel, CustomFieldModel): on_delete=models.PROTECT, related_name='racks' ) - group = models.ForeignKey( - to='dcim.RackGroup', + location = models.ForeignKey( + to='dcim.Location', on_delete=models.SET_NULL, related_name='racks', blank=True, - null=True, - help_text='Assigned group' + null=True ) tenant = models.ForeignKey( to='tenancy.Tenant', @@ -268,25 +188,24 @@ class Rack(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'site', 'group', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', + 'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'comments', ] clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', + 'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', ] class Meta: - ordering = ('site', 'group', '_name', 'pk') # (site, group, name) may be non-unique + ordering = ('site', 'location', '_name', 'pk') # (site, location, name) may be non-unique unique_together = ( - # Name and facility_id must be unique *only* within a RackGroup - ('group', 'name'), - ('group', 'facility_id'), + # Name and facility_id must be unique *only* within a Location + ('location', 'name'), + ('location', 'facility_id'), ) def __str__(self): @@ -298,6 +217,10 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def clean(self): super().clean() + # Validate location/site assignment + if self.site and self.location and self.location.site != self.site: + raise ValidationError(f"Assigned location must belong to parent site ({self.site}).") + # Validate outer dimensions and unit if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit: raise ValidationError("Must specify a unit when setting an outer width/depth") @@ -319,33 +242,17 @@ class Rack(ChangeLoggedModel, CustomFieldModel): min_height ) }) - # Validate that Rack was assigned a group of its same site, if applicable - if self.group: - if self.group.site != self.site: + # Validate that Rack was assigned a Location of its same site, if applicable + if self.location: + if self.location.site != self.site: raise ValidationError({ - 'group': "Rack group must be from the same site, {}.".format(self.site) + 'location': f"Location must be from the same site, {self.site}." }) - def save(self, *args, **kwargs): - - # Record the original site assignment for this rack. - _site_id = None - if self.pk: - _site_id = Rack.objects.get(pk=self.pk).site_id - - super().save(*args, **kwargs) - - # Update racked devices if the assigned Site has been changed. - if _site_id is not None and self.site_id != _site_id: - devices = Device.objects.filter(rack=self) - for device in devices: - device.site = self.site - device.save() - def to_csv(self): return ( self.site.name, - self.group.name if self.group else None, + self.location.name if self.location else None, self.name, self.facility_id, self.tenant.name if self.tenant else None, @@ -561,7 +468,7 @@ class Rack(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RackReservation(ChangeLoggedModel, CustomFieldModel): +class RackReservation(PrimaryModel): """ One or more reserved units within a Rack. """ @@ -587,11 +494,10 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): description = models.CharField( max_length=200 ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() - csv_headers = ['site', 'rack_group', 'rack', 'units', 'tenant', 'user', 'description'] + csv_headers = ['site', 'location', 'rack', 'units', 'tenant', 'user', 'description'] class Meta: ordering = ['created', 'pk'] @@ -632,7 +538,7 @@ class RackReservation(ChangeLoggedModel, CustomFieldModel): def to_csv(self): return ( self.rack.site.name, - self.rack.group if self.rack.group else None, + self.rack.location if self.rack.location else None, self.rack.name, ','.join([str(u) for u in self.units]), self.tenant.name if self.tenant else None, diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 923b33124..3f2c209c3 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,23 +1,23 @@ from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse -from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager +from mptt.models import TreeForeignKey from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem from extras.utils import extras_features +from netbox.models import NestedGroupModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.querysets import RestrictedQuerySet -from utilities.mptt import TreeManager -from utilities.utils import serialize_object __all__ = ( + 'Location', 'Region', 'Site', + 'SiteGroup', ) @@ -25,10 +25,12 @@ __all__ = ( # Regions # -@extras_features('export_templates', 'webhooks') -class Region(MPTTModel, ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Region(NestedGroupModel): """ - Sites can be grouped within geographic Regions. + A region represents a geographic collection of sites. For example, you might create regions representing countries, + states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are + also considered to be members of its parent and ancestor region(s). """ parent = TreeForeignKey( to='self', @@ -51,18 +53,10 @@ class Region(MPTTModel, ChangeLoggedModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] - class MPTTMeta: - order_insertion_by = ['name'] - - def __str__(self): - return self.name - def get_absolute_url(self): - return "{}?region={}".format(reverse('dcim:site_list'), self.slug) + return reverse('dcim:region', args=[self.pk]) def to_csv(self): return ( @@ -78,22 +72,65 @@ class Region(MPTTModel, ChangeLoggedModel): Q(region__in=self.get_descendants()) ).count() - def to_objectchange(self, action): - # Remove MPTT-internal fields - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) + +# +# Site groups +# + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class SiteGroup(NestedGroupModel): + """ + A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and + within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be + nested recursively to form a hierarchy. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + csv_headers = ['name', 'slug', 'parent', 'description'] + + def get_absolute_url(self): + return reverse('dcim:sitegroup', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.slug, + self.parent.name if self.parent else None, + self.description, ) + def get_site_count(self): + return Site.objects.filter( + Q(group=self) | + Q(group__in=self.get_descendants()) + ).count() + # # Sites # @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Site(ChangeLoggedModel, CustomFieldModel): +class Site(PrimaryModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -123,6 +160,13 @@ class Site(ChangeLoggedModel, CustomFieldModel): blank=True, null=True ) + group = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.SET_NULL, + related_name='sites', + blank=True, + null=True + ) tenant = models.ForeignKey( to='tenancy.Tenant', on_delete=models.PROTECT, @@ -188,16 +232,16 @@ class Site(ChangeLoggedModel, CustomFieldModel): images = GenericRelation( to='extras.ImageAttachment' ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', ] clone_fields = [ - 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', + 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', ] @@ -208,7 +252,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): return self.name def get_absolute_url(self): - return reverse('dcim:site', args=[self.slug]) + return reverse('dcim:site', args=[self.pk]) def to_csv(self): return ( @@ -216,6 +260,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): self.slug, self.get_status_display(), self.region.name if self.region else None, + self.group.name if self.group else None, self.tenant.name if self.tenant else None, self.facility, self.asn, @@ -233,3 +278,66 @@ class Site(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return SiteStatusChoices.CSS_CLASSES.get(self.status) + + +# +# Locations +# + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Location(NestedGroupModel): + """ + A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a + site, or a room within a building, for example. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='locations' + ) + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + csv_headers = ['site', 'parent', 'name', 'slug', 'description'] + + class Meta: + ordering = ['site', 'name'] + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + + def get_absolute_url(self): + return reverse('dcim:location', args=[self.pk]) + + def to_csv(self): + return ( + self.site, + self.parent.name if self.parent else '', + self.name, + self.slug, + self.description, + ) + + def clean(self): + super().clean() + + # Parent Location (if any) must belong to the same Site + if self.parent and self.parent.site != self.site: + raise ValidationError(f"Parent location ({self.parent}) must belong to the same site ({self.site})") diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 4a5340748..1dbdca140 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -1,12 +1,13 @@ import logging +from cacheops import invalidate_obj from django.contrib.contenttypes.models import ContentType from django.db.models.signals import post_save, post_delete, pre_delete from django.db import transaction from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, CablePath, Device, PathEndpoint, VirtualChassis +from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis def create_cablepath(node): @@ -30,10 +31,42 @@ def rebuild_paths(obj): with transaction.atomic(): for cp in cable_paths: + invalidate_obj(cp.origin) cp.delete() create_cablepath(cp.origin) +# +# Location/rack/device assignment +# + +@receiver(post_save, sender=Location) +def handle_location_site_change(instance, created, **kwargs): + """ + Update child objects if Site assignment has changed. We intentionally recurse through each child + object instead of calling update() on the QuerySet to ensure the proper change records get created for each. + """ + if not created: + instance.get_descendants().update(site=instance.site) + locations = instance.get_descendants(include_self=True).values_list('pk', flat=True) + Rack.objects.filter(location__in=locations).update(site=instance.site) + Device.objects.filter(location__in=locations).update(site=instance.site) + PowerPanel.objects.filter(location__in=locations).update(site=instance.site) + + +@receiver(post_save, sender=Rack) +def handle_rack_site_change(instance, created, **kwargs): + """ + Update child Devices if Site or Location assignment has changed. + """ + if not created: + Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location) + + +# +# Virtual chassis +# + @receiver(post_save, sender=VirtualChassis) def assign_virtualchassis_master(instance, created, **kwargs): """ @@ -58,6 +91,11 @@ def clear_virtualchassis_members(instance, **kwargs): device.save() +# +# Cables +# + + @receiver(post_save, sender=Cable) def update_connected_endpoints(instance, created, raw=False, **kwargs): """ diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 536be66d9..ed29c72e7 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -1,11 +1,12 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from django.conf import settings from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, @@ -43,12 +44,23 @@ __all__ = ( ) +def get_cabletermination_row_class(record): + if record.mark_connected: + return 'success' + elif record.cable: + return record.cable.get_status_class() + return '' + + # # Device roles # class DeviceRoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', url_params={'role': 'slug'}, @@ -61,7 +73,7 @@ class DeviceRoleTable(BaseTable): ) color = ColorColumn() vm_role = BooleanColumn() - actions = ButtonsColumn(DeviceRole, pk_field='slug') + actions = ButtonsColumn(DeviceRole) class Meta(BaseTable.Meta): model = DeviceRole @@ -75,6 +87,9 @@ class DeviceRoleTable(BaseTable): class PlatformTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) device_count = LinkedCountColumn( viewname='dcim:device_list', url_params={'platform': 'slug'}, @@ -85,7 +100,7 @@ class PlatformTable(BaseTable): url_params={'platform': 'slug'}, verbose_name='VMs' ) - actions = ButtonsColumn(Platform, pk_field='slug') + actions = ButtonsColumn(Platform) class Meta(BaseTable.Meta): model = Platform @@ -109,12 +124,13 @@ class DeviceTable(BaseTable): template_code=DEVICE_LINK ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) + location = tables.Column( + linkify=True + ) rack = tables.Column( linkify=True ) @@ -127,10 +143,18 @@ class DeviceTable(BaseTable): verbose_name='Type', text=lambda record: record.device_type.display_name ) - primary_ip = tables.Column( - linkify=True, - verbose_name='IP Address' - ) + if settings.PREFER_IPV4: + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) + else: + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip6', 'primary_ip4'), + verbose_name='IP Address' + ) primary_ip4 = tables.Column( linkify=True, verbose_name='IPv4 Address' @@ -161,11 +185,11 @@ class DeviceTable(BaseTable): model = Device fields = ( 'pk', 'name', 'status', 'tenant', 'device_role', 'device_type', 'platform', 'serial', 'asset_tag', 'site', - 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', - 'vc_position', 'vc_priority', 'tags', + 'location', 'rack', 'position', 'face', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', + 'virtual_chassis', 'vc_position', 'vc_priority', 'tags', ) default_columns = ( - 'pk', 'name', 'status', 'tenant', 'site', 'rack', 'device_role', 'device_type', 'primary_ip', + 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'device_type', 'primary_ip', ) @@ -174,9 +198,7 @@ class DeviceImportTable(BaseTable): template_code=DEVICE_LINK ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -212,6 +234,7 @@ class DeviceComponentTable(BaseTable): cable = tables.Column( linkify=True ) + mark_connected = BooleanColumn() class Meta(BaseTable.Meta): order_by = ('device', 'name') @@ -227,6 +250,7 @@ class CableTerminationTable(BaseTable): orderable=False, verbose_name='Cable Peer' ) + mark_connected = BooleanColumn() class PathEndpointTable(CableTerminationTable): @@ -239,6 +263,12 @@ class PathEndpointTable(CableTerminationTable): class ConsolePortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleport_list' ) @@ -246,9 +276,10 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', + 'connection', 'tags', ) - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') class DeviceConsolePortTable(ConsolePortTable): @@ -265,23 +296,33 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', + 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_consoleserverports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort - fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags') - default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', + 'connection', 'tags', + ) + default_columns = ('pk', 'device', 'name', 'label', 'type', 'speed', 'description') class DeviceConsoleServerPortTable(ConsoleServerPortTable): @@ -299,15 +340,22 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions' + 'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_peer', + 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerPortTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_powerports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:powerport_list' ) @@ -315,8 +363,8 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable', - 'cable_peer', 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw', + 'cable', 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -336,19 +384,25 @@ class DevicePowerPortTable(PowerPortTable): class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer', - 'connection', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable', + 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class PowerOutletTable(DeviceComponentTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_poweroutlets', + 'args': [Accessor('device_id')], + } + ) power_port = tables.Column( linkify=True ) @@ -359,8 +413,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer', - 'connection', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable', + 'cable_peer', 'connection', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -379,14 +433,14 @@ class DevicePowerOutletTable(PowerOutletTable): class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection', - 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', + 'cable_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } @@ -406,6 +460,13 @@ class BaseInterfaceTable(BaseTable): class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_interfaces', + 'args': [Accessor('device_id')], + } + ) + mgmt_only = BooleanColumn() tags = TagColumn( url_name='dcim:interface_list' ) @@ -414,7 +475,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable model = Interface fields = ( 'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', - 'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', + 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', ) default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description') @@ -426,6 +488,10 @@ class DeviceInterfaceTable(InterfaceTable): '{% endif %}"> {{ value }}', attrs={'td': {'class': 'text-nowrap'}} ) + parent = tables.Column( + linkify=True, + verbose_name='Parent' + ) lag = tables.Column( linkify=True, verbose_name='LAG' @@ -439,19 +505,27 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', - 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', + 'description', 'mark_connected', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', + 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable', - 'connection', 'actions', + 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', + 'cable', 'connection', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class, + 'data-name': lambda record: record.name, } class FrontPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_frontports', + 'args': [Accessor('device_id')], + } + ) rear_port_position = tables.Column( verbose_name='Position' ) @@ -465,8 +539,8 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', - 'cable_peer', 'tags', + 'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', + 'cable', 'cable_peer', 'tags', ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description') @@ -486,26 +560,35 @@ class DeviceFrontPortTable(FrontPortTable): class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', - 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', + 'cable_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class RearPortTable(DeviceComponentTable, CableTerminationTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_rearports', + 'args': [Accessor('device_id')], + } + ) tags = TagColumn( url_name='dcim:rearport_list' ) class Meta(DeviceComponentTable.Meta): model = RearPort - fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags') + fields = ( + 'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', + 'cable_peer', 'tags', + ) default_columns = ('pk', 'device', 'name', 'label', 'type', 'description') @@ -524,17 +607,24 @@ class DeviceRearPortTable(RearPortTable): class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_peer', 'tags', + 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions', ) row_attrs = { - 'class': lambda record: record.cable.get_status_class() if record.cable else '' + 'class': get_cabletermination_row_class } class DeviceBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_devicebays', + 'args': [Accessor('device_id')], + } + ) status = tables.TemplateColumn( template_code=DEVICEBAY_STATUS ) @@ -574,6 +664,12 @@ class DeviceDeviceBayTable(DeviceBayTable): class InventoryItemTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_inventory', + 'args': [Accessor('device_id')], + } + ) manufacturer = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index c4d618cd7..c5b8bb70d 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -37,7 +37,7 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - actions = ButtonsColumn(Manufacturer, pk_field='slug') + actions = ButtonsColumn(Manufacturer) class Meta(BaseTable.Meta): model = Manufacturer diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ae5c2a5c8..ce69b0ede 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -19,9 +19,8 @@ __all__ = ( class PowerPanelTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + site = tables.Column( + linkify=True ) powerfeed_count = LinkedCountColumn( viewname='dcim:powerfeed_list', @@ -34,8 +33,8 @@ class PowerPanelTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPanel - fields = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count', 'tags') - default_columns = ('pk', 'name', 'site', 'rack_group', 'powerfeed_count') + fields = ('pk', 'name', 'site', 'location', 'powerfeed_count', 'tags') + default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') # @@ -69,7 +68,7 @@ class PowerFeedTable(CableTerminationTable): model = PowerFeed fields = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', - 'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', + 'max_utilization', 'mark_connected', 'cable', 'cable_peer', 'connection', 'available_power', 'tags', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 85943dd0d..b56e3bbc4 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,48 +1,45 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from dcim.models import Rack, RackGroup, RackReservation, RackRole -from tenancy.tables import COL_TENANT +from dcim.models import Rack, Location, RackReservation, RackRole +from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, - ToggleColumn, + BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MPTTColumn, + TagColumn, ToggleColumn, UtilizationColumn, ) -from .template_code import MPTT_LINK, RACKGROUP_ELEVATIONS, UTILIZATION_GRAPH +from .template_code import LOCATION_ELEVATIONS __all__ = ( 'RackTable', 'RackDetailTable', - 'RackGroupTable', + 'LocationTable', 'RackReservationTable', 'RackRoleTable', ) # -# Rack groups +# Locations # -class RackGroupTable(BaseTable): +class LocationTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False + name = MPTTColumn( + linkify=True ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')], - verbose_name='Site' + site = tables.Column( + linkify=True ) rack_count = tables.Column( verbose_name='Racks' ) actions = ButtonsColumn( - model=RackGroup, - prepend_template=RACKGROUP_ELEVATIONS + model=Location, + prepend_template=LOCATION_ELEVATIONS ) class Meta(BaseTable.Meta): - model = RackGroup + model = Location fields = ('pk', 'name', 'site', 'rack_count', 'description', 'slug', 'actions') default_columns = ('pk', 'name', 'site', 'rack_count', 'description', 'actions') @@ -80,9 +77,7 @@ class RackTable(BaseTable): site = tables.Column( linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() status = ChoiceFieldColumn() role = ColoredLabelColumn() u_height = tables.TemplateColumn( @@ -105,13 +100,10 @@ class RackDetailTable(RackTable): url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, - orderable=False, + get_utilization = UtilizationColumn( verbose_name='Space' ) - get_power_utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, + get_power_utilization = UtilizationColumn( orderable=False, verbose_name='Power' ) @@ -144,9 +136,7 @@ class RackReservationTable(BaseTable): accessor=Accessor('rack__site'), linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() rack = tables.Column( linkify=True ) diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 76f30f507..b8d06beb6 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,13 +1,13 @@ import django_tables2 as tables -from dcim.models import Region, Site -from tenancy.tables import COL_TENANT -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, TagColumn, ToggleColumn -from .template_code import MPTT_LINK +from dcim.models import Region, Site, SiteGroup +from tenancy.tables import TenantColumn +from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MPTTColumn, TagColumn, ToggleColumn __all__ = ( 'RegionTable', 'SiteTable', + 'SiteGroupTable', ) @@ -17,9 +17,8 @@ __all__ = ( class RegionTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False + name = MPTTColumn( + linkify=True ) site_count = tables.Column( verbose_name='Sites' @@ -32,6 +31,26 @@ class RegionTable(BaseTable): default_columns = ('pk', 'name', 'site_count', 'description', 'actions') +# +# Site groups +# + +class SiteGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn( + linkify=True + ) + site_count = tables.Column( + verbose_name='Sites' + ) + actions = ButtonsColumn(SiteGroup) + + class Meta(BaseTable.Meta): + model = SiteGroup + fields = ('pk', 'name', 'slug', 'site_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + + # # Sites # @@ -45,9 +64,10 @@ class SiteTable(BaseTable): region = tables.Column( linkify=True ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT + group = tables.Column( + linkify=True ) + tenant = TenantColumn() tags = TagColumn( url_name='dcim:site_list' ) @@ -55,8 +75,8 @@ class SiteTable(BaseTable): class Meta(BaseTable.Meta): model = Site fields = ( - 'pk', 'name', 'slug', 'status', 'facility', 'region', 'tenant', 'asn', 'time_zone', 'description', + 'pk', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'tags', ) - default_columns = ('pk', 'name', 'status', 'facility', 'region', 'tenant', 'asn', 'description') + default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'asn', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 4a5aedb29..4324f802f 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -1,6 +1,6 @@ CABLETERMINATION = """ {% if value %} - {{ value.parent }} + {{ value.parent_object }} {{ value }} {% else %} @@ -56,16 +56,6 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ -MPTT_LINK = """ -{% if record.get_children %} - -{% else %} - -{% endif %} - {{ record.name }} - -""" - POWERFEED_CABLE = """ {{ value }} @@ -74,22 +64,17 @@ POWERFEED_CABLE = """ """ POWERFEED_CABLETERMINATION = """ -{{ value.parent }} +{{ value.parent_object }} {{ value }} """ -RACKGROUP_ELEVATIONS = """ - +LOCATION_ELEVATIONS = """ + """ -UTILIZATION_GRAPH = """ -{% load helpers %} -{% utilization_graph value %} -""" - # # Device component buttons # @@ -98,19 +83,28 @@ CONSOLEPORT_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -118,19 +112,28 @@ CONSOLESERVERPORT_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -138,18 +141,27 @@ POWERPORT_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -157,12 +169,21 @@ POWEROUTLET_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - + {% if not record.mark_connected %} + + + + {% else %} + + {% endif %} {% endif %} """ @@ -175,20 +196,29 @@ INTERFACE_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif record.is_connectable and perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -196,22 +226,31 @@ FRONTPORT_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ @@ -219,20 +258,29 @@ REARPORT_BUTTONS = """ {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} + {% if perms.dcim.delete_cable %} + + + + {% endif %} {% elif perms.dcim.add_cable %} - - - - + {% if not record.mark_connected %} + + + + + {% else %} + + {% endif %} {% endif %} """ diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 77c3b4786..594a4d4f5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -4,12 +4,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, Manufacturer, - InventoryItem, Platform, PowerFeed, PowerPort, PowerPortTemplate, PowerOutlet, PowerOutletTemplate, PowerPanel, - Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, VirtualChassis, -) +from dcim.models import * from ipam.models import VLAN from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterType @@ -64,7 +59,7 @@ class Mixins: class RegionTest(APIViewTestCases.APIViewTestCase): model = Region - brief_fields = ['_depth', 'id', 'name', 'site_count', 'slug', 'url'] + brief_fields = ['_depth', 'display', 'id', 'name', 'site_count', 'slug', 'url'] create_data = [ { 'name': 'Region 4', @@ -93,7 +88,7 @@ class RegionTest(APIViewTestCases.APIViewTestCase): class SiteTest(APIViewTestCases.APIViewTestCase): model = Site - brief_fields = ['id', 'name', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'status': 'planned', } @@ -102,14 +97,19 @@ class SiteTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): regions = ( - Region.objects.create(name='Test Region 1', slug='test-region-1'), - Region.objects.create(name='Test Region 2', slug='test-region-2'), + Region.objects.create(name='Region 1', slug='region-1'), + Region.objects.create(name='Region 2', slug='region-2'), + ) + + groups = ( + SiteGroup.objects.create(name='Site Group 1', slug='site-group-1'), + SiteGroup.objects.create(name='Site Group 2', slug='site-group-2'), ) sites = ( - Site(region=regions[0], name='Site 1', slug='site-1'), - Site(region=regions[0], name='Site 2', slug='site-2'), - Site(region=regions[0], name='Site 3', slug='site-3'), + Site(region=regions[0], group=groups[0], name='Site 1', slug='site-1'), + Site(region=regions[0], group=groups[0], name='Site 2', slug='site-2'), + Site(region=regions[0], group=groups[0], name='Site 3', slug='site-3'), ) Site.objects.bulk_create(sites) @@ -118,26 +118,29 @@ class SiteTest(APIViewTestCases.APIViewTestCase): 'name': 'Site 4', 'slug': 'site-4', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 5', 'slug': 'site-5', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, { 'name': 'Site 6', 'slug': 'site-6', 'region': regions[1].pk, + 'group': groups[1].pk, 'status': SiteStatusChoices.STATUS_ACTIVE, }, ] -class RackGroupTest(APIViewTestCases.APIViewTestCase): - model = RackGroup - brief_fields = ['_depth', 'id', 'name', 'rack_count', 'slug', 'url'] +class LocationTest(APIViewTestCases.APIViewTestCase): + model = Location + brief_fields = ['_depth', 'display', 'id', 'name', 'rack_count', 'slug', 'url'] bulk_update_data = { 'description': 'New description', } @@ -151,40 +154,40 @@ class RackGroupTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) - parent_rack_groups = ( - RackGroup.objects.create(site=sites[0], name='Parent Rack Group 1', slug='parent-rack-group-1'), - RackGroup.objects.create(site=sites[1], name='Parent Rack Group 2', slug='parent-rack-group-2'), + 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'), ) - RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1', parent=parent_rack_groups[0]) - RackGroup.objects.create(site=sites[0], name='Rack Group 2', slug='rack-group-2', parent=parent_rack_groups[0]) - RackGroup.objects.create(site=sites[0], name='Rack Group 3', slug='rack-group-3', parent=parent_rack_groups[0]) + 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]) cls.create_data = [ { - 'name': 'Test Rack Group 4', - 'slug': 'test-rack-group-4', + 'name': 'Test Location 4', + 'slug': 'test-location-4', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, { - 'name': 'Test Rack Group 5', - 'slug': 'test-rack-group-5', + 'name': 'Test Location 5', + 'slug': 'test-location-5', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, { - 'name': 'Test Rack Group 6', - 'slug': 'test-rack-group-6', + 'name': 'Test Location 6', + 'slug': 'test-location-6', 'site': sites[1].pk, - 'parent': parent_rack_groups[1].pk, + 'parent': parent_locations[1].pk, }, ] class RackRoleTest(APIViewTestCases.APIViewTestCase): model = RackRole - brief_fields = ['id', 'name', 'rack_count', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'rack_count', 'slug', 'url'] create_data = [ { 'name': 'Rack Role 4', @@ -219,7 +222,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTest(APIViewTestCases.APIViewTestCase): model = Rack - brief_fields = ['device_count', 'display_name', 'id', 'name', 'url'] + brief_fields = ['device_count', 'display', 'display_name', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -233,9 +236,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup.objects.create(site=sites[0], name='Rack Group 1', slug='rack-group-1'), - RackGroup.objects.create(site=sites[1], name='Rack Group 2', slug='rack-group-2'), + locations = ( + Location.objects.create(site=sites[0], name='Location 1', slug='location-1'), + Location.objects.create(site=sites[1], name='Location 2', slug='location-2'), ) rack_roles = ( @@ -245,9 +248,9 @@ class RackTest(APIViewTestCases.APIViewTestCase): RackRole.objects.bulk_create(rack_roles) racks = ( - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 1'), - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 2'), - Rack(site=sites[0], group=rack_groups[0], role=rack_roles[0], name='Rack 3'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 1'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 2'), + Rack(site=sites[0], location=locations[0], role=rack_roles[0], name='Rack 3'), ) Rack.objects.bulk_create(racks) @@ -255,19 +258,19 @@ class RackTest(APIViewTestCases.APIViewTestCase): { 'name': 'Test Rack 4', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, { 'name': 'Test Rack 5', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, { 'name': 'Test Rack 6', 'site': sites[1].pk, - 'group': rack_groups[1].pk, + 'location': locations[1].pk, 'role': rack_roles[1].pk, }, ] @@ -307,7 +310,7 @@ class RackTest(APIViewTestCases.APIViewTestCase): class RackReservationTest(APIViewTestCases.APIViewTestCase): model = RackReservation - brief_fields = ['id', 'units', 'url', 'user'] + brief_fields = ['display', 'id', 'units', 'url', 'user'] bulk_update_data = { 'description': 'New description', } @@ -358,7 +361,7 @@ class RackReservationTest(APIViewTestCases.APIViewTestCase): class ManufacturerTest(APIViewTestCases.APIViewTestCase): model = Manufacturer - brief_fields = ['devicetype_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['devicetype_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Manufacturer 4', @@ -390,7 +393,7 @@ class ManufacturerTest(APIViewTestCases.APIViewTestCase): class DeviceTypeTest(APIViewTestCases.APIViewTestCase): model = DeviceType - brief_fields = ['device_count', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] + brief_fields = ['device_count', 'display', 'display_name', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { 'part_number': 'ABC123', } @@ -432,7 +435,7 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase): class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsolePortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -469,7 +472,7 @@ class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase): class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = ConsoleServerPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -506,7 +509,7 @@ class ConsoleServerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -543,7 +546,7 @@ class PowerPortTemplateTest(APIViewTestCases.APIViewTestCase): class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): model = PowerOutletTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -580,7 +583,7 @@ class PowerOutletTemplateTest(APIViewTestCases.APIViewTestCase): class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): model = InterfaceTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -620,7 +623,7 @@ class InterfaceTemplateTest(APIViewTestCases.APIViewTestCase): class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): model = FrontPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -691,7 +694,7 @@ class FrontPortTemplateTest(APIViewTestCases.APIViewTestCase): class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): model = RearPortTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -731,7 +734,7 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): model = DeviceBayTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -740,7 +743,10 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') devicetype = DeviceType.objects.create( - manufacturer=manufacturer, model='Device Type 1', slug='device-type-1' + manufacturer=manufacturer, + model='Device Type 1', + slug='device-type-1', + subdevice_role=SubdeviceRoleChoices.ROLE_PARENT ) device_bay_templates = ( @@ -768,7 +774,7 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole - brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Device Role 4', @@ -803,7 +809,7 @@ class DeviceRoleTest(APIViewTestCases.APIViewTestCase): class PlatformTest(APIViewTestCases.APIViewTestCase): model = Platform - brief_fields = ['device_count', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] + brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] create_data = [ { 'name': 'Platform 4', @@ -835,7 +841,7 @@ class PlatformTest(APIViewTestCases.APIViewTestCase): class DeviceTest(APIViewTestCases.APIViewTestCase): model = Device - brief_fields = ['display_name', 'id', 'name', 'url'] + brief_fields = ['display', 'display_name', 'id', 'name', 'url'] bulk_update_data = { 'status': 'failed', } @@ -976,7 +982,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1015,7 +1021,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1054,7 +1060,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1093,7 +1099,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1132,7 +1138,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1190,7 +1196,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1248,7 +1254,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort - brief_fields = ['cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1290,7 +1296,7 @@ class RearPortTest(APIViewTestCases.APIViewTestCase): class DeviceBayTest(APIViewTestCases.APIViewTestCase): model = DeviceBay - brief_fields = ['device', 'id', 'name', 'url'] + brief_fields = ['device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1353,7 +1359,7 @@ class DeviceBayTest(APIViewTestCases.APIViewTestCase): class InventoryItemTest(APIViewTestCases.APIViewTestCase): model = InventoryItem - brief_fields = ['_depth', 'device', 'id', 'name', 'url'] + brief_fields = ['_depth', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1391,7 +1397,7 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): class CableTest(APIViewTestCases.APIViewTestCase): model = Cable - brief_fields = ['id', 'label', 'url'] + brief_fields = ['display', 'id', 'label', 'url'] bulk_update_data = { 'length': 100, 'length_unit': 'm', @@ -1576,7 +1582,7 @@ class VirtualChassisTest(APIViewTestCases.APIViewTestCase): class PowerPanelTest(APIViewTestCases.APIViewTestCase): model = PowerPanel - brief_fields = ['id', 'name', 'powerfeed_count', 'url'] + brief_fields = ['display', 'id', 'name', 'powerfeed_count', 'url'] @classmethod def setUpTestData(cls): @@ -1585,17 +1591,17 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): Site.objects.create(name='Site 2', slug='site-2'), ) - rack_groups = ( - RackGroup.objects.create(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup.objects.create(name='Rack Group 2', slug='rack-group-2', site=sites[0]), - RackGroup.objects.create(name='Rack Group 3', slug='rack-group-3', site=sites[0]), - RackGroup.objects.create(name='Rack Group 4', slug='rack-group-3', site=sites[1]), + locations = ( + Location.objects.create(name='Location 1', slug='location-1', site=sites[0]), + Location.objects.create(name='Location 2', slug='location-2', site=sites[0]), + Location.objects.create(name='Location 3', slug='location-3', site=sites[0]), + Location.objects.create(name='Location 4', slug='location-3', site=sites[1]), ) power_panels = ( - PowerPanel(site=sites[0], rack_group=rack_groups[0], name='Power Panel 1'), - PowerPanel(site=sites[0], rack_group=rack_groups[1], name='Power Panel 2'), - PowerPanel(site=sites[0], rack_group=rack_groups[2], name='Power Panel 3'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), + PowerPanel(site=sites[0], location=locations[1], name='Power Panel 2'), + PowerPanel(site=sites[0], location=locations[2], name='Power Panel 3'), ) PowerPanel.objects.bulk_create(power_panels) @@ -1603,29 +1609,29 @@ class PowerPanelTest(APIViewTestCases.APIViewTestCase): { 'name': 'Power Panel 4', 'site': sites[0].pk, - 'rack_group': rack_groups[0].pk, + 'location': locations[0].pk, }, { 'name': 'Power Panel 5', 'site': sites[0].pk, - 'rack_group': rack_groups[1].pk, + 'location': locations[1].pk, }, { 'name': 'Power Panel 6', 'site': sites[0].pk, - 'rack_group': rack_groups[2].pk, + 'location': locations[2].pk, }, ] cls.bulk_update_data = { 'site': sites[1].pk, - 'rack_group': rack_groups[3].pk + 'location': locations[3].pk } class PowerFeedTest(APIViewTestCases.APIViewTestCase): model = PowerFeed - brief_fields = ['cable', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -1633,20 +1639,20 @@ class PowerFeedTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): site = Site.objects.create(name='Site 1', slug='site-1') - rackgroup = RackGroup.objects.create(site=site, name='Rack Group 1', slug='rack-group-1') + location = Location.objects.create(site=site, name='Location 1', slug='location-1') rackrole = RackRole.objects.create(name='Rack Role 1', slug='rack-role-1', color='ff0000') racks = ( - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 1'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 2'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 3'), - Rack(site=site, group=rackgroup, role=rackrole, name='Rack 4'), + Rack(site=site, location=location, role=rackrole, name='Rack 1'), + Rack(site=site, location=location, role=rackrole, name='Rack 2'), + Rack(site=site, location=location, role=rackrole, name='Rack 3'), + Rack(site=site, location=location, role=rackrole, name='Rack 4'), ) Rack.objects.bulk_create(racks) power_panels = ( - PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 1'), - PowerPanel(site=site, rack_group=rackgroup, name='Power Panel 2'), + PowerPanel(site=site, location=location, name='Power Panel 1'), + PowerPanel(site=site, location=location, name='Power Panel 2'), ) PowerPanel.objects.bulk_create(power_panels) diff --git a/netbox/dcim/tests/test_filters.py b/netbox/dcim/tests/test_filters.py index f209cd1f4..a68733f86 100644 --- a/netbox/dcim/tests/test_filters.py +++ b/netbox/dcim/tests/test_filters.py @@ -3,13 +3,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filters import * -from dcim.models import ( - Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, - DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerPortTemplate, PowerOutlet, - PowerOutletTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, -) +from dcim.models import * from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterType @@ -65,6 +59,56 @@ class RegionTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) +class SiteGroupTestCase(TestCase): + queryset = SiteGroup.objects.all() + filterset = SiteGroupFilterSet + + @classmethod + def setUpTestData(cls): + + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1', description='A'), + SiteGroup(name='Site Group 2', slug='site-group-2', description='B'), + SiteGroup(name='Site Group 3', slug='site-group-3', description='C'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + child_sitegroups = ( + SiteGroup(name='Site Group 1A', slug='site-group-1a', parent=sitegroups[0]), + SiteGroup(name='Site Group 1B', slug='site-group-1b', parent=sitegroups[0]), + SiteGroup(name='Site Group 2A', slug='site-group-2a', parent=sitegroups[1]), + SiteGroup(name='Site Group 2B', slug='site-group-2b', parent=sitegroups[1]), + SiteGroup(name='Site Group 3A', slug='site-group-3a', parent=sitegroups[2]), + SiteGroup(name='Site Group 3B', slug='site-group-3b', parent=sitegroups[2]), + ) + for sitegroup in child_sitegroups: + sitegroup.save() + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Site Group 1', 'Site Group 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_slug(self): + params = {'slug': ['site-group-1', 'site-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_sitegroups = SiteGroup.objects.filter(parent__isnull=True)[:2] + params = {'parent_id': [parent_sitegroups[0].pk, parent_sitegroups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'parent': [parent_sitegroups[0].slug, parent_sitegroups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class SiteTestCase(TestCase): queryset = Site.objects.all() filterset = SiteFilterSet @@ -80,6 +124,14 @@ class SiteTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + tenant_groups = ( TenantGroup(name='Tenant group 1', slug='tenant-group-1'), TenantGroup(name='Tenant group 2', slug='tenant-group-2'), @@ -96,9 +148,9 @@ class SiteTestCase(TestCase): Tenant.objects.bulk_create(tenants) sites = ( - Site(name='Site 1', slug='site-1', region=regions[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), - Site(name='Site 2', slug='site-2', region=regions[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), - Site(name='Site 3', slug='site-3', region=regions[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), ) Site.objects.bulk_create(sites) @@ -153,6 +205,13 @@ class SiteTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + groups = SiteGroup.objects.all()[:2] + params = {'group_id': [groups[0].pk, groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [groups[0].slug, groups[1].slug]} + 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]} @@ -168,9 +227,9 @@ class SiteTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RackGroupTestCase(TestCase): - queryset = RackGroup.objects.all() - filterset = RackGroupFilterSet +class LocationTestCase(TestCase): + queryset = Location.objects.all() + filterset = LocationFilterSet @classmethod def setUpTestData(cls): @@ -183,39 +242,47 @@ class RackGroupTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - parent_rack_groups = ( - RackGroup(name='Parent Rack Group 1', slug='parent-rack-group-1', site=sites[0]), - RackGroup(name='Parent Rack Group 2', slug='parent-rack-group-2', site=sites[1]), - RackGroup(name='Parent Rack Group 3', slug='parent-rack-group-3', site=sites[2]), + parent_locations = ( + Location(name='Parent Location 1', slug='parent-location-1', site=sites[0]), + Location(name='Parent Location 2', slug='parent-location-2', site=sites[1]), + Location(name='Parent Location 3', slug='parent-location-3', site=sites[2]), ) - for rackgroup in parent_rack_groups: - rackgroup.save() + for location in parent_locations: + location.save() - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0], parent=parent_rack_groups[0], description='A'), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1], parent=parent_rack_groups[1], description='B'), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2], parent=parent_rack_groups[2], description='C'), + 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'), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() def test_id(self): params = {'id': self.queryset.values_list('pk', flat=True)[:2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_name(self): - params = {'name': ['Rack Group 1', 'Rack Group 2']} + params = {'name': ['Location 1', 'Location 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_slug(self): - params = {'slug': ['rack-group-1', 'rack-group-2']} + params = {'slug': ['location-1', 'location-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_description(self): @@ -229,6 +296,13 @@ class RackGroupTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -237,7 +311,7 @@ class RackGroupTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_parent(self): - parent_groups = RackGroup.objects.filter(name__startswith='Parent')[:2] + parent_groups = Location.objects.filter(name__startswith='Parent')[:2] params = {'parent_id': [parent_groups[0].pk, parent_groups[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} @@ -290,20 +364,28 @@ class RackTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + 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 rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() rack_roles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -328,9 +410,9 @@ class RackTestCase(TestCase): Tenant.objects.bulk_create(tenants) racks = ( - Rack(name='Rack 1', facility_id='rack-1', site=sites[0], group=rack_groups[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 2', facility_id='rack-2', site=sites[1], group=rack_groups[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_19IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), - Rack(name='Rack 3', facility_id='rack-3', site=sites[2], group=rack_groups[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), + Rack(name='Rack 1', facility_id='rack-1', site=sites[0], location=locations[0], tenant=tenants[0], status=RackStatusChoices.STATUS_ACTIVE, role=rack_roles[0], serial='ABC', asset_tag='1001', type=RackTypeChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, u_height=42, desc_units=False, outer_width=100, outer_depth=100, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), + Rack(name='Rack 2', facility_id='rack-2', site=sites[1], location=locations[1], tenant=tenants[1], status=RackStatusChoices.STATUS_PLANNED, role=rack_roles[1], serial='DEF', asset_tag='1002', type=RackTypeChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, u_height=43, desc_units=False, outer_width=200, outer_depth=200, outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER), + Rack(name='Rack 3', facility_id='rack-3', site=sites[2], location=locations[2], tenant=tenants[2], status=RackStatusChoices.STATUS_RESERVED, role=rack_roles[2], serial='GHI', asset_tag='1003', type=RackTypeChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, u_height=44, desc_units=True, outer_width=300, outer_depth=300, outer_unit=RackDimensionUnitChoices.UNIT_INCH), ) Rack.objects.bulk_create(racks) @@ -351,13 +433,11 @@ class RackTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_type(self): - # TODO: Test for multiple values - params = {'type': RackTypeChoices.TYPE_2POST} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_width(self): - # TODO: Test for multiple values - params = {'width': RackWidthChoices.WIDTH_19IN} + params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_u_height(self): @@ -390,6 +470,13 @@ class RackTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -397,11 +484,11 @@ class RackTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_group(self): - groups = RackGroup.objects.all()[:2] - params = {'group_id': [groups[0].pk, groups[1].pk]} + 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 = {'group': [groups[0].slug, groups[1].slug]} + params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_status(self): @@ -450,18 +537,18 @@ class RackReservationTestCase(TestCase): ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + 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 rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_groups[0]), - Rack(name='Rack 2', site=sites[1], group=rack_groups[1]), - Rack(name='Rack 3', site=sites[2], group=rack_groups[2]), + 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) @@ -505,20 +592,19 @@ class RackReservationTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_group(self): - groups = RackGroup.objects.all()[:2] - params = {'group_id': [groups[0].pk, groups[1].pk]} + 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 = {'group': [groups[0].slug, groups[1].slug]} + params = {'location': [locations[0].slug, locations[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_user(self): users = User.objects.all()[:2] params = {'user_id': [users[0].pk, users[1].pk]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - # TODO: Filtering by username is broken - # params = {'user': [users[0].username, users[1].username]} - # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'user': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_tenant(self): tenants = Tenant.objects.all()[:2] @@ -1164,25 +1250,33 @@ class DeviceTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + 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 rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_groups[0]), - Rack(name='Rack 2', site=sites[1], group=rack_groups[1]), - Rack(name='Rack 3', site=sites[2], group=rack_groups[2]), + 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) @@ -1210,9 +1304,9 @@ class DeviceTestCase(TestCase): Tenant.objects.bulk_create(tenants) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1327,6 +1421,13 @@ class DeviceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1334,9 +1435,9 @@ class DeviceTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rackgroup(self): - rack_groups = RackGroup.objects.all()[:2] - params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]} + 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) def test_rack(self): @@ -1466,10 +1567,19 @@ class ConsolePortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1491,9 +1601,9 @@ class ConsolePortTestCase(TestCase): ConsoleServerPort.objects.bulk_create(console_server_ports) console_ports = ( - ConsolePort(device=devices[0], name='Console Port 1', description='First'), - ConsolePort(device=devices[1], name='Console Port 2', description='Second'), - ConsolePort(device=devices[2], name='Console Port 3', description='Third'), + ConsolePort(device=devices[0], name='Console Port 1', label='A', description='First'), + ConsolePort(device=devices[1], name='Console Port 2', label='B', description='Second'), + ConsolePort(device=devices[2], name='Console Port 3', label='C', description='Third'), ) ConsolePort.objects.bulk_create(console_ports) @@ -1510,6 +1620,10 @@ class ConsolePortTestCase(TestCase): params = {'name': ['Console Port 1', 'Console Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1527,6 +1641,13 @@ class ConsolePortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1562,10 +1683,19 @@ class ConsoleServerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1587,9 +1717,9 @@ class ConsoleServerPortTestCase(TestCase): ConsolePort.objects.bulk_create(console_ports) console_server_ports = ( - ConsoleServerPort(device=devices[0], name='Console Server Port 1', description='First'), - ConsoleServerPort(device=devices[1], name='Console Server Port 2', description='Second'), - ConsoleServerPort(device=devices[2], name='Console Server Port 3', description='Third'), + ConsoleServerPort(device=devices[0], name='Console Server Port 1', label='A', description='First'), + ConsoleServerPort(device=devices[1], name='Console Server Port 2', label='B', description='Second'), + ConsoleServerPort(device=devices[2], name='Console Server Port 3', label='C', description='Third'), ) ConsoleServerPort.objects.bulk_create(console_server_ports) @@ -1606,6 +1736,10 @@ class ConsoleServerPortTestCase(TestCase): params = {'name': ['Console Server Port 1', 'Console Server Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1623,6 +1757,13 @@ class ConsoleServerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1658,10 +1799,19 @@ class PowerPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1683,9 +1833,9 @@ class PowerPortTestCase(TestCase): PowerOutlet.objects.bulk_create(power_outlets) power_ports = ( - PowerPort(device=devices[0], name='Power Port 1', maximum_draw=100, allocated_draw=50, description='First'), - PowerPort(device=devices[1], name='Power Port 2', maximum_draw=200, allocated_draw=100, description='Second'), - PowerPort(device=devices[2], name='Power Port 3', maximum_draw=300, allocated_draw=150, description='Third'), + PowerPort(device=devices[0], name='Power Port 1', label='A', maximum_draw=100, allocated_draw=50, description='First'), + PowerPort(device=devices[1], name='Power Port 2', label='B', maximum_draw=200, allocated_draw=100, description='Second'), + PowerPort(device=devices[2], name='Power Port 3', label='C', maximum_draw=300, allocated_draw=150, description='Third'), ) PowerPort.objects.bulk_create(power_ports) @@ -1702,6 +1852,10 @@ class PowerPortTestCase(TestCase): params = {'name': ['Power Port 1', 'Power Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1727,6 +1881,13 @@ class PowerPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1762,10 +1923,19 @@ class PowerOutletTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1787,9 +1957,9 @@ class PowerOutletTestCase(TestCase): PowerPort.objects.bulk_create(power_ports) power_outlets = ( - PowerOutlet(device=devices[0], name='Power Outlet 1', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), - PowerOutlet(device=devices[1], name='Power Outlet 2', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), - PowerOutlet(device=devices[2], name='Power Outlet 3', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), + PowerOutlet(device=devices[0], name='Power Outlet 1', label='A', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A, description='First'), + PowerOutlet(device=devices[1], name='Power Outlet 2', label='B', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B, description='Second'), + PowerOutlet(device=devices[2], name='Power Outlet 3', label='C', feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C, description='Third'), ) PowerOutlet.objects.bulk_create(power_outlets) @@ -1806,6 +1976,10 @@ class PowerOutletTestCase(TestCase): params = {'name': ['Power Outlet 1', 'Power Outlet 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1828,6 +2002,13 @@ class PowerOutletTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1863,10 +2044,19 @@ class InterfaceTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -1882,12 +2072,12 @@ class InterfaceTestCase(TestCase): Device.objects.bulk_create(devices) interfaces = ( - Interface(device=devices[0], name='Interface 1', 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'), - Interface(device=devices[1], name='Interface 2', 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'), - Interface(device=devices[2], name='Interface 3', 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'), - Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), - Interface(device=devices[3], name='Interface 6', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), + Interface(device=devices[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'), + Interface(device=devices[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'), + Interface(device=devices[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'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True), + Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False), ) Interface.objects.bulk_create(interfaces) @@ -1904,6 +2094,10 @@ class InterfaceTestCase(TestCase): params = {'name': ['Interface 1', 'Interface 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): params = {'connected': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) @@ -1934,6 +2128,34 @@ class InterfaceTestCase(TestCase): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_parent(self): + # Create child interfaces + parent_interface = Interface.objects.first() + child_interfaces = ( + Interface(device=parent_interface.device, name='Child 1', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=parent_interface.device, name='Child 2', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + Interface(device=parent_interface.device, name='Child 3', parent=parent_interface, type=InterfaceTypeChoices.TYPE_VIRTUAL), + ) + Interface.objects.bulk_create(child_interfaces) + + params = {'parent_id': [parent_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_lag(self): + # Create LAG members + device = Device.objects.first() + lag_interface = Interface(device=device, name='LAG', type=InterfaceTypeChoices.TYPE_LAG) + lag_interface.save() + lag_members = ( + Interface(device=device, name='Member 1', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device, name='Member 2', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=device, name='Member 3', lag=lag_interface, type=InterfaceTypeChoices.TYPE_1GE_FIXED), + ) + Interface.objects.bulk_create(lag_members) + + params = {'lag_id': [lag_interface.pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -1941,6 +2163,13 @@ class InterfaceTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -1990,10 +2219,19 @@ class FrontPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2019,12 +2257,12 @@ class FrontPortTestCase(TestCase): RearPort.objects.bulk_create(rear_ports) front_ports = ( - FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), - FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), - FrontPort(device=devices[2], name='Front Port 3', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), - FrontPort(device=devices[3], name='Front Port 4', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 5', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), - FrontPort(device=devices[3], name='Front Port 6', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), + FrontPort(device=devices[0], name='Front Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0], rear_port_position=1, description='First'), + FrontPort(device=devices[1], name='Front Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, rear_port=rear_ports[1], rear_port_position=2, description='Second'), + FrontPort(device=devices[2], name='Front Port 3', label='C', type=PortTypeChoices.TYPE_BNC, rear_port=rear_ports[2], rear_port_position=3, description='Third'), + FrontPort(device=devices[3], name='Front Port 4', label='D', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[3], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 5', label='E', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[4], rear_port_position=1), + FrontPort(device=devices[3], name='Front Port 6', label='F', type=PortTypeChoices.TYPE_FC, rear_port=rear_ports[5], rear_port_position=1), ) FrontPort.objects.bulk_create(front_ports) @@ -2041,6 +2279,10 @@ class FrontPortTestCase(TestCase): params = {'name': ['Front Port 1', 'Front Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): # TODO: Test for multiple values params = {'type': PortTypeChoices.TYPE_8P8C} @@ -2057,6 +2299,13 @@ class FrontPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2092,10 +2341,19 @@ class RearPortTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2111,12 +2369,12 @@ class RearPortTestCase(TestCase): Device.objects.bulk_create(devices) rear_ports = ( - RearPort(device=devices[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), - RearPort(device=devices[1], name='Rear Port 2', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), - RearPort(device=devices[2], name='Rear Port 3', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), - RearPort(device=devices[3], name='Rear Port 4', type=PortTypeChoices.TYPE_FC, positions=4), - RearPort(device=devices[3], name='Rear Port 5', type=PortTypeChoices.TYPE_FC, positions=5), - RearPort(device=devices[3], name='Rear Port 6', type=PortTypeChoices.TYPE_FC, positions=6), + RearPort(device=devices[0], name='Rear Port 1', label='A', type=PortTypeChoices.TYPE_8P8C, positions=1, description='First'), + RearPort(device=devices[1], name='Rear Port 2', label='B', type=PortTypeChoices.TYPE_110_PUNCH, positions=2, description='Second'), + RearPort(device=devices[2], name='Rear Port 3', label='C', type=PortTypeChoices.TYPE_BNC, positions=3, description='Third'), + RearPort(device=devices[3], name='Rear Port 4', label='D', type=PortTypeChoices.TYPE_FC, positions=4), + RearPort(device=devices[3], name='Rear Port 5', label='E', type=PortTypeChoices.TYPE_FC, positions=5), + RearPort(device=devices[3], name='Rear Port 6', label='F', type=PortTypeChoices.TYPE_FC, positions=6), ) RearPort.objects.bulk_create(rear_ports) @@ -2133,6 +2391,10 @@ class RearPortTestCase(TestCase): params = {'name': ['Rear Port 1', 'Rear Port 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_type(self): # TODO: Test for multiple values params = {'type': PortTypeChoices.TYPE_8P8C} @@ -2153,6 +2415,13 @@ class RearPortTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2188,10 +2457,19 @@ class DeviceBayTestCase(TestCase): ) for region in regions: region.save() + + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = Site.objects.bulk_create(( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') @@ -2206,9 +2484,9 @@ class DeviceBayTestCase(TestCase): Device.objects.bulk_create(devices) device_bays = ( - DeviceBay(device=devices[0], name='Device Bay 1', description='First'), - DeviceBay(device=devices[1], name='Device Bay 2', description='Second'), - DeviceBay(device=devices[2], name='Device Bay 3', description='Third'), + DeviceBay(device=devices[0], name='Device Bay 1', label='A', description='First'), + DeviceBay(device=devices[1], name='Device Bay 2', label='B', description='Second'), + DeviceBay(device=devices[2], name='Device Bay 3', label='C', description='Third'), ) DeviceBay.objects.bulk_create(device_bays) @@ -2220,6 +2498,10 @@ class DeviceBayTestCase(TestCase): params = {'name': ['Device Bay 1', 'Device Bay 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2231,6 +2513,13 @@ class DeviceBayTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2271,10 +2560,18 @@ class InventoryItemTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2286,9 +2583,9 @@ class InventoryItemTestCase(TestCase): Device.objects.bulk_create(devices) inventory_items = ( - InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), - InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), - InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), + InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'), + InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'), + InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'), ) for i in inventory_items: i.save() @@ -2309,6 +2606,10 @@ class InventoryItemTestCase(TestCase): params = {'name': ['Inventory Item 1', 'Inventory Item 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_part_id(self): params = {'part_id': ['1001', '1002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -2331,6 +2632,13 @@ class InventoryItemTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2384,10 +2692,18 @@ class VirtualChassisTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2402,9 +2718,9 @@ class VirtualChassisTestCase(TestCase): Device.objects.bulk_create(devices) virtual_chassis = ( - VirtualChassis(master=devices[0], domain='Domain 1'), - VirtualChassis(master=devices[2], domain='Domain 2'), - VirtualChassis(master=devices[4], domain='Domain 3'), + VirtualChassis(name='VC 1', master=devices[0], domain='Domain 1'), + VirtualChassis(name='VC 2', master=devices[2], domain='Domain 2'), + VirtualChassis(name='VC 3', master=devices[4], domain='Domain 3'), ) VirtualChassis.objects.bulk_create(virtual_chassis) @@ -2420,6 +2736,17 @@ class VirtualChassisTestCase(TestCase): params = {'domain': ['Domain 1', 'Domain 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_master(self): + masters = Device.objects.all() + params = {'master_id': [masters[0].pk, masters[2].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'master': [masters[0].name, masters[2].name]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['VC 1', 'VC 2']} + 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]} @@ -2427,6 +2754,13 @@ class VirtualChassisTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2574,25 +2908,33 @@ class PowerPanelTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=sites[2]), + 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 rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() power_panels = ( - PowerPanel(name='Power Panel 1', site=sites[0], rack_group=rack_groups[0]), - PowerPanel(name='Power Panel 2', site=sites[1], rack_group=rack_groups[1]), - PowerPanel(name='Power Panel 3', site=sites[2], rack_group=rack_groups[2]), + PowerPanel(name='Power Panel 1', site=sites[0], location=locations[0]), + PowerPanel(name='Power Panel 2', site=sites[1], location=locations[1]), + PowerPanel(name='Power Panel 3', site=sites[2], location=locations[2]), ) PowerPanel.objects.bulk_create(power_panels) @@ -2611,6 +2953,13 @@ class PowerPanelTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -2618,9 +2967,9 @@ class PowerPanelTestCase(TestCase): params = {'site': [sites[0].slug, sites[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_rack_group(self): - rack_groups = RackGroup.objects.all()[:2] - params = {'rack_group_id': [rack_groups[0].pk, rack_groups[1].pk]} + 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) @@ -2639,10 +2988,18 @@ class PowerFeedTestCase(TestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for group in groups: + group.save() + sites = ( - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[1]), - Site(name='Site 3', slug='site-3', region=regions[2]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]), + Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), ) Site.objects.bulk_create(sites) @@ -2723,6 +3080,13 @@ class PowerFeedTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index e8cb73fe4..6eeffbc96 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -82,7 +82,7 @@ class DeviceTestCase(TestCase): self.assertTrue(form.is_valid()) self.assertTrue(form.save()) - def test_non_racked_device_with_face_position(self): + def test_non_racked_device_with_face(self): form = DeviceForm(data={ 'name': 'New Device', 'device_role': DeviceRole.objects.first().pk, @@ -92,12 +92,26 @@ class DeviceTestCase(TestCase): 'site': Site.objects.first().pk, 'rack': None, 'face': DeviceFaceChoices.FACE_REAR, - 'position': 10, 'platform': None, 'status': DeviceStatusChoices.STATUS_ACTIVE, }) self.assertFalse(form.is_valid()) self.assertIn('face', form.errors) + + def test_non_racked_device_with_position(self): + form = DeviceForm(data={ + 'name': 'New Device', + 'device_role': DeviceRole.objects.first().pk, + 'tenant': None, + 'manufacturer': Manufacturer.objects.first().pk, + 'device_type': DeviceType.objects.first().pk, + 'site': Site.objects.first().pk, + 'rack': None, + 'position': 10, + 'platform': None, + 'status': DeviceStatusChoices.STATUS_ACTIVE, + }) + self.assertFalse(form.is_valid()) self.assertIn('position', form.errors) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index b20e21102..b4454aa8a 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,6 +7,69 @@ from dcim.models import * from tenancy.models import Tenant +class LocationTestCase(TestCase): + + def test_change_location_site(self): + """ + Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology: + Site A + - Location A1 + - Location A2 + - Rack 2 + - Device 2 + - Rack 1 + - Device 1 + """ + 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' + ) + + site_a = Site.objects.create(name='Site A', slug='site-a') + site_b = Site.objects.create(name='Site B', slug='site-b') + + location_a1 = Location(site=site_a, name='Location A1', slug='location-a1') + location_a1.save() + location_a2 = Location(site=site_a, parent=location_a1, name='Location A2', slug='location-a2') + location_a2.save() + + rack1 = Rack.objects.create(site=site_a, location=location_a1, name='Rack 1') + rack2 = Rack.objects.create(site=site_a, location=location_a2, name='Rack 2') + + device1 = Device.objects.create( + site=site_a, + location=location_a1, + name='Device 1', + device_type=device_type, + device_role=device_role + ) + device2 = Device.objects.create( + site=site_a, + location=location_a2, + name='Device 2', + device_type=device_type, + device_role=device_role + ) + + powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1') + + # Move Location A1 to Site B + location_a1.site = site_b + location_a1.save() + + # Check that all objects within Location A1 now belong to Site B + self.assertEqual(Location.objects.get(pk=location_a1.pk).site, site_b) + self.assertEqual(Location.objects.get(pk=location_a2.pk).site, site_b) + self.assertEqual(Rack.objects.get(pk=rack1.pk).site, site_b) + self.assertEqual(Rack.objects.get(pk=rack2.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + self.assertEqual(Device.objects.get(pk=device2.pk).site, site_b) + self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b) + + class RackTestCase(TestCase): def setUp(self): @@ -19,12 +82,12 @@ class RackTestCase(TestCase): name='TestSite2', slug='test-site-2' ) - self.group1 = RackGroup.objects.create( + self.location1 = Location.objects.create( name='TestGroup1', slug='test-group-1', site=self.site1 ) - self.group2 = RackGroup.objects.create( + self.location2 = Location.objects.create( name='TestGroup2', slug='test-group-2', site=self.site2 @@ -33,7 +96,7 @@ class RackTestCase(TestCase): name='TestRack1', facility_id='A101', site=self.site1, - group=self.group1, + location=self.location1, u_height=42 ) self.manufacturer = Manufacturer.objects.create( @@ -98,19 +161,19 @@ class RackTestCase(TestCase): with self.assertRaises(ValidationError): rack1.clean() - def test_rack_group_site(self): + def test_location_site(self): - rack_invalid_group = Rack( + rack_invalid_location = Rack( name='TestRack2', facility_id='A102', site=self.site1, u_height=42, - group=self.group2 + location=self.location2 ) - rack_invalid_group.save() + rack_invalid_location.save() with self.assertRaises(ValidationError): - rack_invalid_group.clean() + rack_invalid_location.clean() def test_mount_single_device(self): @@ -154,6 +217,34 @@ class RackTestCase(TestCase): ) self.assertTrue(pdu) + def test_change_rack_site(self): + """ + Check that child Devices get updated when a Rack is moved to a new Site. + """ + 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) + + # Move Rack1 to Site B + rack1.site = site_b + rack1.save() + + # Check that Device1 is now assigned to Site B + self.assertEqual(Device.objects.get(pk=device1.pk).site, site_b) + class DeviceTestCase(TestCase): @@ -388,10 +479,13 @@ class CableTestCase(TestCase): device=self.patch_pannel, name='FP4', type='8p8c', rear_port=self.rear_port4, rear_port_position=1 ) self.provider = Provider.objects.create(name='Provider 1', slug='provider-1') + cloud = Cloud.objects.create(name='Cloud 1', provider=self.provider) self.circuittype = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1') - self.circuit = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') - self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='A') - self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site, term_side='Z') + self.circuit1 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='1') + self.circuit2 = Circuit.objects.create(provider=self.provider, type=self.circuittype, cid='2') + self.circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='A') + self.circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit1, site=site, term_side='Z') + self.circuittermination3 = CircuitTermination.objects.create(circuit=self.circuit2, cloud=cloud, term_side='A') def test_cable_creation(self): """ @@ -461,6 +555,14 @@ class CableTestCase(TestCase): with self.assertRaises(ValidationError): cable.clean() + def test_cable_cannot_terminate_to_a_cloud_circuittermination(self): + """ + Neither side of a cable can be terminated to a CircuitTermination which is attached to a Cloud + """ + cable = Cable(termination_a=self.interface3, termination_b=self.circuittermination3) + with self.assertRaises(ValidationError): + cable.clean() + def test_rearport_connections(self): """ Test various combinations of RearPort connections. diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 97a4f3705..8fde267d9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -57,6 +57,44 @@ class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Region 6,region-6,Sixth region", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + + +class SiteGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = SiteGroup + + @classmethod + def setUpTestData(cls): + + # Create three SiteGroups + sitegroups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for sitegroup in sitegroups: + sitegroup.save() + + cls.form_data = { + 'name': 'Site Group X', + 'slug': 'site-group-x', + 'parent': sitegroups[2].pk, + 'description': 'A new site group', + } + + cls.csv_data = ( + "name,slug,description", + "Site Group 4,site-group-4,Fourth site group", + "Site Group 5,site-group-5,Fifth site group", + "Site Group 6,site-group-6,Sixth site group", + ) + + cls.bulk_edit_data = { + 'description': 'New description', + } + class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Site @@ -71,10 +109,17 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): for region in regions: region.save() + groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + ) + for group in groups: + group.save() + Site.objects.bulk_create([ - Site(name='Site 1', slug='site-1', region=regions[0]), - Site(name='Site 2', slug='site-2', region=regions[0]), - Site(name='Site 3', slug='site-3', region=regions[0]), + Site(name='Site 1', slug='site-1', region=regions[0], group=groups[1]), + Site(name='Site 2', slug='site-2', region=regions[0], group=groups[1]), + Site(name='Site 3', slug='site-3', region=regions[0], group=groups[1]), ]) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') @@ -84,6 +129,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'facility': 'Facility X', 'asn': 65001, @@ -110,6 +156,7 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.bulk_edit_data = { 'status': SiteStatusChoices.STATUS_PLANNED, 'region': regions[1].pk, + 'group': groups[1].pk, 'tenant': None, 'asn': 65009, 'time_zone': pytz.timezone('US/Eastern'), @@ -117,8 +164,8 @@ class SiteTestCase(ViewTestCases.PrimaryObjectViewTestCase): } -class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): - model = RackGroup +class LocationTestCase(ViewTestCases.OrganizationalObjectViewTestCase): + model = Location @classmethod def setUpTestData(cls): @@ -126,28 +173,32 @@ class RackGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): site = Site(name='Site 1', slug='site-1') site.save() - rack_groups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=site), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=site), - RackGroup(name='Rack Group 3', slug='rack-group-3', site=site), + locations = ( + Location(name='Location 1', slug='location-1', site=site), + Location(name='Location 2', slug='location-2', site=site), + Location(name='Location 3', slug='location-3', site=site), ) - for rackgroup in rack_groups: - rackgroup.save() + for location in locations: + location.save() cls.form_data = { - 'name': 'Rack Group X', - 'slug': 'rack-group-x', + 'name': 'Location X', + 'slug': 'location-x', 'site': site.pk, - 'description': 'A new rack group', + 'description': 'A new location', } cls.csv_data = ( "site,name,slug,description", - "Site 1,Rack Group 4,rack-group-4,Fourth rack group", - "Site 1,Rack Group 5,rack-group-5,Fifth rack group", - "Site 1,Rack Group 6,rack-group-6,Sixth rack group", + "Site 1,Location 4,location-4,Fourth location", + "Site 1,Location 5,location-5,Fifth location", + "Site 1,Location 6,location-6,Sixth location", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = RackRole @@ -175,6 +226,11 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Rack Role 6,rack-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = RackReservation @@ -187,10 +243,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): site = Site.objects.create(name='Site 1', slug='site-1') - rack_group = RackGroup(name='Rack Group 1', slug='rack-group-1', site=site) - rack_group.save() + location = Location(name='Location 1', slug='location-1', site=site) + location.save() - rack = Rack(name='Rack 1', site=site, group=rack_group) + rack = Rack(name='Rack 1', site=site, location=location) rack.save() RackReservation.objects.bulk_create([ @@ -211,10 +267,10 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - 'site,rack_group,rack,units,description', - 'Site 1,Rack Group 1,Rack 1,"10,11,12",Reservation 1', - 'Site 1,Rack Group 1,Rack 1,"13,14,15",Reservation 2', - 'Site 1,Rack Group 1,Rack 1,"16,17,18",Reservation 3', + 'site,location,rack,units,description', + 'Site 1,Location 1,Rack 1,"10,11,12",Reservation 1', + 'Site 1,Location 1,Rack 1,"13,14,15",Reservation 2', + 'Site 1,Location 1,Rack 1,"16,17,18",Reservation 3', ) cls.bulk_edit_data = { @@ -236,12 +292,12 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rackgroups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]) + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]) ) - for rackgroup in rackgroups: - rackgroup.save() + for location in locations: + location.save() rackroles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), @@ -261,7 +317,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Rack X', 'facility_id': 'Facility X', 'site': sites[1].pk, - 'group': rackgroups[1].pk, + 'location': locations[1].pk, 'tenant': None, 'status': RackStatusChoices.STATUS_PLANNED, 'role': rackroles[1].pk, @@ -279,15 +335,15 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "site,group,name,width,u_height", + "site,location,name,width,u_height", "Site 1,,Rack 4,19,42", - "Site 1,Rack Group 1,Rack 5,19,42", - "Site 2,Rack Group 2,Rack 6,19,42", + "Site 1,Location 1,Rack 5,19,42", + "Site 2,Location 2,Rack 6,19,42", ) cls.bulk_edit_data = { 'site': sites[1].pk, - 'group': rackgroups[1].pk, + 'location': locations[1].pk, 'tenant': None, 'status': RackStatusChoices.STATUS_DEPRECATED, 'role': rackroles[1].pk, @@ -302,6 +358,14 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'comments': 'New comments', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_list_rack_elevations(self): + """ + Test viewing the list of rack elevations. + """ + response = self.client.get(reverse('dcim:rack_elevation_list')) + self.assertHttpStatus(response, 200) + class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Manufacturer @@ -328,6 +392,10 @@ class ManufacturerTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Manufacturer 6,manufacturer-6,Sixth manufacturer", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + # TODO: Change base class to PrimaryObjectViewTestCase # Blocked by absence of bulk import view for DeviceTypes @@ -388,6 +456,7 @@ manufacturer: Generic model: TEST-1000 slug: test-1000 u_height: 2 +subdevice_role: parent comments: test comment console-ports: - name: Console Port 1 @@ -823,8 +892,8 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas def setUpTestData(cls): manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetypes = ( - DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', subdevice_role=SubdeviceRoleChoices.ROLE_PARENT), ) DeviceType.objects.bulk_create(devicetypes) @@ -876,6 +945,11 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Device Role 6,device-role-6,0000ff", ) + cls.bulk_edit_data = { + 'color': '00ff00', + 'description': 'New description', + } + class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Platform @@ -907,6 +981,11 @@ class PlatformTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Platform 6,platform-6,Sixth platform", ) + cls.bulk_edit_data = { + 'napalm_driver': 'ios', + 'description': 'New description', + } + class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Device @@ -920,11 +999,11 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rack_group = RackGroup(site=sites[0], name='Rack Group 1', slug='rack-group-1') - rack_group.save() + location = Location(site=sites[0], name='Location 1', slug='location-1') + location.save() racks = ( - Rack(name='Rack 1', site=sites[0], group=rack_group), + Rack(name='Rack 1', site=sites[0], location=location), Rack(name='Rack 2', site=sites[1]), ) Rack.objects.bulk_create(racks) @@ -982,10 +1061,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device_role,manufacturer,device_type,status,name,site,rack_group,rack,position,face", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Rack Group 1,Rack 1,10,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Rack Group 1,Rack 1,20,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Rack Group 1,Rack 1,30,front", + "device_role,manufacturer,device_type,status,name,site,location,rack,position,face", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front", ) cls.bulk_edit_data = { @@ -1762,38 +1841,38 @@ class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) Site.objects.bulk_create(sites) - rackgroups = ( - RackGroup(name='Rack Group 1', slug='rack-group-1', site=sites[0]), - RackGroup(name='Rack Group 2', slug='rack-group-2', site=sites[1]), + locations = ( + Location(name='Location 1', slug='location-1', site=sites[0]), + Location(name='Location 2', slug='location-2', site=sites[1]), ) - for rackgroup in rackgroups: - rackgroup.save() + for location in locations: + location.save() PowerPanel.objects.bulk_create(( - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 1'), - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 2'), - PowerPanel(site=sites[0], rack_group=rackgroups[0], name='Power Panel 3'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 1'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 2'), + PowerPanel(site=sites[0], location=locations[0], name='Power Panel 3'), )) tags = cls.create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { 'site': sites[1].pk, - 'rack_group': rackgroups[1].pk, + 'location': locations[1].pk, 'name': 'Power Panel X', 'tags': [t.pk for t in tags], } cls.csv_data = ( - "site,rack_group,name", - "Site 1,Rack Group 1,Power Panel 4", - "Site 1,Rack Group 1,Power Panel 5", - "Site 1,Rack Group 1,Power Panel 6", + "site,location,name", + "Site 1,Location 1,Power Panel 4", + "Site 1,Location 1,Power Panel 5", + "Site 1,Location 1,Power Panel 6", ) cls.bulk_edit_data = { 'site': sites[1].pk, - 'rack_group': rackgroups[1].pk, + 'location': locations[1].pk, } diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d167ebdb7..b23603c97 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,13 +1,9 @@ from django.urls import path -from extras.views import ObjectChangeLogView, ImageAttachmentEditView +from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView from . import views -from .models import ( - Cable, ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, FrontPort, Interface, - InventoryItem, Manufacturer, Platform, PowerFeed, PowerPanel, PowerPort, PowerOutlet, Rack, RackGroup, - RackReservation, RackRole, RearPort, Region, Site, VirtualChassis, -) +from .models import * app_name = 'dcim' urlpatterns = [ @@ -16,37 +12,55 @@ urlpatterns = [ path('regions/', views.RegionListView.as_view(), name='region_list'), path('regions/add/', views.RegionEditView.as_view(), name='region_add'), path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'), + path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'), path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'), + path('regions//', views.RegionView.as_view(), name='region'), path('regions//edit/', views.RegionEditView.as_view(), name='region_edit'), path('regions//delete/', views.RegionDeleteView.as_view(), name='region_delete'), path('regions//changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}), + # Site groups + path('site-groups/', views.SiteGroupListView.as_view(), name='sitegroup_list'), + path('site-groups/add/', views.SiteGroupEditView.as_view(), name='sitegroup_add'), + path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'), + path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'), + path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'), + path('site-groups//', views.SiteGroupView.as_view(), name='sitegroup'), + path('site-groups//edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'), + path('site-groups//delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'), + path('site-groups//changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}), + # Sites path('sites/', views.SiteListView.as_view(), name='site_list'), path('sites/add/', views.SiteEditView.as_view(), name='site_add'), path('sites/import/', views.SiteBulkImportView.as_view(), name='site_import'), path('sites/edit/', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), path('sites/delete/', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), - path('sites//', views.SiteView.as_view(), name='site'), - path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), - path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), - path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//', views.SiteView.as_view(), name='site'), + path('sites//edit/', views.SiteEditView.as_view(), name='site_edit'), + path('sites//delete/', views.SiteDeleteView.as_view(), name='site_delete'), + path('sites//changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}), + path('sites//journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}), path('sites//images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}), - # Rack groups - path('rack-groups/', views.RackGroupListView.as_view(), name='rackgroup_list'), - path('rack-groups/add/', views.RackGroupEditView.as_view(), name='rackgroup_add'), - path('rack-groups/import/', views.RackGroupBulkImportView.as_view(), name='rackgroup_import'), - path('rack-groups/delete/', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), - path('rack-groups//edit/', views.RackGroupEditView.as_view(), name='rackgroup_edit'), - path('rack-groups//delete/', views.RackGroupDeleteView.as_view(), name='rackgroup_delete'), - path('rack-groups//changelog/', ObjectChangeLogView.as_view(), name='rackgroup_changelog', kwargs={'model': RackGroup}), + # Locations + path('locations/', views.LocationListView.as_view(), name='location_list'), + path('locations/add/', views.LocationEditView.as_view(), name='location_add'), + path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'), + path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'), + path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'), + path('locations//', views.LocationView.as_view(), name='location'), + path('locations//edit/', views.LocationEditView.as_view(), name='location_edit'), + path('locations//delete/', views.LocationDeleteView.as_view(), name='location_delete'), + path('locations//changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}), # Rack roles path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'), path('rack-roles/add/', views.RackRoleEditView.as_view(), name='rackrole_add'), path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'), + path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'), path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + path('rack-roles//', views.RackRoleView.as_view(), name='rackrole'), path('rack-roles//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), path('rack-roles//changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}), @@ -61,6 +75,7 @@ urlpatterns = [ path('rack-reservations//edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'), path('rack-reservations//delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'), path('rack-reservations//changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}), + path('rack-reservations//journal/', ObjectJournalView.as_view(), name='rackreservation_journal', kwargs={'model': RackReservation}), # Racks path('racks/', views.RackListView.as_view(), name='rack_list'), @@ -73,16 +88,19 @@ urlpatterns = [ path('racks//edit/', views.RackEditView.as_view(), name='rack_edit'), path('racks//delete/', views.RackDeleteView.as_view(), name='rack_delete'), path('racks//changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}), + path('racks//journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}), path('racks//images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}), # Manufacturers path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'), path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'), path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'), + path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'), path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'), - path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), - path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), - path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), + path('manufacturers//', views.ManufacturerView.as_view(), name='manufacturer'), + path('manufacturers//edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'), + path('manufacturers//delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'), + path('manufacturers//changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}), # Device types path('device-types/', views.DeviceTypeListView.as_view(), name='devicetype_list'), @@ -94,6 +112,7 @@ urlpatterns = [ path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), + path('device-types//journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}), # Console port templates path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'), @@ -163,19 +182,23 @@ urlpatterns = [ path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'), + path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'), path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'), - path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), - path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), - path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), + path('device-roles//', views.DeviceRoleView.as_view(), name='devicerole'), + path('device-roles//edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'), + path('device-roles//delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'), + path('device-roles//changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}), # Platforms path('platforms/', views.PlatformListView.as_view(), name='platform_list'), path('platforms/add/', views.PlatformEditView.as_view(), name='platform_add'), path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'), + path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'), path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'), - path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), - path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), - path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), + path('platforms//', views.PlatformView.as_view(), name='platform'), + path('platforms//edit/', views.PlatformEditView.as_view(), name='platform_edit'), + path('platforms//delete/', views.PlatformDeleteView.as_view(), name='platform_delete'), + path('platforms//changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}), # Devices path('devices/', views.DeviceListView.as_view(), name='device_list'), @@ -198,6 +221,7 @@ urlpatterns = [ path('devices//inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'), path('devices//config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'), path('devices//changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}), + path('devices//journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}), path('devices//status/', views.DeviceStatusView.as_view(), name='device_status'), path('devices//lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'), path('devices//config/', views.DeviceConfigView.as_view(), name='device_config'), @@ -353,6 +377,7 @@ urlpatterns = [ path('cables//edit/', views.CableEditView.as_view(), name='cable_edit'), path('cables//delete/', views.CableDeleteView.as_view(), name='cable_delete'), path('cables//changelog/', ObjectChangeLogView.as_view(), name='cable_changelog', kwargs={'model': Cable}), + path('cables//journal/', ObjectJournalView.as_view(), name='cable_journal', kwargs={'model': Cable}), # Console/power/interface connections (read-only) path('console-connections/', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), @@ -369,6 +394,7 @@ urlpatterns = [ path('virtual-chassis//edit/', views.VirtualChassisEditView.as_view(), name='virtualchassis_edit'), path('virtual-chassis//delete/', views.VirtualChassisDeleteView.as_view(), name='virtualchassis_delete'), path('virtual-chassis//changelog/', ObjectChangeLogView.as_view(), name='virtualchassis_changelog', kwargs={'model': VirtualChassis}), + path('virtual-chassis//journal/', ObjectJournalView.as_view(), name='virtualchassis_journal', kwargs={'model': VirtualChassis}), path('virtual-chassis//add-member/', views.VirtualChassisAddMemberView.as_view(), name='virtualchassis_add_member'), path('virtual-chassis-members//delete/', views.VirtualChassisRemoveMemberView.as_view(), name='virtualchassis_remove_member'), @@ -382,6 +408,7 @@ urlpatterns = [ path('power-panels//edit/', views.PowerPanelEditView.as_view(), name='powerpanel_edit'), path('power-panels//delete/', views.PowerPanelDeleteView.as_view(), name='powerpanel_delete'), path('power-panels//changelog/', ObjectChangeLogView.as_view(), name='powerpanel_changelog', kwargs={'model': PowerPanel}), + path('power-panels//journal/', ObjectJournalView.as_view(), name='powerpanel_journal', kwargs={'model': PowerPanel}), # Power feeds path('power-feeds/', views.PowerFeedListView.as_view(), name='powerfeed_list'), @@ -394,6 +421,7 @@ urlpatterns = [ path('power-feeds//delete/', views.PowerFeedDeleteView.as_view(), name='powerfeed_delete'), 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/views.py b/netbox/dcim/views.py index 9f8e4c13f..3bd456bcc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe from django.views.generic import View from circuits.models import Circuit -from extras.views import ObjectChangeLogView, ObjectConfigContextView +from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView from ipam.models import IPAddress, Prefix, Service, VLAN from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable from netbox.views import generic @@ -20,7 +20,8 @@ from secrets.models import Secret from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.utils import csv_format, get_subquery +from utilities.tables import paginate_table +from utilities.utils import csv_format, count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine from . import filters, forms, tables @@ -30,8 +31,8 @@ from .models import ( Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel, - PowerPort, PowerPortTemplate, Rack, RackGroup, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, - VirtualChassis, + PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site, + SiteGroup, VirtualChassis, ) @@ -111,6 +112,23 @@ class RegionListView(generic.ObjectListView): table = tables.RegionTable +class RegionView(generic.ObjectView): + queryset = Region.objects.all() + + def get_extra_context(self, request, instance): + sites = Site.objects.restrict(request.user, 'view').filter( + region=instance + ) + + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('region') + paginate_table(sites_table, request) + + return { + 'sites_table': sites_table, + } + + class RegionEditView(generic.ObjectEditView): queryset = Region.objects.all() model_form = forms.RegionForm @@ -126,6 +144,19 @@ class RegionBulkImportView(generic.BulkImportView): table = tables.RegionTable +class RegionBulkEditView(generic.BulkEditView): + queryset = Region.objects.add_related_count( + Region.objects.all(), + Site, + 'region', + 'site_count', + cumulative=True + ) + filterset = filters.RegionFilterSet + table = tables.RegionTable + form = forms.RegionBulkEditForm + + class RegionBulkDeleteView(generic.BulkDeleteView): queryset = Region.objects.add_related_count( Region.objects.all(), @@ -138,6 +169,80 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable +# +# Site groups +# + +class SiteGroupListView(generic.ObjectListView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + filterset_form = forms.SiteGroupFilterForm + table = tables.SiteGroupTable + + +class SiteGroupView(generic.ObjectView): + queryset = SiteGroup.objects.all() + + def get_extra_context(self, request, instance): + sites = Site.objects.restrict(request.user, 'view').filter( + group=instance + ) + + sites_table = tables.SiteTable(sites) + sites_table.columns.hide('group') + paginate_table(sites_table, request) + + return { + 'sites_table': sites_table, + } + + +class SiteGroupEditView(generic.ObjectEditView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupForm + + +class SiteGroupDeleteView(generic.ObjectDeleteView): + queryset = SiteGroup.objects.all() + + +class SiteGroupBulkImportView(generic.BulkImportView): + queryset = SiteGroup.objects.all() + model_form = forms.SiteGroupCSVForm + table = tables.SiteGroupTable + + +class SiteGroupBulkEditView(generic.BulkEditView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + table = tables.SiteGroupTable + form = forms.SiteGroupBulkEditForm + + +class SiteGroupBulkDeleteView(generic.BulkDeleteView): + queryset = SiteGroup.objects.add_related_count( + SiteGroup.objects.all(), + Site, + 'group', + 'site_count', + cumulative=True + ) + filterset = filters.SiteGroupFilterSet + table = tables.SiteGroupTable + + # # Sites # @@ -161,24 +266,23 @@ class SiteView(generic.ObjectView): 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).count(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance).count(), } - rack_groups = RackGroup.objects.add_related_count( - RackGroup.objects.all(), + locations = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).restrict(request.user, 'view').filter(site=instance) return { 'stats': stats, - 'rack_groups': rack_groups, + 'locations': locations, } class SiteEditView(generic.ObjectEditView): queryset = Site.objects.all() model_form = forms.SiteForm - template_name = 'dcim/site_edit.html' class SiteDeleteView(generic.ObjectDeleteView): @@ -208,44 +312,74 @@ class SiteBulkDeleteView(generic.BulkDeleteView): # Rack groups # -class RackGroupListView(generic.ObjectListView): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationListView(generic.ObjectListView): + queryset = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ) - filterset = filters.RackGroupFilterSet - filterset_form = forms.RackGroupFilterForm - table = tables.RackGroupTable + filterset = filters.LocationFilterSet + filterset_form = forms.LocationFilterForm + table = tables.LocationTable -class RackGroupEditView(generic.ObjectEditView): - queryset = RackGroup.objects.all() - model_form = forms.RackGroupForm +class LocationView(generic.ObjectView): + queryset = Location.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + location=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('location') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } -class RackGroupDeleteView(generic.ObjectDeleteView): - queryset = RackGroup.objects.all() +class LocationEditView(generic.ObjectEditView): + queryset = Location.objects.all() + model_form = forms.LocationForm -class RackGroupBulkImportView(generic.BulkImportView): - queryset = RackGroup.objects.all() - model_form = forms.RackGroupCSVForm - table = tables.RackGroupTable +class LocationDeleteView(generic.ObjectDeleteView): + queryset = Location.objects.all() -class RackGroupBulkDeleteView(generic.BulkDeleteView): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +class LocationBulkImportView(generic.BulkImportView): + queryset = Location.objects.all() + model_form = forms.LocationCSVForm + table = tables.LocationTable + + +class LocationBulkEditView(generic.BulkEditView): + queryset = Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).prefetch_related('site') - filterset = filters.RackGroupFilterSet - table = tables.RackGroupTable + filterset = filters.LocationFilterSet + table = tables.LocationTable + form = forms.LocationBulkEditForm + + +class LocationBulkDeleteView(generic.BulkDeleteView): + queryset = Location.objects.add_related_count( + Location.objects.all(), + Rack, + 'location', + 'rack_count', + cumulative=True + ).prefetch_related('site') + filterset = filters.LocationFilterSet + table = tables.LocationTable # @@ -254,11 +388,28 @@ class RackGroupBulkDeleteView(generic.BulkDeleteView): class RackRoleListView(generic.ObjectListView): queryset = RackRole.objects.annotate( - rack_count=get_subquery(Rack, 'role') + rack_count=count_related(Rack, 'role') ) table = tables.RackRoleTable +class RackRoleView(generic.ObjectView): + queryset = RackRole.objects.all() + + def get_extra_context(self, request, instance): + racks = Rack.objects.restrict(request.user, 'view').filter( + role=instance + ) + + racks_table = tables.RackTable(racks) + racks_table.columns.hide('role') + paginate_table(racks_table, request) + + return { + 'racks_table': racks_table, + } + + class RackRoleEditView(generic.ObjectEditView): queryset = RackRole.objects.all() model_form = forms.RackRoleForm @@ -274,9 +425,18 @@ class RackRoleBulkImportView(generic.BulkImportView): table = tables.RackRoleTable +class RackRoleBulkEditView(generic.BulkEditView): + queryset = RackRole.objects.annotate( + rack_count=count_related(Rack, 'role') + ) + filterset = filters.RackRoleFilterSet + table = tables.RackRoleTable + form = forms.RackRoleBulkEditForm + + class RackRoleBulkDeleteView(generic.BulkDeleteView): queryset = RackRole.objects.annotate( - rack_count=get_subquery(Rack, 'role') + rack_count=count_related(Rack, 'role') ) table = tables.RackRoleTable @@ -287,9 +447,9 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackListView(generic.ObjectListView): queryset = Rack.objects.prefetch_related( - 'site', 'group', 'tenant', 'role', 'devices__device_type' + 'site', 'location', 'tenant', 'role', 'devices__device_type' ).annotate( - device_count=get_subquery(Device, 'rack') + device_count=count_related(Device, 'rack') ) filterset = filters.RackFilterSet filterset_form = forms.RackFilterForm @@ -339,7 +499,7 @@ class RackElevationListView(generic.ObjectListView): class RackView(generic.ObjectView): - queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role') + queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role') def get_extra_context(self, request, instance): # Get 0U and child devices located within the rack @@ -350,10 +510,10 @@ class RackView(generic.ObjectView): peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) - if instance.group: - peer_racks = peer_racks.filter(group=instance.group) + if instance.location: + peer_racks = peer_racks.filter(location=instance.location) else: - peer_racks = peer_racks.filter(group__isnull=True) + peer_racks = peer_racks.filter(location__isnull=True) next_rack = peer_racks.filter(name__gt=instance.name).order_by('name').first() prev_rack = peer_racks.filter(name__lt=instance.name).order_by('-name').first() @@ -391,14 +551,14 @@ class RackBulkImportView(generic.BulkImportView): class RackBulkEditView(generic.BulkEditView): - queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable form = forms.RackBulkEditForm class RackBulkDeleteView(generic.BulkDeleteView): - queryset = Rack.objects.prefetch_related('site', 'group', 'tenant', 'role') + queryset = Rack.objects.prefetch_related('site', 'location', 'tenant', 'role') filterset = filters.RackFilterSet table = tables.RackTable @@ -421,7 +581,6 @@ class RackReservationView(generic.ObjectView): class RackReservationEditView(generic.ObjectEditView): queryset = RackReservation.objects.all() model_form = forms.RackReservationForm - template_name = 'dcim/rackreservation_edit.html' def alter_obj(self, obj, request, args, kwargs): if not obj.pk: @@ -470,13 +629,30 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView): class ManufacturerListView(generic.ObjectListView): queryset = Manufacturer.objects.annotate( - devicetype_count=get_subquery(DeviceType, 'manufacturer'), - inventoryitem_count=get_subquery(InventoryItem, 'manufacturer'), - platform_count=get_subquery(Platform, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer'), + inventoryitem_count=count_related(InventoryItem, 'manufacturer'), + platform_count=count_related(Platform, 'manufacturer') ) table = tables.ManufacturerTable +class ManufacturerView(generic.ObjectView): + queryset = Manufacturer.objects.all() + + def get_extra_context(self, request, instance): + devicetypes = DeviceType.objects.restrict(request.user, 'view').filter( + manufacturer=instance + ) + + devicetypes_table = tables.DeviceTypeTable(devicetypes) + devicetypes_table.columns.hide('manufacturer') + paginate_table(devicetypes_table, request) + + return { + 'devicetypes_table': devicetypes_table, + } + + class ManufacturerEditView(generic.ObjectEditView): queryset = Manufacturer.objects.all() model_form = forms.ManufacturerForm @@ -492,9 +668,18 @@ class ManufacturerBulkImportView(generic.BulkImportView): table = tables.ManufacturerTable +class ManufacturerBulkEditView(generic.BulkEditView): + queryset = Manufacturer.objects.annotate( + devicetype_count=count_related(DeviceType, 'manufacturer') + ) + filterset = filters.ManufacturerFilterSet + table = tables.ManufacturerTable + form = forms.ManufacturerBulkEditForm + + class ManufacturerBulkDeleteView(generic.BulkDeleteView): queryset = Manufacturer.objects.annotate( - devicetype_count=get_subquery(DeviceType, 'manufacturer') + devicetype_count=count_related(DeviceType, 'manufacturer') ) table = tables.ManufacturerTable @@ -505,7 +690,7 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): class DeviceTypeListView(generic.ObjectListView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=get_subquery(Device, 'device_type') + instance_count=count_related(Device, 'device_type') ) filterset = filters.DeviceTypeFilterSet filterset_form = forms.DeviceTypeFilterForm @@ -577,7 +762,6 @@ class DeviceTypeView(generic.ObjectView): class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm - template_name = 'dcim/devicetype_edit.html' class DeviceTypeDeleteView(generic.ObjectDeleteView): @@ -612,7 +796,7 @@ class DeviceTypeImportView(generic.ObjectImportView): class DeviceTypeBulkEditView(generic.BulkEditView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=get_subquery(Device, 'device_type') + instance_count=count_related(Device, 'device_type') ) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -621,7 +805,7 @@ class DeviceTypeBulkEditView(generic.BulkEditView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView): queryset = DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=get_subquery(Device, 'device_type') + instance_count=count_related(Device, 'device_type') ) filterset = filters.DeviceTypeFilterSet table = tables.DeviceTypeTable @@ -913,12 +1097,29 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView): class DeviceRoleListView(generic.ObjectListView): queryset = DeviceRole.objects.annotate( - device_count=get_subquery(Device, 'device_role'), - vm_count=get_subquery(VirtualMachine, 'role') + device_count=count_related(Device, 'device_role'), + vm_count=count_related(VirtualMachine, 'role') ) table = tables.DeviceRoleTable +class DeviceRoleView(generic.ObjectView): + queryset = DeviceRole.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + device_role=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('device_role') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class DeviceRoleEditView(generic.ObjectEditView): queryset = DeviceRole.objects.all() model_form = forms.DeviceRoleForm @@ -934,6 +1135,13 @@ class DeviceRoleBulkImportView(generic.BulkImportView): table = tables.DeviceRoleTable +class DeviceRoleBulkEditView(generic.BulkEditView): + queryset = DeviceRole.objects.all() + filterset = filters.DeviceRoleFilterSet + table = tables.DeviceRoleTable + form = forms.DeviceRoleBulkEditForm + + class DeviceRoleBulkDeleteView(generic.BulkDeleteView): queryset = DeviceRole.objects.all() table = tables.DeviceRoleTable @@ -945,12 +1153,29 @@ class DeviceRoleBulkDeleteView(generic.BulkDeleteView): class PlatformListView(generic.ObjectListView): queryset = Platform.objects.annotate( - device_count=get_subquery(Device, 'platform'), - vm_count=get_subquery(VirtualMachine, 'platform') + device_count=count_related(Device, 'platform'), + vm_count=count_related(VirtualMachine, 'platform') ) table = tables.PlatformTable +class PlatformView(generic.ObjectView): + queryset = Platform.objects.all() + + def get_extra_context(self, request, instance): + devices = Device.objects.restrict(request.user, 'view').filter( + platform=instance + ) + + devices_table = tables.DeviceTable(devices) + devices_table.columns.hide('platform') + paginate_table(devices_table, request) + + return { + 'devices_table': devices_table, + } + + class PlatformEditView(generic.ObjectEditView): queryset = Platform.objects.all() model_form = forms.PlatformForm @@ -966,6 +1191,13 @@ class PlatformBulkImportView(generic.BulkImportView): table = tables.PlatformTable +class PlatformBulkEditView(generic.BulkEditView): + queryset = Platform.objects.all() + filterset = filters.PlatformFilterSet + table = tables.PlatformTable + form = forms.PlatformBulkEditForm + + class PlatformBulkDeleteView(generic.BulkDeleteView): queryset = Platform.objects.all() table = tables.PlatformTable @@ -985,7 +1217,7 @@ class DeviceListView(generic.ObjectListView): class DeviceView(generic.ObjectView): queryset = Device.objects.prefetch_related( - 'site__region', 'rack__group', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' + 'site__region', 'location', 'rack', 'tenant__group', 'device_role', 'platform', 'primary_ip4', 'primary_ip6' ) def get_extra_context(self, request, instance): @@ -1271,6 +1503,10 @@ class DeviceChangeLogView(ObjectChangeLogView): base_template = 'dcim/device/base.html' +class DeviceJournalView(ObjectJournalView): + base_template = 'dcim/device/base.html' + + class DeviceEditView(generic.ObjectEditView): queryset = Device.objects.all() model_form = forms.DeviceForm @@ -2134,10 +2370,14 @@ class PathTraceView(generic.ObjectView): else: path = related_paths.first() + # Get the total length of the cable and whether the length is definitive (fully defined) + total_length, is_definitive = path.get_total_length() if path else (None, False) + return { 'path': path, 'related_paths': related_paths, - 'total_length': path.get_total_length() if path else None, + 'total_length': total_length, + 'is_definitive': is_definitive } @@ -2181,13 +2421,15 @@ class CableCreateView(generic.ObjectEditView): 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, 'site', None) + 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, 'rack', None) + initial_data['termination_b_rack'] = getattr(obj.termination_a.parent_object, 'rack', None) form = self.model_form(instance=obj, initial=initial_data) @@ -2335,7 +2577,7 @@ class InterfaceConnectionsListView(generic.ObjectListView): class VirtualChassisListView(generic.ObjectListView): queryset = VirtualChassis.objects.prefetch_related('master').annotate( - member_count=get_subquery(Device, 'virtual_chassis') + member_count=count_related(Device, 'virtual_chassis') ) table = tables.VirtualChassisTable filterset = filters.VirtualChassisFilterSet @@ -2563,9 +2805,9 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView): class PowerPanelListView(generic.ObjectListView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( - powerfeed_count=get_subquery(PowerFeed, 'power_panel') + powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filters.PowerPanelFilterSet filterset_form = forms.PowerPanelFilterForm @@ -2573,7 +2815,7 @@ class PowerPanelListView(generic.ObjectListView): class PowerPanelView(generic.ObjectView): - queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') + queryset = PowerPanel.objects.prefetch_related('site', 'location') def get_extra_context(self, request, instance): power_feeds = PowerFeed.objects.restrict(request.user).filter(power_panel=instance).prefetch_related('rack') @@ -2591,7 +2833,6 @@ class PowerPanelView(generic.ObjectView): class PowerPanelEditView(generic.ObjectEditView): queryset = PowerPanel.objects.all() model_form = forms.PowerPanelForm - template_name = 'dcim/powerpanel_edit.html' class PowerPanelDeleteView(generic.ObjectDeleteView): @@ -2605,7 +2846,7 @@ class PowerPanelBulkImportView(generic.BulkImportView): class PowerPanelBulkEditView(generic.BulkEditView): - queryset = PowerPanel.objects.prefetch_related('site', 'rack_group') + queryset = PowerPanel.objects.prefetch_related('site', 'location') filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable form = forms.PowerPanelBulkEditForm @@ -2613,9 +2854,9 @@ class PowerPanelBulkEditView(generic.BulkEditView): class PowerPanelBulkDeleteView(generic.BulkDeleteView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( - powerfeed_count=get_subquery(PowerFeed, 'power_panel') + powerfeed_count=count_related(PowerFeed, 'power_panel') ) filterset = filters.PowerPanelFilterSet table = tables.PowerPanelTable @@ -2639,7 +2880,6 @@ class PowerFeedView(generic.ObjectView): class PowerFeedEditView(generic.ObjectEditView): queryset = PowerFeed.objects.all() model_form = forms.PowerFeedForm - template_name = 'dcim/powerfeed_edit.html' class PowerFeedDeleteView(generic.ObjectDeleteView): diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index a4786610d..4e11307fc 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -132,15 +132,15 @@ class CustomLinkForm(forms.ModelForm): model = CustomLink exclude = [] widgets = { - 'text': forms.Textarea, - 'url': forms.Textarea, + 'link_text': forms.Textarea, + 'link_url': forms.Textarea, } help_texts = { 'weight': 'A numeric weight to influence the ordering of this link among its peers. Lower weights appear ' 'first in a list.', - 'text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. Links ' - 'which render as empty text will not be displayed.', - 'url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', + 'link_text': 'Jinja2 template code for the link text. Reference the object as {{ obj }}. ' + 'Links which render as empty text will not be displayed.', + 'link_url': 'Jinja2 template code for the link URL. Reference the object as {{ obj }}.', } def __init__(self, *args, **kwargs): @@ -158,7 +158,7 @@ class CustomLinkAdmin(admin.ModelAdmin): 'fields': ('content_type', 'name', 'group_name', 'weight', 'button_class', 'new_window') }), ('Templates', { - 'fields': ('text', 'url'), + 'fields': ('link_text', 'link_url'), 'classes': ('monospace',) }) ) diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index c8c4ba89e..5cb1fc276 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,9 +1,7 @@ from django.contrib.contenttypes.models import ContentType -from rest_framework.fields import CreateOnlyDefault, Field +from rest_framework.fields import Field -from extras.choices import * from extras.models import CustomField -from netbox.api import ValidatedModelSerializer # @@ -56,34 +54,3 @@ class CustomFieldsDataField(Field): data = {**self.parent.instance.custom_field_data, **data} return data - - -class CustomFieldModelSerializer(ValidatedModelSerializer): - """ - Extends ModelSerializer to render any CustomFields and their values associated with an object. - """ - custom_fields = CustomFieldsDataField( - source='custom_field_data', - default=CreateOnlyDefault(CustomFieldDefaultValues()) - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance is not None: - - # Retrieve the set of CustomFields which apply to this type of object - content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(content_types=content_type) - - # Populate CustomFieldValues for each instance from database - if type(self.instance) in (list, tuple): - for obj in self.instance: - self._populate_custom_fields(obj, fields) - else: - self._populate_custom_fields(self.instance, fields) - - def _populate_custom_fields(self, instance, custom_fields): - instance.custom_fields = {} - for field in custom_fields: - instance.custom_fields[field.name] = instance.cf.get(field.name) diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py index 5635f401b..4acde31ab 100644 --- a/netbox/extras/api/nested_serializers.py +++ b/netbox/extras/api/nested_serializers.py @@ -2,24 +2,44 @@ from rest_framework import serializers from extras import choices, models from netbox.api import ChoiceField, WritableNestedSerializer +from netbox.api.serializers import NestedTagSerializer from users.api.nested_serializers import NestedUserSerializer __all__ = [ 'NestedConfigContextSerializer', 'NestedCustomFieldSerializer', + 'NestedCustomLinkSerializer', 'NestedExportTemplateSerializer', 'NestedImageAttachmentSerializer', 'NestedJobResultSerializer', - 'NestedTagSerializer', + 'NestedJournalEntrySerializer', + 'NestedTagSerializer', # Defined in netbox.api.serializers + 'NestedWebhookSerializer', ] +class NestedWebhookSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + + class Meta: + model = models.Webhook + fields = ['id', 'url', 'display', 'name'] + + class NestedCustomFieldSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfield-detail') class Meta: model = models.CustomField - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] + + +class NestedCustomLinkSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + + class Meta: + model = models.CustomLink + fields = ['id', 'url', 'display', 'name'] class NestedConfigContextSerializer(WritableNestedSerializer): @@ -27,7 +47,7 @@ class NestedConfigContextSerializer(WritableNestedSerializer): class Meta: model = models.ConfigContext - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedExportTemplateSerializer(WritableNestedSerializer): @@ -35,7 +55,7 @@ class NestedExportTemplateSerializer(WritableNestedSerializer): class Meta: model = models.ExportTemplate - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedImageAttachmentSerializer(WritableNestedSerializer): @@ -43,15 +63,15 @@ class NestedImageAttachmentSerializer(WritableNestedSerializer): class Meta: model = models.ImageAttachment - fields = ['id', 'url', 'name', 'image'] + fields = ['id', 'url', 'display', 'name', 'image'] -class NestedTagSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') +class NestedJournalEntrySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') class Meta: - model = models.Tag - fields = ['id', 'url', 'name', 'slug', 'color'] + model = models.JournalEntry + fields = ['id', 'url', 'display', 'created'] class NestedJobResultSerializer(serializers.ModelSerializer): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index a85ca05b7..4a1b154d3 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -4,17 +4,16 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from dcim.api.nested_serializers import ( - NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, + NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, + NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup from extras.choices import * -from extras.models import ( - ConfigContext, CustomField, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, -) +from extras.models import * from extras.utils import FeatureQuery -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.exceptions import SerializerNotFound +from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer @@ -23,6 +22,46 @@ from virtualization.api.nested_serializers import NestedClusterGroupSerializer, from virtualization.models import Cluster, ClusterGroup from .nested_serializers import * +__all__ = ( + 'ConfigContextSerializer', + 'ContentTypeSerializer', + 'CustomFieldSerializer', + 'CustomLinkSerializer', + 'ExportTemplateSerializer', + 'ImageAttachmentSerializer', + 'JobResultSerializer', + 'ObjectChangeSerializer', + 'ReportDetailSerializer', + 'ReportSerializer', + 'ScriptDetailSerializer', + 'ScriptInputSerializer', + 'ScriptLogMessageSerializer', + 'ScriptOutputSerializer', + 'ScriptSerializer', + 'TagSerializer', + 'WebhookSerializer', +) + + +# +# Webhooks +# + +class WebhookSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:webhook-detail') + content_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('webhooks').get_query()), + many=True + ) + + class Meta: + model = Webhook + fields = [ + 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', + 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', + 'ssl_verification', 'ca_file_path', + ] + # # Custom fields @@ -40,11 +79,29 @@ class CustomFieldSerializer(ValidatedModelSerializer): class Meta: model = CustomField fields = [ - 'id', 'url', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', + 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', ] +# +# Custom links +# + +class CustomLinkSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:customlink-detail') + content_type = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_links').get_query()) + ) + + class Meta: + model = CustomLink + fields = [ + 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', + ] + + # # Export templates # @@ -57,7 +114,10 @@ class ExportTemplateSerializer(ValidatedModelSerializer): class Meta: model = ExportTemplate - fields = ['id', 'url', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] + fields = [ + 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', + 'file_extension', + ] # @@ -70,39 +130,7 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'url', 'name', 'slug', 'color', 'description', 'tagged_items'] - - -class TaggedObjectSerializer(serializers.Serializer): - tags = NestedTagSerializer(many=True, required=False) - - def create(self, validated_data): - tags = validated_data.pop('tags', None) - instance = super().create(validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def update(self, instance, validated_data): - tags = validated_data.pop('tags', None) - - # Cache tags on instance for change logging - instance._tags = tags or [] - - instance = super().update(instance, validated_data) - - if tags is not None: - return self._save_tags(instance, tags) - return instance - - def _save_tags(self, instance, tags): - if tags: - instance.tags.set(*[t.name for t in tags]) - else: - instance.tags.clear() - - return instance + fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items'] # @@ -119,8 +147,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): class Meta: model = ImageAttachment fields = [ - 'id', 'url', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', 'image_width', - 'created', + 'id', 'url', 'display', 'content_type', 'object_id', 'parent', 'name', 'image', 'image_height', + 'image_width', 'created', ] def validate(self, data): @@ -154,6 +182,51 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): return serializer(obj.parent, context={'request': self.context['request']}).data +# +# Journal entries +# + +class JournalEntrySerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:journalentry-detail') + assigned_object_type = ContentTypeField( + queryset=ContentType.objects.all() + ) + assigned_object = serializers.SerializerMethodField(read_only=True) + kind = ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + + class Meta: + model = JournalEntry + fields = [ + 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'created', + 'created_by', 'kind', 'comments', + ] + + def validate(self, data): + + # Validate that the parent object exists + if 'assigned_object_type' in data and 'assigned_object_id' in data: + try: + data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id']) + except ObjectDoesNotExist: + raise serializers.ValidationError( + f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}" + ) + + # Enforce model validation + super().validate(data) + + return data + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_assigned_object(self, instance): + serializer = get_serializer_for_model(instance.assigned_object_type.model_class(), prefix='Nested') + context = {'request': self.context['request']} + return serializer(instance.assigned_object, context=context).data + + # # Config contexts # @@ -166,12 +239,24 @@ class ConfigContextSerializer(ValidatedModelSerializer): required=False, many=True ) + site_groups = SerializedPKRelatedField( + queryset=SiteGroup.objects.all(), + serializer=NestedSiteGroupSerializer, + required=False, + many=True + ) sites = SerializedPKRelatedField( queryset=Site.objects.all(), serializer=NestedSiteSerializer, required=False, many=True ) + device_types = SerializedPKRelatedField( + queryset=DeviceType.objects.all(), + serializer=NestedDeviceTypeSerializer, + required=False, + many=True + ) roles = SerializedPKRelatedField( queryset=DeviceRole.objects.all(), serializer=NestedDeviceRoleSerializer, @@ -218,8 +303,9 @@ class ConfigContextSerializer(ValidatedModelSerializer): class Meta: model = ConfigContext fields = [ - 'id', 'url', 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', - 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', + 'device_types', 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', + 'data', 'created', 'last_updated', ] @@ -227,7 +313,7 @@ class ConfigContextSerializer(ValidatedModelSerializer): # Job Results # -class JobResultSerializer(serializers.ModelSerializer): +class JobResultSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:jobresult-detail') user = NestedUserSerializer( read_only=True @@ -240,7 +326,7 @@ class JobResultSerializer(serializers.ModelSerializer): class Meta: model = JobResult fields = [ - 'id', 'url', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', + 'id', 'url', 'display', 'created', 'completed', 'name', 'obj_type', 'status', 'user', 'data', 'job_id', ] @@ -318,7 +404,7 @@ class ScriptOutputSerializer(serializers.Serializer): # Change logging # -class ObjectChangeSerializer(serializers.ModelSerializer): +class ObjectChangeSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:objectchange-detail') user = NestedUserSerializer( read_only=True @@ -337,8 +423,8 @@ class ObjectChangeSerializer(serializers.ModelSerializer): class Meta: model = ObjectChange fields = [ - 'id', 'url', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', - 'changed_object_id', 'changed_object', 'object_data', + 'id', 'url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', + 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -365,13 +451,13 @@ class ObjectChangeSerializer(serializers.ModelSerializer): # ContentTypes # -class ContentTypeSerializer(serializers.ModelSerializer): +class ContentTypeSerializer(BaseModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:contenttype-detail') display_name = serializers.SerializerMethodField() class Meta: model = ContentType - fields = ['id', 'url', 'app_label', 'model', 'display_name'] + fields = ['id', 'url', 'display', 'app_label', 'model', 'display_name'] @swagger_serializer_method(serializer_or_field=serializers.CharField) def get_display_name(self, obj): diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index da62b3d72..565f2cdc7 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -5,9 +5,15 @@ from . import views router = OrderedDefaultRouter() router.APIRootView = views.ExtrasRootView +# Webhooks +router.register('webhooks', views.WebhookViewSet) + # Custom fields router.register('custom-fields', views.CustomFieldViewSet) +# Custom links +router.register('custom-links', views.CustomLinkViewSet) + # Export templates router.register('export-templates', views.ExportTemplateViewSet) @@ -17,6 +23,9 @@ router.register('tags', views.TagViewSet) # Image attachments router.register('image-attachments', views.ImageAttachmentViewSet) +# Journal entries +router.register('journal-entries', views.JournalEntryViewSet) + # Config contexts router.register('config-contexts', views.ConfigContextViewSet) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 38077c89a..cee5146a6 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -11,9 +11,7 @@ from rq import Worker from extras import filters from extras.choices import JobResultStatusChoices -from extras.models import ( - ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem, -) +from extras.models import * from extras.models import CustomField from extras.reports import get_report, get_reports, run_report from extras.scripts import get_script, get_scripts, run_script @@ -21,7 +19,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.views import ModelViewSet from utilities.exceptions import RQWorkerNotRunningException -from utilities.utils import copy_safe_request, get_subquery +from utilities.utils import copy_safe_request, count_related from . import serializers @@ -39,7 +37,6 @@ class ConfigContextQuerySetMixin: Provides a get_queryset() method which deals with adding the config context data annotation or not. """ - def get_queryset(self): """ Build the proper queryset based on the request context @@ -49,11 +46,22 @@ class ConfigContextQuerySetMixin: Else, return the queryset annotated with config context data """ - + queryset = super().get_queryset() request = self.get_serializer_context()['request'] - if request.query_params.get('brief') or 'config_context' in request.query_params.get('exclude', []): - return self.queryset - return self.queryset.annotate_config_context_data() + if self.brief or 'config_context' in request.query_params.get('exclude', []): + return queryset + return queryset.annotate_config_context_data() + + +# +# Webhooks +# + +class WebhookViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = Webhook.objects.all() + serializer_class = serializers.WebhookSerializer + filterset_class = filters.WebhookFilterSet # @@ -85,6 +93,17 @@ class CustomFieldModelViewSet(ModelViewSet): return context +# +# Custom links +# + +class CustomLinkViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = CustomLink.objects.all() + serializer_class = serializers.CustomLinkSerializer + filterset_class = filters.CustomLinkFilterSet + + # # Export templates # @@ -102,7 +121,7 @@ class ExportTemplateViewSet(ModelViewSet): class TagViewSet(ModelViewSet): queryset = Tag.objects.annotate( - tagged_items=get_subquery(TaggedItem, 'tag') + tagged_items=count_related(TaggedItem, 'tag') ) serializer_class = serializers.TagSerializer filterset_class = filters.TagFilterSet @@ -119,13 +138,24 @@ class ImageAttachmentViewSet(ModelViewSet): filterset_class = filters.ImageAttachmentFilterSet +# +# Journal entries +# + +class JournalEntryViewSet(ModelViewSet): + metadata_class = ContentTypeMetadata + queryset = JournalEntry.objects.all() + serializer_class = serializers.JournalEntrySerializer + filterset_class = filters.JournalEntryFilterSet + + # # Config contexts # class ConfigContextViewSet(ModelViewSet): queryset = ConfigContext.objects.prefetch_related( - 'regions', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', + 'regions', 'site_groups', 'sites', 'roles', 'platforms', 'tenant_groups', 'tenants', ) serializer_class = serializers.ConfigContextSerializer filterset_class = filters.ConfigContextFilterSet diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 45f8ac31f..33c70f70d 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -13,6 +13,7 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_DATE = 'date' TYPE_URL = 'url' TYPE_SELECT = 'select' + TYPE_MULTISELECT = 'multiselect' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -21,6 +22,7 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_DATE, 'Date'), (TYPE_URL, 'URL'), (TYPE_SELECT, 'Selection'), + (TYPE_MULTISELECT, 'Multiple selection'), ) @@ -85,6 +87,32 @@ class ObjectChangeActionChoices(ChoiceSet): } +# +# Jounral entries +# + +class JournalEntryKindChoices(ChoiceSet): + + KIND_INFO = 'info' + KIND_SUCCESS = 'success' + KIND_WARNING = 'warning' + KIND_DANGER = 'danger' + + CHOICES = ( + (KIND_INFO, 'Info'), + (KIND_SUCCESS, 'Success'), + (KIND_WARNING, 'Warning'), + (KIND_DANGER, 'Danger'), + ) + + CSS_CLASSES = { + KIND_INFO: 'default', + KIND_SUCCESS: 'success', + KIND_WARNING: 'warning', + KIND_DANGER: 'danger', + } + + # # Log Levels for Reports and Scripts # diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 7b341f74d..495e03797 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -2,13 +2,14 @@ import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.forms import DateField, IntegerField, NullBooleanField -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.filters import BaseFilterSet, ContentTypeFilter from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, JobResult, ObjectChange, Tag +from .models import * __all__ = ( @@ -16,12 +17,15 @@ __all__ = ( 'ContentTypeFilterSet', 'CreatedUpdatedFilterSet', 'CustomFieldFilter', + 'CustomLinkFilterSet', 'CustomFieldModelFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', + 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', + 'WebhookFilterSet', ) EXACT_FILTER_TYPES = ( @@ -32,30 +36,41 @@ EXACT_FILTER_TYPES = ( ) +class WebhookFilterSet(BaseFilterSet): + content_types = ContentTypeFilter() + http_method = django_filters.MultipleChoiceFilter( + choices=WebhookHttpMethodChoices + ) + + class Meta: + model = Webhook + fields = [ + 'id', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', + 'http_method', 'http_content_type', 'secret', 'ssl_verification', 'ca_file_path', + ] + + class CustomFieldFilter(django_filters.Filter): """ Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name. """ def __init__(self, custom_field, *args, **kwargs): self.custom_field = custom_field + + if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER: + self.field_class = IntegerField + elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + self.field_class = NullBooleanField + elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE: + self.field_class = DateField + super().__init__(*args, **kwargs) - def filter(self, queryset, value): + self.field_name = f'custom_field_data__{self.field_name}' - # Skip filter on empty value - if value is None or not value.strip(): - return queryset - - # Apply the assigned filter logic (exact or loose) - if ( - self.custom_field.type in EXACT_FILTER_TYPES or - self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT - ): - kwargs = {f'custom_field_data__{self.field_name}': value} - else: - kwargs = {f'custom_field_data__{self.field_name}__icontains': value} - - return queryset.filter(**kwargs) + if custom_field.type not in EXACT_FILTER_TYPES: + if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: + self.lookup_expr = 'icontains' class CustomFieldModelFilterSet(django_filters.FilterSet): @@ -81,6 +96,13 @@ class CustomFieldFilterSet(django_filters.FilterSet): fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] +class CustomLinkFilterSet(BaseFilterSet): + + class Meta: + model = CustomLink + fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + + class ExportTemplateFilterSet(BaseFilterSet): class Meta: @@ -96,6 +118,37 @@ class ImageAttachmentFilterSet(BaseFilterSet): fields = ['id', 'content_type_id', 'object_id', 'name'] +class JournalEntryFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + created = django_filters.DateTimeFromToRangeFilter() + assigned_object_type = ContentTypeFilter() + created_by_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + created_by = django_filters.ModelMultipleChoiceFilter( + field_name='created_by__username', + queryset=User.objects.all(), + to_field_name='username', + label='User (name)', + ) + kind = django_filters.MultipleChoiceFilter( + choices=JournalEntryKindChoices + ) + + class Meta: + model = JournalEntry + fields = ['id', 'assigned_object_type_id', 'assigned_object_id', 'created', 'kind'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter(comments__icontains=value) + + class TagFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -131,6 +184,17 @@ class ConfigContextFilterSet(BaseFilterSet): to_field_name='slug', label='Region (slug)', ) + site_group = django_filters.ModelMultipleChoiceFilter( + field_name='site_groups__slug', + queryset=SiteGroup.objects.all(), + to_field_name='slug', + label='Site group (slug)', + ) + site_group_id = django_filters.ModelMultipleChoiceFilter( + field_name='site_groups', + queryset=SiteGroup.objects.all(), + label='Site group', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='sites', queryset=Site.objects.all(), @@ -142,6 +206,11 @@ class ConfigContextFilterSet(BaseFilterSet): to_field_name='slug', label='Site (slug)', ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device_types', + queryset=DeviceType.objects.all(), + label='Device type', + ) role_id = django_filters.ModelMultipleChoiceFilter( field_name='roles', queryset=DeviceRole.objects.all(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index eee54076b..4cf5023a3 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -2,25 +2,51 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.utils.safestring import mark_safe +from django.utils.translation import gettext as _ -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - ContentTypeSelect, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, - StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES, + CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup from .choices import * -from .models import ConfigContext, CustomField, ImageAttachment, ObjectChange, Tag +from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag # # Custom fields # -class CustomFieldModelForm(forms.ModelForm): +class CustomFieldForm(forms.Form): + """ + Extend Form to include custom field support. + """ + model = None + def __init__(self, *args, **kwargs): + if self.model is None: + raise NotImplementedError("CustomFieldForm must specify a model class.") + self.custom_fields = [] + + super().__init__(*args, **kwargs) + + # Append relevant custom fields to the form instance + obj_type = ContentType.objects.get_for_model(self.model) + for cf in CustomField.objects.filter(content_types=obj_type): + field_name = 'cf_{}'.format(cf.name) + self.fields[field_name] = cf.to_form_field() + + # Annotate the field in the list of CustomField form fields + self.custom_fields.append(field_name) + + +class CustomFieldModelForm(forms.ModelForm): + """ + Extend ModelForm to include custom field support. + """ def __init__(self, *args, **kwargs): self.obj_type = ContentType.objects.get_for_model(self._meta.model) @@ -46,13 +72,13 @@ class CustomFieldModelForm(forms.ModelForm): # Annotate the field in the list of CustomField form fields self.custom_fields.append(field_name) - def save(self, commit=True): + def clean(self): # Save custom field data on instance for cf_name in self.custom_fields: self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name) - return super().save(commit) + return super().clean() class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): @@ -116,6 +142,9 @@ class TagForm(BootstrapMixin, forms.ModelForm): fields = [ 'name', 'slug', 'color', 'description' ] + fieldsets = ( + ('Tag', ('name', 'slug', 'color', 'description')), + ) class TagCSVForm(CSVModelForm): @@ -149,7 +178,7 @@ class TagFilterForm(BootstrapMixin, forms.Form): model = Tag q = forms.CharField( required=False, - label='Search' + label=_('Search') ) @@ -181,10 +210,18 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): queryset=Region.objects.all(), required=False ) + site_groups = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) sites = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False ) + device_types = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) roles = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), required=False @@ -220,8 +257,8 @@ class ConfigContextForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConfigContext fields = ( - 'name', 'weight', 'description', 'is_active', 'regions', 'sites', 'roles', 'platforms', 'cluster_groups', - 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', + 'name', 'weight', 'description', 'is_active', 'regions', 'site_groups', 'sites', 'roles', 'device_types', + 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) @@ -250,54 +287,69 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): class ConfigContextFilterForm(BootstrapMixin, forms.Form): + field_order = [ + 'q', 'region_id', 'site_group_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', + 'tenant_group_id', 'tenant_id', + ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Regions') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site groups') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Sites') ) - role = DynamicModelMultipleChoiceField( + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device types') + ) + role_id = DynamicModelMultipleChoiceField( queryset=DeviceRole.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Roles') ) - platform = DynamicModelMultipleChoiceField( + platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Platforms') ) - cluster_group = DynamicModelMultipleChoiceField( + cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Cluster groups') ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, - label='Cluster' + label=_('Clusters') ) - tenant_group = DynamicModelMultipleChoiceField( + tenant_group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Tenant groups') ) - tenant = DynamicModelMultipleChoiceField( + tenant_id = DynamicModelMultipleChoiceField( queryset=Tenant.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Tenant') ) tag = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), to_field_name='slug', - required=False + required=False, + label=_('Tags') ) @@ -308,7 +360,7 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): class LocalConfigContextFilterForm(forms.Form): local_context_data = forms.NullBooleanField( required=False, - label='Has local config context data', + label=_('Has local config context data'), widget=StaticSelect2( choices=BOOLEAN_WITH_BLANK_CHOICES ) @@ -328,6 +380,78 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm): ] +# +# Journal entries +# + +class JournalEntryForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = JournalEntry + fields = ['assigned_object_type', 'assigned_object_id', 'kind', 'comments'] + widgets = { + 'assigned_object_type': forms.HiddenInput, + 'assigned_object_id': forms.HiddenInput, + } + + +class JournalEntryBulkEditForm(BootstrapMixin, BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=JournalEntry.objects.all(), + widget=forms.MultipleHiddenInput + ) + kind = forms.ChoiceField( + choices=JournalEntryKindChoices, + required=False + ) + comments = forms.CharField( + required=False, + widget=forms.Textarea() + ) + + class Meta: + nullable_fields = [] + + +class JournalEntryFilterForm(BootstrapMixin, forms.Form): + model = JournalEntry + q = forms.CharField( + required=False, + label=_('Search') + ) + created_after = forms.DateTimeField( + required=False, + label=_('After'), + widget=DateTimePicker() + ) + created_before = forms.DateTimeField( + required=False, + label=_('Before'), + widget=DateTimePicker() + ) + created_by_id = DynamicModelMultipleChoiceField( + queryset=User.objects.all(), + required=False, + label=_('User'), + widget=APISelectMultiple( + api_url='/api/users/users/', + ) + ) + assigned_object_type_id = DynamicModelMultipleChoiceField( + queryset=ContentType.objects.all(), + required=False, + label=_('Object Type'), + widget=APISelectMultiple( + api_url='/api/extras/content-types/', + ) + ) + kind = forms.ChoiceField( + choices=add_blank_choice(JournalEntryKindChoices), + required=False, + widget=StaticSelect2() + ) + + # # Change logging # @@ -336,16 +460,16 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): model = ObjectChange q = forms.CharField( required=False, - label='Search' + label=_('Search') ) time_after = forms.DateTimeField( - label='After', required=False, + label=_('After'), widget=DateTimePicker() ) time_before = forms.DateTimeField( - label='Before', required=False, + label=_('Before'), widget=DateTimePicker() ) action = forms.ChoiceField( @@ -356,8 +480,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, - display_field='username', - label='User', + label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', ) @@ -365,8 +488,7 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): changed_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), required=False, - display_field='display_name', - label='Object Type', + label=_('Object Type'), widget=APISelectMultiple( api_url='/api/extras/content-types/', ) diff --git a/netbox/extras/management/commands/renaturalize.py b/netbox/extras/management/commands/renaturalize.py index cfd037910..8ad51966a 100644 --- a/netbox/extras/management/commands/renaturalize.py +++ b/netbox/extras/management/commands/renaturalize.py @@ -1,3 +1,4 @@ +from cacheops import invalidate_model from django.apps import apps from django.core.management.base import BaseCommand, CommandError @@ -27,7 +28,7 @@ class Command(BaseCommand): app_label, model_name = name.split('.') except ValueError: raise CommandError( - "Invalid format: {}. Models must be specified in the form app_label.ModelName.".format(name) + f"Invalid format: {name}. Models must be specified in the form app_label.ModelName." ) try: app_config = apps.get_app_config(app_label) @@ -36,13 +37,13 @@ class Command(BaseCommand): try: model = app_config.get_model(model_name) except LookupError: - raise CommandError("Unknown model: {}.{}".format(app_label, model_name)) + raise CommandError(f"Unknown model: {app_label}.{model_name}") fields = [ field for field in model._meta.concrete_fields if type(field) is NaturalOrderingField ] if not fields: raise CommandError( - "Invalid model: {}.{} does not employ natural ordering".format(app_label, model_name) + f"Invalid model: {app_label}.{model_name} does not employ natural ordering" ) models.append( (model, fields) @@ -67,7 +68,7 @@ class Command(BaseCommand): models = self._get_models(args) if options['verbosity']: - self.stdout.write("Renaturalizing {} models.".format(len(models))) + self.stdout.write(f"Renaturalizing {len(models)} models.") for model, fields in models: for field in fields: @@ -78,7 +79,7 @@ class Command(BaseCommand): # Print the model and field name if options['verbosity']: self.stdout.write( - "{}.{} ({})... ".format(model._meta.label, field.target_field, field.name), + f"{model._meta.label}.{field.target_field} ({field.name})... ", ending='\n' if options['verbosity'] >= 2 else '' ) self.stdout.flush() @@ -89,23 +90,26 @@ class Command(BaseCommand): naturalized_value = naturalize(value, max_length=field.max_length) if options['verbosity'] >= 2: - self.stdout.write(" {} -> {}".format(value, naturalized_value), ending='') + self.stdout.write(f" {value} -> {naturalized_value}", ending='') self.stdout.flush() # Update each unique field value in bulk changed = model.objects.filter(name=value).update(**{field.name: naturalized_value}) if options['verbosity'] >= 2: - self.stdout.write(" ({})".format(changed)) + self.stdout.write(f" ({changed})") count += changed # Print the total count of alterations for the field if options['verbosity'] >= 2: - self.stdout.write(self.style.SUCCESS("{} {} updated ({} unique values)".format( - count, model._meta.verbose_name_plural, queryset.count() - ))) + self.stdout.write(self.style.SUCCESS( + f"{count} {model._meta.verbose_name_plural} updated ({queryset.count()} unique values)" + )) elif options['verbosity']: self.stdout.write(self.style.SUCCESS(str(count))) + # Invalidate cached queries + invalidate_model(model) + if options['verbosity']: self.stdout.write(self.style.SUCCESS("Done.")) diff --git a/netbox/extras/management/commands/webhook_receiver.py b/netbox/extras/management/commands/webhook_receiver.py index b15dc9d27..147e4c261 100644 --- a/netbox/extras/management/commands/webhook_receiver.py +++ b/netbox/extras/management/commands/webhook_receiver.py @@ -1,3 +1,4 @@ +import json import sys from http.server import HTTPServer, BaseHTTPRequestHandler @@ -47,8 +48,10 @@ class WebhookHandler(BaseHTTPRequestHandler): # Print the request body (if any) content_length = self.headers.get('Content-Length') if content_length is not None: - body = self.rfile.read(int(content_length)) - print(body.decode('utf-8')) + body = self.rfile.read(int(content_length)).decode('utf-8') + if self.headers.get('Content-Type') == 'application/json': + body = json.loads(body) + print(json.dumps(body, indent=4)) else: print('(No body)') diff --git a/netbox/extras/migrations/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py index 2c992c700..41b2febe7 100644 --- a/netbox/extras/migrations/0051_migrate_customfields.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -67,7 +67,7 @@ def migrate_customfieldvalues(apps, schema_editor): cf_data = model.objects.filter(pk=cfv.obj_id).values('custom_field_data').first() try: cf_data['custom_field_data'][cfv.field.name] = deserialize_value(cfv.field, cfv.serialized_value) - except ValueError as e: + except Exception as e: print(f'{cfv.field.name} ({cfv.field.type}): {cfv.serialized_value} ({cfv.pk})') raise e model.objects.filter(pk=cfv.obj_id).update(**cf_data) diff --git a/netbox/extras/migrations/0054_standardize_models.py b/netbox/extras/migrations/0054_standardize_models.py new file mode 100644 index 000000000..c73043345 --- /dev/null +++ b/netbox/extras/migrations/0054_standardize_models.py @@ -0,0 +1,61 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0053_rename_webhook_obj_type'), + ] + + operations = [ + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tag', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/extras/migrations/0055_objectchange_data.py b/netbox/extras/migrations/0055_objectchange_data.py new file mode 100644 index 000000000..4dc33fc1c --- /dev/null +++ b/netbox/extras/migrations/0055_objectchange_data.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2b1 on 2021-03-03 20:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0054_standardize_models'), + ] + + operations = [ + migrations.RenameField( + model_name='objectchange', + old_name='object_data', + new_name='postchange_data', + ), + migrations.AlterField( + model_name='objectchange', + name='postchange_data', + field=models.JSONField(blank=True, editable=False, null=True), + ), + migrations.AddField( + model_name='objectchange', + name='prechange_data', + field=models.JSONField(blank=True, editable=False, null=True), + ), + ] diff --git a/netbox/extras/migrations/0056_extend_configcontext.py b/netbox/extras/migrations/0056_extend_configcontext.py new file mode 100644 index 000000000..9c7e2d700 --- /dev/null +++ b/netbox/extras/migrations/0056_extend_configcontext.py @@ -0,0 +1,22 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0130_sitegroup'), + ('extras', '0055_objectchange_data'), + ] + + operations = [ + migrations.AddField( + model_name='configcontext', + name='site_groups', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_site_groups_+', to='dcim.SiteGroup'), + ), + migrations.AddField( + model_name='configcontext', + name='device_types', + field=models.ManyToManyField(blank=True, related_name='_extras_configcontext_device_types_+', to='dcim.DeviceType'), + ), + ] diff --git a/netbox/extras/migrations/0057_customlink_rename_fields.py b/netbox/extras/migrations/0057_customlink_rename_fields.py new file mode 100644 index 000000000..6aba35d9f --- /dev/null +++ b/netbox/extras/migrations/0057_customlink_rename_fields.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2b1 on 2021-03-09 01:42 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0056_extend_configcontext'), + ] + + operations = [ + migrations.RenameField( + model_name='customlink', + old_name='text', + new_name='link_text', + ), + migrations.RenameField( + model_name='customlink', + old_name='url', + new_name='link_url', + ), + migrations.AlterField( + model_name='customlink', + name='new_window', + field=models.BooleanField(default=False), + ), + ] diff --git a/netbox/extras/migrations/0058_journalentry.py b/netbox/extras/migrations/0058_journalentry.py new file mode 100644 index 000000000..14be2a50d --- /dev/null +++ b/netbox/extras/migrations/0058_journalentry.py @@ -0,0 +1,31 @@ +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0057_customlink_rename_fields'), + ] + + operations = [ + migrations.CreateModel( + name='JournalEntry', + fields=[ + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('assigned_object_id', models.PositiveIntegerField()), + ('created', models.DateTimeField(auto_now_add=True)), + ('kind', models.CharField(default='info', max_length=30)), + ('comments', models.TextField()), + ('assigned_object_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name_plural': 'journal entries', + 'ordering': ('-created',), + }, + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index c6191bbd2..84676453f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,21 +1,18 @@ -from .change_logging import ChangeLoggedModel, ObjectChange -from .customfields import CustomField, CustomFieldModel -from .models import ( - ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, - Webhook, -) +from .change_logging import ObjectChange +from .configcontexts import ConfigContext, ConfigContextModel +from .customfields import CustomField +from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook from .tags import Tag, TaggedItem __all__ = ( - 'ChangeLoggedModel', 'ConfigContext', 'ConfigContextModel', 'CustomField', - 'CustomFieldModel', 'CustomLink', 'ExportTemplate', 'ImageAttachment', 'JobResult', + 'JournalEntry', 'ObjectChange', 'Report', 'Script', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 52ffd38f9..fac86b641 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -4,48 +4,12 @@ from django.contrib.contenttypes.models import ContentType from django.db import models from django.urls import reverse -from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from extras.choices import * +from netbox.models import BigIDModel +from utilities.querysets import RestrictedQuerySet -# -# Change logging -# - -class ChangeLoggedModel(models.Model): - """ - An abstract model which adds fields to store the creation and last-updated times for an object. Both fields can be - null to facilitate adding these fields to existing instances via a database migration. - """ - created = models.DateField( - auto_now_add=True, - blank=True, - null=True - ) - last_updated = models.DateTimeField( - auto_now=True, - blank=True, - null=True - ) - - class Meta: - abstract = True - - def to_objectchange(self, action): - """ - Return a new ObjectChange representing a change made to this object. This will typically be called automatically - by ChangeLoggingMiddleware. - """ - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self) - ) - - -class ObjectChange(models.Model): +class ObjectChange(BigIDModel): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the @@ -103,15 +67,22 @@ class ObjectChange(models.Model): max_length=200, editable=False ) - object_data = models.JSONField( - editable=False + prechange_data = models.JSONField( + editable=False, + blank=True, + null=True + ) + postchange_data = models.JSONField( + editable=False, + blank=True, + null=True ) objects = RestrictedQuerySet.as_manager() csv_headers = [ 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object_id', - 'related_object_type', 'related_object_id', 'object_repr', 'object_data', + 'related_object_type', 'related_object_id', 'object_repr', 'prechange_data', 'postchange_data', ] class Meta: @@ -150,7 +121,8 @@ class ObjectChange(models.Model): self.related_object_type, self.related_object_id, self.object_repr, - self.object_data, + self.prechange_data, + self.postchange_data, ) def get_action_class(self): diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py new file mode 100644 index 000000000..8c142de8b --- /dev/null +++ b/netbox/extras/models/configcontexts.py @@ -0,0 +1,166 @@ +from collections import OrderedDict + +from django.core.validators import ValidationError +from django.db import models +from django.urls import reverse + +from extras.querysets import ConfigContextQuerySet +from extras.utils import extras_features +from netbox.models import ChangeLoggedModel +from utilities.utils import deepmerge + + +__all__ = ( + 'ConfigContext', + 'ConfigContextModel', +) + + +# +# Config contexts +# + +@extras_features('webhooks') +class ConfigContext(ChangeLoggedModel): + """ + A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned + qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B + will be available to a Device in site A assigned to tenant B. Data is stored in JSON format. + """ + name = models.CharField( + max_length=100, + unique=True + ) + weight = models.PositiveSmallIntegerField( + default=1000 + ) + description = models.CharField( + max_length=200, + blank=True + ) + is_active = models.BooleanField( + default=True, + ) + regions = models.ManyToManyField( + to='dcim.Region', + related_name='+', + blank=True + ) + site_groups = models.ManyToManyField( + to='dcim.SiteGroup', + related_name='+', + blank=True + ) + sites = models.ManyToManyField( + to='dcim.Site', + related_name='+', + blank=True + ) + device_types = models.ManyToManyField( + to='dcim.DeviceType', + related_name='+', + blank=True + ) + roles = models.ManyToManyField( + to='dcim.DeviceRole', + related_name='+', + blank=True + ) + platforms = models.ManyToManyField( + to='dcim.Platform', + related_name='+', + blank=True + ) + cluster_groups = models.ManyToManyField( + to='virtualization.ClusterGroup', + related_name='+', + blank=True + ) + clusters = models.ManyToManyField( + to='virtualization.Cluster', + related_name='+', + blank=True + ) + tenant_groups = models.ManyToManyField( + to='tenancy.TenantGroup', + related_name='+', + blank=True + ) + tenants = models.ManyToManyField( + to='tenancy.Tenant', + related_name='+', + blank=True + ) + tags = models.ManyToManyField( + to='extras.Tag', + related_name='+', + blank=True + ) + data = models.JSONField() + + objects = ConfigContextQuerySet.as_manager() + + class Meta: + ordering = ['weight', 'name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('extras:configcontext', kwargs={'pk': self.pk}) + + def clean(self): + super().clean() + + # Verify that JSON data is provided as an object + if type(self.data) is not dict: + raise ValidationError( + {'data': 'JSON data must be in object form. Example: {"foo": 123}'} + ) + + +class ConfigContextModel(models.Model): + """ + A model which includes local configuration context data. This local data will override any inherited data from + ConfigContexts. + """ + local_context_data = models.JSONField( + blank=True, + null=True, + ) + + class Meta: + abstract = True + + def get_config_context(self): + """ + 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() + + if not hasattr(self, 'config_context_data'): + # The annotation is not available, so we fall back to manually querying for the config context objects + config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) + else: + # The attribute may exist, but the annotated value could be None if there is no config context data + config_context_data = self.config_context_data or [] + + for context in config_context_data: + data = deepmerge(data, context) + + # If the object has local config context data defined, merge it last + if self.local_context_data: + data = deepmerge(data, self.local_context_data) + + return data + + def clean(self): + super().clean() + + # Verify that JSON data is provided as an object + if self.local_context_data and type(self.local_context_data) is not dict: + raise ValidationError( + {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'} + ) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index be06eea8a..8b97877a4 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -1,69 +1,23 @@ import re -from collections import OrderedDict -from datetime import datetime +from datetime import datetime, date from django import forms from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField -from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import RegexValidator, ValidationError from django.db import models from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery -from utilities.forms import CSVChoiceField, DatePicker, LaxURLField, StaticSelect2, add_blank_choice +from netbox.models import BigIDModel +from utilities.forms import ( + CSVChoiceField, DatePicker, LaxURLField, StaticSelect2Multiple, StaticSelect2, add_blank_choice, +) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex -class CustomFieldModel(models.Model): - """ - Abstract class for any model which may have custom fields associated with it. - """ - custom_field_data = models.JSONField( - encoder=DjangoJSONEncoder, - blank=True, - default=dict - ) - - class Meta: - abstract = True - - @property - def cf(self): - """ - Convenience wrapper for custom field data. - """ - return self.custom_field_data - - def get_custom_fields(self): - """ - Return a dictionary of custom fields for a single object in the form {: value}. - """ - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) - - def clean(self): - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} - - # Validate all field values - for field_name, value in self.custom_field_data.items(): - if field_name not in custom_fields: - raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") - try: - custom_fields[field_name].validate(value) - except ValidationError as e: - raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") - - # Check for missing required values - for cf in custom_fields.values(): - if cf.required and cf.name not in self.custom_field_data: - raise ValidationError(f"Missing required custom field '{cf.name}'.") - - class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): use_in_migrations = True @@ -75,7 +29,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(models.Model): +class CustomField(BigIDModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', @@ -172,6 +126,8 @@ class CustomField(models.Model): obj.save() def clean(self): + super().clean() + # Validate the field's default value (if any) if self.default is not None: try: @@ -192,13 +148,17 @@ class CustomField(models.Model): }) # Regex validation can be set only for text fields - if self.validation_regex and self.type != CustomFieldTypeChoices.TYPE_TEXT: + regex_types = (CustomFieldTypeChoices.TYPE_TEXT, CustomFieldTypeChoices.TYPE_URL) + if self.validation_regex and self.type not in regex_types: raise ValidationError({ 'validation_regex': "Regular expression validation is supported only for text and URL fields" }) # Choices can be set only on selection fields - if self.choices and self.type != CustomFieldTypeChoices.TYPE_SELECT: + if self.choices and self.type not in ( + CustomFieldTypeChoices.TYPE_SELECT, + CustomFieldTypeChoices.TYPE_MULTISELECT + ): raise ValidationError({ 'choices': "Choices may be set only for custom selection fields." }) @@ -251,7 +211,7 @@ class CustomField(models.Model): field = forms.DateField(required=required, initial=initial, widget=DatePicker()) # Select - elif self.type == CustomFieldTypeChoices.TYPE_SELECT: + elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT): choices = [(c, c) for c in self.choices] default_choice = self.default if self.default in self.choices else None @@ -262,10 +222,16 @@ class CustomField(models.Model): if set_initial and default_choice: initial = default_choice - field_class = CSVChoiceField if for_csv_import else forms.ChoiceField - field = field_class( - choices=choices, required=required, initial=initial, widget=StaticSelect2() - ) + if self.type == CustomFieldTypeChoices.TYPE_SELECT: + field_class = CSVChoiceField if for_csv_import else forms.ChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2() + ) + else: + field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField + field = field_class( + choices=choices, required=required, initial=initial, widget=StaticSelect2Multiple() + ) # URL elif self.type == CustomFieldTypeChoices.TYPE_URL: @@ -317,10 +283,11 @@ class CustomField(models.Model): # Validate date if self.type == CustomFieldTypeChoices.TYPE_DATE: - try: - datetime.strptime(value, '%Y-%m-%d') - except ValueError: - raise ValidationError("Date values must be in the format YYYY-MM-DD.") + if type(value) is not date: + try: + datetime.strptime(value, '%Y-%m-%d') + except ValueError: + raise ValidationError("Date values must be in the format YYYY-MM-DD.") # Validate selected choice if self.type == CustomFieldTypeChoices.TYPE_SELECT: @@ -329,5 +296,12 @@ class CustomField(models.Model): f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}" ) + # Validate all selected choices + if self.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + if not set(value).issubset(self.choices): + raise ValidationError( + f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" + ) + elif self.required: raise ValidationError("Required field cannot be empty.") diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 8934d9732..d54eac0db 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,6 +1,5 @@ import json import uuid -from collections import OrderedDict from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey @@ -8,24 +7,34 @@ from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.http import HttpResponse -from django.urls import reverse from django.utils import timezone from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * -from extras.models import ChangeLoggedModel -from extras.querysets import ConfigContextQuerySet from extras.utils import extras_features, FeatureQuery, image_upload +from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet -from utilities.utils import deepmerge, render_jinja2 +from utilities.utils import render_jinja2 + + +__all__ = ( + 'CustomLink', + 'ExportTemplate', + 'ImageAttachment', + 'JobResult', + 'JournalEntry', + 'Report', + 'Script', + 'Webhook', +) # # Webhooks # -class Webhook(models.Model): +class Webhook(BigIDModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -109,6 +118,8 @@ class Webhook(models.Model): 'Leave blank to use the system defaults.' ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ('name',) unique_together = ('payload_url', 'type_create', 'type_update', 'type_delete',) @@ -117,11 +128,15 @@ class Webhook(models.Model): return self.name def clean(self): + super().clean() + + # At least one action type must be selected if not self.type_create and not self.type_delete and not self.type_update: raise ValidationError( "You must select at least one type: create, update, and/or delete." ) + # CA file path requires SSL verification enabled if not self.ssl_verification and self.ca_file_path: raise ValidationError({ 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.' @@ -154,7 +169,7 @@ class Webhook(models.Model): # Custom links # -class CustomLink(models.Model): +class CustomLink(BigIDModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -168,13 +183,13 @@ class CustomLink(models.Model): max_length=100, unique=True ) - text = models.CharField( + link_text = models.CharField( max_length=500, help_text="Jinja2 template code for link text" ) - url = models.CharField( + link_url = models.CharField( max_length=500, - verbose_name='URL', + verbose_name='Link URL', help_text="Jinja2 template code for link URL" ) weight = models.PositiveSmallIntegerField( @@ -192,9 +207,12 @@ class CustomLink(models.Model): help_text="The class of the first link in a group will be used for the dropdown button" ) new_window = models.BooleanField( + default=False, help_text="Force link to open in a new window" ) + objects = RestrictedQuerySet.as_manager() + class Meta: ordering = ['group_name', 'weight', 'name'] @@ -206,7 +224,7 @@ class CustomLink(models.Model): # Export templates # -class ExportTemplate(models.Model): +class ExportTemplate(BigIDModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -243,7 +261,15 @@ class ExportTemplate(models.Model): ] def __str__(self): - return '{}: {}'.format(self.content_type, self.name) + return f"{self.content_type}: {self.name}" + + def clean(self): + super().clean() + + if self.name.lower() == 'table': + raise ValidationError({ + 'name': f'"{self.name}" is a reserved name. Please choose a different name.' + }) def render(self, queryset): """ @@ -281,7 +307,7 @@ class ExportTemplate(models.Model): # Image attachments # -class ImageAttachment(models.Model): +class ImageAttachment(BigIDModel): """ An uploaded image which is associated with an object. """ @@ -354,142 +380,52 @@ class ImageAttachment(models.Model): # -# Config contexts +# Journal entries # -class ConfigContext(ChangeLoggedModel): +class JournalEntry(BigIDModel): """ - A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned - qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B - will be available to a Device in site A assigned to tenant B. Data is stored in JSON format. + A historical remark concerning an object; collectively, these form an object's journal. The journal is used to + preserve historical context around an object, and complements NetBox's built-in change logging. For example, you + might record a new journal entry when a device undergoes maintenance, or when a prefix is expanded. """ - name = models.CharField( - max_length=100, - unique=True + assigned_object_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE ) - weight = models.PositiveSmallIntegerField( - default=1000 + assigned_object_id = models.PositiveIntegerField() + assigned_object = GenericForeignKey( + ct_field='assigned_object_type', + fk_field='assigned_object_id' ) - description = models.CharField( - max_length=200, - blank=True + created = models.DateTimeField( + auto_now_add=True ) - is_active = models.BooleanField( - default=True, + created_by = models.ForeignKey( + to=User, + on_delete=models.SET_NULL, + blank=True, + null=True ) - regions = models.ManyToManyField( - to='dcim.Region', - related_name='+', - blank=True + kind = models.CharField( + max_length=30, + choices=JournalEntryKindChoices, + default=JournalEntryKindChoices.KIND_INFO ) - sites = models.ManyToManyField( - to='dcim.Site', - related_name='+', - blank=True - ) - roles = models.ManyToManyField( - to='dcim.DeviceRole', - related_name='+', - blank=True - ) - platforms = models.ManyToManyField( - to='dcim.Platform', - related_name='+', - blank=True - ) - cluster_groups = models.ManyToManyField( - to='virtualization.ClusterGroup', - related_name='+', - blank=True - ) - clusters = models.ManyToManyField( - to='virtualization.Cluster', - related_name='+', - blank=True - ) - tenant_groups = models.ManyToManyField( - to='tenancy.TenantGroup', - related_name='+', - blank=True - ) - tenants = models.ManyToManyField( - to='tenancy.Tenant', - related_name='+', - blank=True - ) - tags = models.ManyToManyField( - to='extras.Tag', - related_name='+', - blank=True - ) - data = models.JSONField() + comments = models.TextField() - objects = ConfigContextQuerySet.as_manager() + objects = RestrictedQuerySet.as_manager() class Meta: - ordering = ['weight', 'name'] + ordering = ('-created',) + verbose_name_plural = 'journal entries' def __str__(self): - return self.name + time_created = self.created.replace(microsecond=0) + return f"{time_created} - {self.get_kind_display()}" - def get_absolute_url(self): - return reverse('extras:configcontext', kwargs={'pk': self.pk}) - - def clean(self): - - # Verify that JSON data is provided as an object - if type(self.data) is not dict: - raise ValidationError( - {'data': 'JSON data must be in object form. Example: {"foo": 123}'} - ) - - -class ConfigContextModel(models.Model): - """ - A model which includes local configuration context data. This local data will override any inherited data from - ConfigContexts. - """ - local_context_data = models.JSONField( - blank=True, - null=True, - ) - - class Meta: - abstract = True - - def get_config_context(self): - """ - 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() - - if not hasattr(self, 'config_context_data'): - # The annotation is not available, so we fall back to manually querying for the config context objects - config_context_data = ConfigContext.objects.get_for_object(self, aggregate_data=True) - else: - # The attribute may exist, but the annotated value could be None if there is no config context data - config_context_data = self.config_context_data or [] - - for context in config_context_data: - data = deepmerge(data, context) - - # If the object has local config context data defined, merge it last - if self.local_context_data: - data = deepmerge(data, self.local_context_data) - - return data - - def clean(self): - - super().clean() - - # Verify that JSON data is provided as an object - if self.local_context_data and type(self.local_context_data) is not dict: - raise ValidationError( - {'local_context_data': 'JSON data must be in object form. Example: {"foo": 123}'} - ) + def get_kind_class(self): + return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) # @@ -522,7 +458,7 @@ class Report(models.Model): # Job results # -class JobResult(models.Model): +class JobResult(BigIDModel): """ This model stores the results from running a user-defined report. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 4b5acdc76..6268751b2 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,8 +1,10 @@ from django.db import models +from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.models import ChangeLoggedModel +from extras.utils import extras_features +from netbox.models import BigIDModel, ChangeLoggedModel from utilities.choices import ColorChoices from utilities.fields import ColorField from utilities.querysets import RestrictedQuerySet @@ -12,7 +14,8 @@ from utilities.querysets import RestrictedQuerySet # Tags # -class Tag(TagBase, ChangeLoggedModel): +@extras_features('webhooks') +class Tag(ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -28,6 +31,9 @@ class Tag(TagBase, ChangeLoggedModel): class Meta: ordering = ['name'] + def get_absolute_url(self): + return reverse('extras:tag', args=[self.pk]) + def slugify(self, tag, i=None): # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names) slug = slugify(tag, allow_unicode=True) @@ -44,7 +50,7 @@ class Tag(TagBase, ChangeLoggedModel): ) -class TaggedItem(GenericTaggedItemBase): +class TaggedItem(BigIDModel, GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 8276787db..be5ae6416 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,7 +1,8 @@ +from django.contrib.postgres.aggregates import JSONBAgg from django.db.models import OuterRef, Subquery, Q from extras.models.tags import TaggedItem -from utilities.query_functions import EmptyGroupByJSONBAgg, OrderableJSONBAgg +from utilities.query_functions import EmptyGroupByJSONBAgg from utilities.querysets import RestrictedQuerySet @@ -18,7 +19,10 @@ class ConfigContextQuerySet(RestrictedQuerySet): # `device_role` for Device; `role` for VirtualMachine role = getattr(obj, 'device_role', None) or obj.role - # Virtualization cluster for VirtualMachine + # Device type assignment is relevant only for Devices + device_type = getattr(obj, 'device_type', None) + + # Cluster assignment is relevant only for VirtualMachines cluster = getattr(obj, 'cluster', None) cluster_group = getattr(cluster, 'group', None) @@ -27,14 +31,17 @@ class ConfigContextQuerySet(RestrictedQuerySet): # Match against the directly assigned region as well as any parent regions. region = getattr(obj.site, 'region', None) - if region: - regions = region.get_ancestors(include_self=True) - else: - regions = [] + regions = region.get_ancestors(include_self=True) if region else [] + + # Match against the directly assigned site group as well as any parent site groups. + sitegroup = getattr(obj.site, 'group', None) + sitegroups = sitegroup.get_ancestors(include_self=True) if sitegroup else [] queryset = self.filter( Q(regions__in=regions) | Q(regions=None), + Q(site_groups__in=sitegroups) | Q(site_groups=None), Q(sites=obj.site) | Q(sites=None), + Q(device_types=device_type) | Q(device_types=None), Q(roles=role) | Q(roles=None), Q(platforms=obj.platform) | Q(platforms=None), Q(cluster_groups=cluster_group) | Q(cluster_groups=None), @@ -47,7 +54,7 @@ class ConfigContextQuerySet(RestrictedQuerySet): if aggregate_data: return queryset.aggregate( - config_context_data=OrderableJSONBAgg('data', ordering=['weight', 'name']) + config_context_data=JSONBAgg('data', ordering=['weight', 'name']) )['config_context_data'] return queryset @@ -89,6 +96,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): } base_query = Q( Q(platforms=OuterRef('platform')) | Q(platforms=None), + Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None), + Q(clusters=OuterRef('cluster')) | Q(clusters=None), Q(tenant_groups=OuterRef('tenant__group')) | Q(tenant_groups=None), Q(tenants=OuterRef('tenant')) | Q(tenants=None), Q( @@ -105,16 +114,17 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): ) if self.model._meta.model_name == 'device': + 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) region_field = 'site__region' + sitegroup_field = 'site__group' elif self.model._meta.model_name == 'virtualmachine': base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND) - base_query.add((Q(cluster_groups=OuterRef('cluster__group')) | Q(cluster_groups=None)), Q.AND) - base_query.add((Q(clusters=OuterRef('cluster')) | Q(clusters=None)), Q.AND) base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND) region_field = 'cluster__site__region' + sitegroup_field = 'cluster__site__group' base_query.add( (Q( @@ -126,4 +136,14 @@ class ConfigContextModelQuerySet(RestrictedQuerySet): Q.AND ) + base_query.add( + (Q( + site_groups__tree_id=OuterRef(f'{sitegroup_field}__tree_id'), + site_groups__level__lte=OuterRef(f'{sitegroup_field}__level'), + site_groups__lft__lte=OuterRef(f'{sitegroup_field}__lft'), + site_groups__rght__gte=OuterRef(f'{sitegroup_field}__rght'), + ) | Q(site_groups=None)), + Q.AND + ) + return base_query diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index 3ef8741a5..29ecc3ef3 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -180,28 +180,26 @@ class ObjectVar(ScriptVariable): A single object within NetBox. :param model: The NetBox model being referenced - :param display_field: The attribute of the returned object to display in the selection list (default: 'name') + :param display_field: The attribute of the returned object to display in the selection list (DEPRECATED) :param query_params: A dictionary of additional query parameters to attach when making REST API requests (optional) :param null_option: The label to use as a "null" selection option (optional) """ form_field = DynamicModelChoiceField - def __init__(self, model=None, queryset=None, display_field='name', query_params=None, null_option=None, *args, - **kwargs): + def __init__(self, model, query_params=None, null_option=None, *args, **kwargs): + + # TODO: Remove display_field in v2.12 + if 'display_field' in kwargs: + warnings.warn( + "The 'display_field' parameter has been deprecated, and will be removed in NetBox v2.12. Object " + "variables will now reference the 'display' attribute available on all model serializers by default." + ) + display_field = kwargs.pop('display_field', 'display') + super().__init__(*args, **kwargs) - # Set the form field's queryset. Support backward compatibility for the "queryset" argument for now. - if model is not None: - self.field_attrs['queryset'] = model.objects.all() - elif queryset is not None: - warnings.warn( - f'{self}: Specifying a queryset for ObjectVar is no longer supported. Please use "model" instead.' - ) - self.field_attrs['queryset'] = queryset - else: - raise TypeError('ObjectVar must specify a model') - self.field_attrs.update({ + 'queryset': model.objects.all(), 'display_field': display_field, 'query_params': query_params, 'null_option': null_option, diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 0d6295e5b..ba7c725bf 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -36,6 +36,9 @@ def _handle_changed_object(request, sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(action) + # TODO: Move this to to_objectchange() + if hasattr(instance, '_prechange_snapshot'): + objectchange.prechange_data = instance._prechange_snapshot objectchange.user = request.user objectchange.request_id = request.id objectchange.save() @@ -62,6 +65,9 @@ def _handle_deleted_object(request, sender, instance, **kwargs): # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) + # TODO: Move this to to_objectchange() + if hasattr(instance, '_prechange_snapshot'): + objectchange.prechange_data = instance._prechange_snapshot objectchange.user = request.user objectchange.request_id = request.id objectchange.save() diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index cccb56c0a..70b5c5f70 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,16 +1,10 @@ import django_tables2 as tables from django.conf import settings -from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ToggleColumn -from .models import ConfigContext, ObjectChange, Tag, TaggedItem - -TAGGED_ITEM = """ -{% if value.get_absolute_url %} - {{ value }} -{% else %} - {{ value }} -{% endif %} -""" +from utilities.tables import ( + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ToggleColumn, +) +from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem CONFIGCONTEXT_ACTIONS = """ {% if perms.extras.change_configcontext %} @@ -36,8 +30,11 @@ OBJECTCHANGE_REQUEST_ID = """ class TagTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) color = ColorColumn() - actions = ButtonsColumn(Tag, pk_field='slug') + actions = ButtonsColumn(Tag) class Meta(BaseTable.Meta): model = Tag @@ -45,18 +42,18 @@ class TagTable(BaseTable): class TaggedItemTable(BaseTable): - content_object = tables.TemplateColumn( - template_code=TAGGED_ITEM, + content_type = ContentTypeColumn( + verbose_name='Type' + ) + content_object = tables.Column( + linkify=True, orderable=False, verbose_name='Object' ) - content_type = tables.Column( - verbose_name='Type' - ) class Meta(BaseTable.Meta): model = TaggedItem - fields = ('content_object', 'content_type') + fields = ('content_type', 'content_object') class ConfigContextTable(BaseTable): @@ -81,7 +78,7 @@ class ObjectChangeTable(BaseTable): format=settings.SHORT_DATETIME_FORMAT ) action = ChoiceFieldColumn() - changed_object_type = tables.Column( + changed_object_type = ContentTypeColumn( verbose_name='Type' ) object_repr = tables.TemplateColumn( @@ -96,3 +93,45 @@ class ObjectChangeTable(BaseTable): class Meta(BaseTable.Meta): model = ObjectChange fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') + + +class ObjectJournalTable(BaseTable): + """ + Used for displaying a set of JournalEntries within the context of a single object. + """ + created = tables.DateTimeColumn( + format=settings.SHORT_DATETIME_FORMAT + ) + kind = ChoiceFieldColumn() + comments = tables.TemplateColumn( + template_code='{% load helpers %}{{ value|render_markdown }}' + ) + actions = ButtonsColumn( + model=JournalEntry, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ('created', 'created_by', 'kind', 'comments', 'actions') + + +class JournalEntryTable(ObjectJournalTable): + pk = ToggleColumn() + assigned_object_type = ContentTypeColumn( + verbose_name='Object type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object' + ) + comments = tables.TemplateColumn( + template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ( + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' + ) diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 30375c562..39017adfd 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -52,9 +52,9 @@ def custom_links(context, obj): # Add non-grouped links else: try: - text_rendered = render_jinja2(cl.text, link_context) + text_rendered = render_jinja2(cl.link_text, link_context) if text_rendered: - link_rendered = render_jinja2(cl.url, link_context) + link_rendered = render_jinja2(cl.link_url, link_context) link_target = ' target="_blank"' if cl.new_window else '' template_code += LINK_BUTTON.format( link_rendered, link_target, cl.button_class, text_rendered @@ -70,10 +70,10 @@ def custom_links(context, obj): for cl in links: try: - text_rendered = render_jinja2(cl.text, link_context) + text_rendered = render_jinja2(cl.link_text, link_context) if text_rendered: link_target = ' target="_blank"' if cl.new_window else '' - link_rendered = render_jinja2(cl.url, link_context) + link_rendered = render_jinja2(cl.link_url, link_context) links_rendered.append( GROUP_LINK.format(link_rendered, link_target, text_rendered) ) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index bcc95467a..da0e6dbb2 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,6 +1,7 @@ import datetime from unittest import skipIf +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -9,9 +10,9 @@ from django_rq.queues import get_connection from rest_framework import status from rq import Worker -from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, RackGroup, RackRole, Site +from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site from extras.api.views import ReportViewSet, ScriptViewSet -from extras.models import ConfigContext, CustomField, ExportTemplate, ImageAttachment, Tag +from extras.models import * from extras.reports import Report from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases @@ -30,9 +31,63 @@ class AppTest(APITestCase): self.assertEqual(response.status_code, 200) +class WebhookTest(APIViewTestCases.APIViewTestCase): + model = Webhook + brief_fields = ['display', 'id', 'name', 'url'] + create_data = [ + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Webhook 4', + 'type_create': True, + 'payload_url': 'http://example.com/?4', + }, + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Webhook 5', + 'type_update': True, + 'payload_url': 'http://example.com/?5', + }, + { + 'content_types': ['dcim.device', 'dcim.devicetype'], + 'name': 'Webhook 6', + 'type_delete': True, + 'payload_url': 'http://example.com/?6', + }, + ] + bulk_update_data = { + 'ssl_verification': False, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + rack_ct = ContentType.objects.get_for_model(Rack) + + webhooks = ( + Webhook( + name='Webhook 1', + type_create=True, + payload_url='http://example.com/?1', + ), + Webhook( + name='Webhook 2', + type_update=True, + payload_url='http://example.com/?1', + ), + Webhook( + name='Webhook 3', + type_delete=True, + payload_url='http://example.com/?1', + ), + ) + Webhook.objects.bulk_create(webhooks) + for webhook in webhooks: + webhook.content_types.add(site_ct, rack_ct) + + class CustomFieldTest(APIViewTestCases.APIViewTestCase): model = CustomField - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'content_types': ['dcim.site'], @@ -77,9 +132,63 @@ class CustomFieldTest(APIViewTestCases.APIViewTestCase): cf.content_types.add(site_ct) +class CustomLinkTest(APIViewTestCases.APIViewTestCase): + model = CustomLink + brief_fields = ['display', 'id', 'name', 'url'] + create_data = [ + { + 'content_type': 'dcim.site', + 'name': 'Custom Link 4', + 'link_text': 'Link 4', + 'link_url': 'http://example.com/?4', + }, + { + 'content_type': 'dcim.site', + 'name': 'Custom Link 5', + 'link_text': 'Link 5', + 'link_url': 'http://example.com/?5', + }, + { + 'content_type': 'dcim.site', + 'name': 'Custom Link 6', + 'link_text': 'Link 6', + 'link_url': 'http://example.com/?6', + }, + ] + bulk_update_data = { + 'new_window': True, + } + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + custom_links = ( + CustomLink( + content_type=site_ct, + name='Custom Link 1', + link_text='Link 1', + link_url='http://example.com/?1', + ), + CustomLink( + content_type=site_ct, + name='Custom Link 2', + link_text='Link 2', + link_url='http://example.com/?2', + ), + CustomLink( + content_type=site_ct, + name='Custom Link 3', + link_text='Link 3', + link_url='http://example.com/?3', + ), + ) + CustomLink.objects.bulk_create(custom_links) + + class ExportTemplateTest(APIViewTestCases.APIViewTestCase): model = ExportTemplate - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'content_type': 'dcim.device', @@ -127,7 +236,7 @@ class ExportTemplateTest(APIViewTestCases.APIViewTestCase): class TagTest(APIViewTestCases.APIViewTestCase): model = Tag - brief_fields = ['color', 'id', 'name', 'slug', 'url'] + brief_fields = ['color', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Tag 4', @@ -164,7 +273,7 @@ class ImageAttachmentTest( APIViewTestCases.DeleteObjectViewTestCase ): model = ImageAttachment - brief_fields = ['id', 'image', 'name', 'url'] + brief_fields = ['display', 'id', 'image', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -201,9 +310,59 @@ class ImageAttachmentTest( ImageAttachment.objects.bulk_create(image_attachments) +class JournalEntryTest(APIViewTestCases.APIViewTestCase): + model = JournalEntry + brief_fields = ['created', 'display', 'id', 'url'] + bulk_update_data = { + 'comments': 'Overwritten', + } + + @classmethod + def setUpTestData(cls): + user = User.objects.first() + site = Site.objects.create(name='Site 1', slug='site-1') + + journal_entries = ( + JournalEntry( + created_by=user, + assigned_object=site, + comments='Fourth entry', + ), + JournalEntry( + created_by=user, + assigned_object=site, + comments='Fifth entry', + ), + JournalEntry( + created_by=user, + assigned_object=site, + comments='Sixth entry', + ), + ) + JournalEntry.objects.bulk_create(journal_entries) + + cls.create_data = [ + { + 'assigned_object_type': 'dcim.site', + 'assigned_object_id': site.pk, + 'comments': 'First entry', + }, + { + 'assigned_object_type': 'dcim.site', + 'assigned_object_id': site.pk, + 'comments': 'Second entry', + }, + { + 'assigned_object_type': 'dcim.site', + 'assigned_object_id': site.pk, + 'comments': 'Third entry', + }, + ] + + class ConfigContextTest(APIViewTestCases.APIViewTestCase): model = ConfigContext - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'name': 'Config Context 4', @@ -382,13 +541,13 @@ class CreatedUpdatedFilterTest(APITestCase): super().setUp() self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') - self.rackgroup1 = RackGroup.objects.create(site=self.site1, name='Test Rack Group 1', slug='test-rack-group-1') + self.location1 = Location.objects.create(site=self.site1, name='Test Location 1', slug='test-location-1') self.rackrole1 = RackRole.objects.create(name='Test Rack Role 1', slug='test-rack-role-1', color='ff0000') self.rack1 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 1', u_height=42, + site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 1', u_height=42, ) self.rack2 = Rack.objects.create( - site=self.site1, group=self.rackgroup1, role=self.rackrole1, name='Test Rack 2', u_height=42, + site=self.site1, location=self.location1, role=self.rackrole1, name='Test Rack 2', u_height=42, ) # change the created and last_updated of one diff --git a/netbox/extras/tests/test_changelog.py b/netbox/extras/tests/test_changelog.py index dbdbb5343..5e44c83d1 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -27,13 +27,24 @@ class ChangeLogViewTest(ModelViewTestCase): cf.save() cf.content_types.set([ct]) + # Create a select custom field on the Site model + cf_select = CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='my_field_select', + required=False, + choices=['Bar', 'Foo'] + ) + cf_select.save() + cf_select.content_types.set([ct]) + def test_create_object(self): tags = self.create_tags('Tag 1', 'Tag 2') form_data = { - 'name': 'Test Site 1', - 'slug': 'test-site-1', + 'name': 'Site 1', + 'slug': 'site-1', 'status': SiteStatusChoices.STATUS_ACTIVE, 'cf_my_field': 'ABC', + 'cf_my_field_select': 'Bar', 'tags': [tag.pk for tag in tags], } @@ -45,7 +56,7 @@ class ChangeLogViewTest(ModelViewTestCase): response = self.client.post(**request) self.assertHttpStatus(response, 302) - site = Site.objects.get(name='Test Site 1') + site = Site.objects.get(name='Site 1') # First OC is the creation; second is the tags update oc_list = ObjectChange.objects.filter( changed_object_type=ContentType.objects.get_for_model(Site), @@ -53,21 +64,24 @@ class ChangeLogViewTest(ModelViewTestCase): ).order_by('pk') self.assertEqual(oc_list[0].changed_object, site) self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].object_data['custom_fields']['my_field'], form_data['cf_my_field']) + self.assertEqual(oc_list[0].prechange_data, None) + self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) + self.assertEqual(oc_list[0].postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): - site = Site(name='Test Site 1', slug='test-site-1') + site = Site(name='Site 1', slug='site-1') site.save() tags = self.create_tags('Tag 1', 'Tag 2', 'Tag 3') site.tags.set('Tag 1', 'Tag 2') form_data = { - 'name': 'Test Site X', - 'slug': 'test-site-x', + 'name': 'Site X', + 'slug': 'site-x', 'status': SiteStatusChoices.STATUS_PLANNED, 'cf_my_field': 'DEF', + 'cf_my_field_select': 'Foo', 'tags': [tags[2].pk], } @@ -87,15 +101,19 @@ class ChangeLogViewTest(ModelViewTestCase): ).first() self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc.object_data['custom_fields']['my_field'], form_data['cf_my_field']) - self.assertEqual(oc.object_data['tags'], ['Tag 3']) + self.assertEqual(oc.prechange_data['name'], 'Site 1') + self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc.postchange_data['custom_fields']['my_field'], form_data['cf_my_field']) + self.assertEqual(oc.postchange_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) def test_delete_object(self): site = Site( - name='Test Site 1', - slug='test-site-1', + name='Site 1', + slug='site-1', custom_field_data={ - 'my_field': 'ABC' + 'my_field': 'ABC', + 'my_field_select': 'Bar' } ) site.save() @@ -114,14 +132,83 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.object_data['custom_fields']['my_field'], 'ABC') - self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc.postchange_data, None) + + def test_bulk_update_objects(self): + sites = ( + Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE), + Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE), + Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE), + ) + Site.objects.bulk_create(sites) + + form_data = { + 'pk': [site.pk for site in sites], + '_apply': True, + 'status': SiteStatusChoices.STATUS_PLANNED, + 'description': 'New description', + } + + request = { + 'path': self._get_url('bulk_edit'), + 'data': post_data(form_data), + } + self.add_permissions('dcim.view_site', 'dcim.change_site') + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + objectchange = ObjectChange.objects.get( + changed_object_type=ContentType.objects.get_for_model(Site), + changed_object_id=sites[0].pk + ) + self.assertEqual(objectchange.changed_object, sites[0]) + self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(objectchange.prechange_data['status'], SiteStatusChoices.STATUS_ACTIVE) + self.assertEqual(objectchange.prechange_data['description'], '') + self.assertEqual(objectchange.postchange_data['status'], form_data['status']) + self.assertEqual(objectchange.postchange_data['description'], form_data['description']) + + def test_bulk_delete_objects(self): + sites = ( + Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE), + Site(name='Site 2', slug='site-2', status=SiteStatusChoices.STATUS_ACTIVE), + Site(name='Site 3', slug='site-3', status=SiteStatusChoices.STATUS_ACTIVE), + ) + Site.objects.bulk_create(sites) + + form_data = { + 'pk': [site.pk for site in sites], + 'confirm': True, + '_confirm': True, + } + + request = { + 'path': self._get_url('bulk_delete'), + 'data': post_data(form_data), + } + self.add_permissions('dcim.delete_site') + response = self.client.post(**request) + self.assertHttpStatus(response, 302) + + objectchange = ObjectChange.objects.get( + changed_object_type=ContentType.objects.get_for_model(Site), + changed_object_id=sites[0].pk + ) + self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site)) + self.assertEqual(objectchange.changed_object_id, sites[0].pk) + self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(objectchange.prechange_data['name'], sites[0].name) + self.assertEqual(objectchange.prechange_data['slug'], sites[0].slug) + self.assertEqual(objectchange.postchange_data, None) class ChangeLogAPITest(APITestCase): - def setUp(self): - super().setUp() + @classmethod + def setUpTestData(cls): # Create a custom field on the Site model ct = ContentType.objects.get_for_model(Site) @@ -133,6 +220,16 @@ class ChangeLogAPITest(APITestCase): cf.save() cf.content_types.set([ct]) + # Create a select custom field on the Site model + cf_select = CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='my_field_select', + required=False, + choices=['Bar', 'Foo'] + ) + cf_select.save() + cf_select.content_types.set([ct]) + # Create some tags tags = ( Tag(name='Tag 1', slug='tag-1'), @@ -143,10 +240,11 @@ class ChangeLogAPITest(APITestCase): def test_create_object(self): data = { - 'name': 'Test Site 1', - 'slug': 'test-site-1', + 'name': 'Site 1', + 'slug': 'site-1', 'custom_fields': { - 'my_field': 'ABC' + 'my_field': 'ABC', + 'my_field_select': 'Bar', }, 'tags': [ {'name': 'Tag 1'}, @@ -168,19 +266,21 @@ class ChangeLogAPITest(APITestCase): ).order_by('pk') self.assertEqual(oc_list[0].changed_object, site) self.assertEqual(oc_list[0].action, ObjectChangeActionChoices.ACTION_CREATE) - self.assertEqual(oc_list[0].object_data['custom_fields'], data['custom_fields']) + self.assertEqual(oc_list[0].prechange_data, None) + self.assertEqual(oc_list[0].postchange_data['custom_fields'], data['custom_fields']) self.assertEqual(oc_list[1].action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc_list[1].object_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc_list[1].postchange_data['tags'], ['Tag 1', 'Tag 2']) def test_update_object(self): - site = Site(name='Test Site 1', slug='test-site-1') + site = Site(name='Site 1', slug='site-1') site.save() data = { - 'name': 'Test Site X', - 'slug': 'test-site-x', + 'name': 'Site X', + 'slug': 'site-x', 'custom_fields': { - 'my_field': 'DEF' + 'my_field': 'DEF', + 'my_field_select': 'Foo', }, 'tags': [ {'name': 'Tag 3'} @@ -201,15 +301,16 @@ class ChangeLogAPITest(APITestCase): ).first() self.assertEqual(oc.changed_object, site) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_UPDATE) - self.assertEqual(oc.object_data['custom_fields'], data['custom_fields']) - self.assertEqual(oc.object_data['tags'], ['Tag 3']) + self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields']) + self.assertEqual(oc.postchange_data['tags'], ['Tag 3']) def test_delete_object(self): site = Site( - name='Test Site 1', - slug='test-site-1', + name='Site 1', + slug='site-1', custom_field_data={ - 'my_field': 'ABC' + 'my_field': 'ABC', + 'my_field_select': 'Bar' } ) site.save() @@ -226,5 +327,123 @@ class ChangeLogAPITest(APITestCase): self.assertEqual(oc.changed_object, None) self.assertEqual(oc.object_repr, site.name) self.assertEqual(oc.action, ObjectChangeActionChoices.ACTION_DELETE) - self.assertEqual(oc.object_data['custom_fields'], {'my_field': 'ABC'}) - self.assertEqual(oc.object_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc.prechange_data['custom_fields']['my_field'], 'ABC') + self.assertEqual(oc.prechange_data['custom_fields']['my_field_select'], 'Bar') + self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2']) + self.assertEqual(oc.postchange_data, None) + + def test_bulk_create_objects(self): + data = ( + { + 'name': 'Site 1', + 'slug': 'site-1', + }, + { + 'name': 'Site 2', + 'slug': 'site-2', + }, + { + 'name': 'Site 3', + 'slug': 'site-3', + }, + ) + self.assertEqual(ObjectChange.objects.count(), 0) + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.add_site') + + response = self.client.post(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_201_CREATED) + self.assertEqual(ObjectChange.objects.count(), 3) + + site1 = Site.objects.get(pk=response.data[0]['id']) + objectchange = ObjectChange.objects.get( + changed_object_type=ContentType.objects.get_for_model(Site), + changed_object_id=site1.pk + ) + self.assertEqual(objectchange.changed_object, site1) + self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(objectchange.prechange_data, None) + self.assertEqual(objectchange.postchange_data['name'], data[0]['name']) + self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug']) + + def test_bulk_edit_objects(self): + 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) + + data = ( + { + 'id': sites[0].pk, + 'name': 'Site A', + 'slug': 'site-A', + }, + { + 'id': sites[1].pk, + 'name': 'Site B', + 'slug': 'site-b', + }, + { + 'id': sites[2].pk, + 'name': 'Site C', + 'slug': 'site-c', + }, + ) + self.assertEqual(ObjectChange.objects.count(), 0) + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.change_site') + + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(ObjectChange.objects.count(), 3) + + objectchange = ObjectChange.objects.get( + changed_object_type=ContentType.objects.get_for_model(Site), + changed_object_id=sites[0].pk + ) + self.assertEqual(objectchange.changed_object, sites[0]) + self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(objectchange.prechange_data['name'], 'Site 1') + self.assertEqual(objectchange.prechange_data['slug'], 'site-1') + self.assertEqual(objectchange.postchange_data['name'], data[0]['name']) + self.assertEqual(objectchange.postchange_data['slug'], data[0]['slug']) + + def test_bulk_delete_objects(self): + 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) + + data = ( + { + 'id': sites[0].pk, + }, + { + 'id': sites[1].pk, + }, + { + 'id': sites[2].pk, + }, + ) + self.assertEqual(ObjectChange.objects.count(), 0) + url = reverse('dcim-api:site-list') + self.add_permissions('dcim.delete_site') + + response = self.client.delete(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) + self.assertEqual(ObjectChange.objects.count(), 3) + + objectchange = ObjectChange.objects.get( + changed_object_type=ContentType.objects.get_for_model(Site), + changed_object_id=sites[0].pk + ) + self.assertEqual(objectchange.changed_object_type, ContentType.objects.get_for_model(Site)) + self.assertEqual(objectchange.changed_object_id, sites[0].pk) + self.assertEqual(objectchange.action, ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(objectchange.prechange_data['name'], 'Site 1') + self.assertEqual(objectchange.prechange_data['slug'], 'site-1') + self.assertEqual(objectchange.postchange_data, None) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fe56027dc..4f7a67676 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.urls import reverse from rest_framework import status +from dcim.filters import SiteFilterSet from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * @@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase): site.cf['baz'] = 'def' site.clean() + + +class CustomFieldFilterTest(TestCase): + queryset = Site.objects.all() + filterset = SiteFilterSet + + @classmethod + def setUpTestData(cls): + obj_type = ContentType.objects.get_for_model(Site) + + # Integer filtering + cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) + cf.save() + cf.content_types.set([obj_type]) + + # Boolean filtering + cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) + cf.save() + cf.content_types.set([obj_type]) + + # Exact text filtering + cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf.save() + cf.content_types.set([obj_type]) + + # Loose text filtering + cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf.save() + cf.content_types.set([obj_type]) + + # Date filtering + cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) + cf.save() + cf.content_types.set([obj_type]) + + # Exact URL filtering + cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf.save() + cf.content_types.set([obj_type]) + + # Loose URL filtering + cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf.save() + cf.content_types.set([obj_type]) + + # Selection filtering + cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz']) + cf.save() + cf.content_types.set([obj_type]) + + Site.objects.bulk_create([ + Site(name='Site 1', slug='site-1', custom_field_data={ + 'cf1': 100, + 'cf2': True, + 'cf3': 'foo', + 'cf4': 'foo', + 'cf5': '2016-06-26', + 'cf6': 'http://foo.example.com/', + 'cf7': 'http://foo.example.com/', + 'cf8': 'Foo', + }), + Site(name='Site 2', slug='site-2', custom_field_data={ + 'cf1': 200, + 'cf2': False, + 'cf3': 'foobar', + 'cf4': 'foobar', + 'cf5': '2016-06-27', + 'cf6': 'http://bar.example.com/', + 'cf7': 'http://bar.example.com/', + 'cf8': 'Bar', + }), + Site(name='Site 3', slug='site-3', custom_field_data={ + }), + ]) + + def test_filter_integer(self): + self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1) + + def test_filter_boolean(self): + self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) + + def test_filter_text(self): + self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2) + + def test_filter_date(self): + self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1) + + def test_filter_url(self): + self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2) + + def test_filter_select(self): + self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 7bcd83e81..bb78c4daf 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -4,15 +4,150 @@ from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.test import TestCase -from dcim.models import DeviceRole, Platform, Rack, Region, Site -from extras.choices import ObjectChangeActionChoices +from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup +from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices from extras.filters import * -from extras.models import ConfigContext, ExportTemplate, ImageAttachment, ObjectChange, Tag +from extras.models import * from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.models import Cluster, ClusterGroup, ClusterType +class WebhookTestCase(TestCase): + queryset = Webhook.objects.all() + filterset = WebhookFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + webhooks = ( + Webhook( + name='Webhook 1', + type_create=True, + payload_url='http://example.com/?1', + enabled=True, + http_method='GET', + ssl_verification=True, + ), + Webhook( + name='Webhook 2', + type_update=True, + payload_url='http://example.com/?2', + enabled=True, + http_method='POST', + ssl_verification=True, + ), + Webhook( + name='Webhook 3', + type_delete=True, + payload_url='http://example.com/?3', + enabled=False, + http_method='PATCH', + ssl_verification=False, + ), + ) + Webhook.objects.bulk_create(webhooks) + webhooks[0].content_types.add(content_types[0]) + webhooks[1].content_types.add(content_types[1]) + webhooks[2].content_types.add(content_types[2]) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Webhook 1', 'Webhook 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_types(self): + params = {'content_types': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_type_create(self): + params = {'type_create': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_type_update(self): + params = {'type_update': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_type_delete(self): + params = {'type_delete': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_http_method(self): + params = {'http_method': ['GET', 'POST']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_ssl_verification(self): + params = {'ssl_verification': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class CustomLinkTestCase(TestCase): + queryset = CustomLink.objects.all() + filterset = CustomLinkFilterSet + + @classmethod + def setUpTestData(cls): + content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) + + custom_links = ( + CustomLink( + name='Custom Link 1', + content_type=content_types[0], + weight=100, + new_window=False, + link_text='Link 1', + link_url='http://example.com/?1' + ), + CustomLink( + name='Custom Link 2', + content_type=content_types[1], + weight=200, + new_window=False, + link_text='Link 1', + link_url='http://example.com/?2' + ), + CustomLink( + name='Custom Link 3', + content_type=content_types[2], + weight=300, + new_window=True, + link_text='Link 1', + link_url='http://example.com/?3' + ), + ) + CustomLink.objects.bulk_create(custom_links) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_name(self): + params = {'name': ['Custom Link 1', 'Custom Link 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_content_type(self): + params = {'content_type': ContentType.objects.get(model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_weight(self): + params = {'weight': [100, 200]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_new_window(self): + params = {'new_window': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'new_window': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ExportTemplateTestCase(TestCase): queryset = ExportTemplate.objects.all() filterset = ExportTemplateFilterSet @@ -120,6 +255,100 @@ class ImageAttachmentTestCase(TestCase): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) +class JournalEntryTestCase(TestCase): + queryset = JournalEntry.objects.all() + filterset = JournalEntryFilterSet + + @classmethod + def setUpTestData(cls): + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) + + racks = ( + Rack(name='Rack 1', site=sites[0]), + Rack(name='Rack 2', site=sites[1]), + ) + Rack.objects.bulk_create(racks) + + users = ( + User(username='Alice'), + User(username='Bob'), + User(username='Charlie'), + ) + User.objects.bulk_create(users) + + journal_entries = ( + JournalEntry( + assigned_object=sites[0], + created_by=users[0], + kind=JournalEntryKindChoices.KIND_INFO, + comments='New journal entry' + ), + JournalEntry( + assigned_object=sites[0], + created_by=users[1], + kind=JournalEntryKindChoices.KIND_SUCCESS, + comments='New journal entry' + ), + JournalEntry( + assigned_object=sites[1], + created_by=users[2], + kind=JournalEntryKindChoices.KIND_WARNING, + comments='New journal entry' + ), + JournalEntry( + assigned_object=racks[0], + created_by=users[0], + kind=JournalEntryKindChoices.KIND_INFO, + comments='New journal entry' + ), + JournalEntry( + assigned_object=racks[0], + created_by=users[1], + kind=JournalEntryKindChoices.KIND_SUCCESS, + comments='New journal entry' + ), + JournalEntry( + assigned_object=racks[1], + created_by=users[2], + kind=JournalEntryKindChoices.KIND_WARNING, + comments='New journal entry' + ), + ) + JournalEntry.objects.bulk_create(journal_entries) + + def test_id(self): + params = {'id': self.queryset.values_list('pk', flat=True)[:2]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_created_by(self): + users = User.objects.filter(username__in=['Alice', 'Bob']) + params = {'created_by': [users[0].username, users[1].username]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'created_by_id': [users[0].pk, users[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_assigned_object_type(self): + params = {'assigned_object_type': 'dcim.site'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_assigned_object(self): + params = { + 'assigned_object_type': 'dcim.site', + 'assigned_object_id': [Site.objects.first().pk], + } + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_kind(self): + params = {'kind': [JournalEntryKindChoices.KIND_INFO, JournalEntryKindChoices.KIND_SUCCESS]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + class ConfigContextTestCase(TestCase): queryset = ConfigContext.objects.all() filterset = ConfigContextFilterSet @@ -132,10 +361,17 @@ class ConfigContextTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( Site(name='Test Site 1', slug='test-site-1'), Site(name='Test Site 2', slug='test-site-2'), @@ -143,6 +379,14 @@ class ConfigContextTestCase(TestCase): ) Site.objects.bulk_create(sites) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-3'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-4'), + ) + DeviceType.objects.bulk_create(device_types) + device_roles = ( DeviceRole(name='Device Role 1', slug='device-role-1'), DeviceRole(name='Device Role 2', slug='device-role-2'), @@ -195,7 +439,9 @@ class ConfigContextTestCase(TestCase): data='{"foo": 123}' ) c.regions.set([regions[i]]) + c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) + c.device_types.set([device_types[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) c.cluster_groups.set([cluster_groups[i]]) @@ -224,6 +470,13 @@ class ConfigContextTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -231,6 +484,11 @@ class ConfigContextTestCase(TestCase): params = {'site': [sites[0].slug, sites[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]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_role(self): device_roles = DeviceRole.objects.all()[:2] params = {'role_id': [device_roles[0].pk, device_roles[1].pk]} @@ -327,7 +585,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_CREATE, changed_object=site, object_repr=str(site), - object_data={'name': site.name, 'slug': site.slug} + postchange_data={'name': site.name, 'slug': site.slug} ), ObjectChange( user=users[0], @@ -336,7 +594,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_UPDATE, changed_object=site, object_repr=str(site), - object_data={'name': site.name, 'slug': site.slug} + postchange_data={'name': site.name, 'slug': site.slug} ), ObjectChange( user=users[1], @@ -345,7 +603,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_DELETE, changed_object=site, object_repr=str(site), - object_data={'name': site.name, 'slug': site.slug} + postchange_data={'name': site.name, 'slug': site.slug} ), ObjectChange( user=users[1], @@ -354,7 +612,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_CREATE, changed_object=ipaddress, object_repr=str(ipaddress), - object_data={'address': ipaddress.address, 'status': ipaddress.status} + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} ), ObjectChange( user=users[2], @@ -363,7 +621,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_UPDATE, changed_object=ipaddress, object_repr=str(ipaddress), - object_data={'address': ipaddress.address, 'status': ipaddress.status} + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} ), ObjectChange( user=users[2], @@ -372,7 +630,7 @@ class ObjectChangeTestCase(TestCase): action=ObjectChangeActionChoices.ACTION_DELETE, changed_object=ipaddress, object_repr=str(ipaddress), - object_data={'address': ipaddress.address, 'status': ipaddress.status} + postchange_data={'address': ipaddress.address, 'status': ipaddress.status} ), ) ObjectChange.objects.bulk_create(object_changes) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 45a246f38..286fa7613 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -3,12 +3,11 @@ import uuid from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType -from django.test import override_settings from django.urls import reverse from dcim.models import Site from extras.choices import ObjectChangeActionChoices -from extras.models import ConfigContext, CustomLink, ObjectChange, Tag +from extras.models import ConfigContext, CustomLink, JournalEntry, ObjectChange, Tag from utilities.testing import ViewTestCases, TestCase @@ -128,6 +127,43 @@ class ObjectChangeTestCase(TestCase): self.assertHttpStatus(response, 200) +class JournalEntryTestCase( + # ViewTestCases.GetObjectViewTestCase, + ViewTestCases.CreateObjectViewTestCase, + ViewTestCases.EditObjectViewTestCase, + ViewTestCases.DeleteObjectViewTestCase, + ViewTestCases.ListObjectsViewTestCase, + ViewTestCases.BulkEditObjectsViewTestCase, + ViewTestCases.BulkDeleteObjectsViewTestCase +): + model = JournalEntry + + @classmethod + def setUpTestData(cls): + site_ct = ContentType.objects.get_for_model(Site) + + site = Site.objects.create(name='Site 1', slug='site-1') + user = User.objects.create(username='User 1') + + JournalEntry.objects.bulk_create(( + JournalEntry(assigned_object=site, created_by=user, comments='First entry'), + JournalEntry(assigned_object=site, created_by=user, comments='Second entry'), + JournalEntry(assigned_object=site, created_by=user, comments='Third entry'), + )) + + cls.form_data = { + 'assigned_object_type': site_ct.pk, + 'assigned_object_id': site.pk, + 'kind': 'info', + 'comments': 'A new entry', + } + + cls.bulk_edit_data = { + 'kind': 'success', + 'comments': 'Overwritten', + } + + class CustomLinkTest(TestCase): user_permissions = ['dcim.view_site'] @@ -135,8 +171,8 @@ class CustomLinkTest(TestCase): customlink = CustomLink( content_type=ContentType.objects.get_for_model(Site), name='Test', - text='FOO {{ obj.name }} BAR', - url='http://example.com/?site={{ obj.slug }}', + link_text='FOO {{ obj.name }} BAR', + link_url='http://example.com/?site={{ obj.slug }}', new_window=False ) customlink.save() diff --git a/netbox/extras/tests/test_webhooks.py b/netbox/extras/tests/test_webhooks.py index d8f30e26e..eff9cdb97 100644 --- a/netbox/extras/tests/test_webhooks.py +++ b/netbox/extras/tests/test_webhooks.py @@ -56,10 +56,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object creation webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_create=True)) - self.assertEqual(job.args[1]['id'], response.data['id']) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_CREATE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_create=True)) + self.assertEqual(job.kwargs['data']['id'], response.data['id']) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE) def test_enqueue_webhook_update(self): # Update an object via the REST API @@ -75,10 +75,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object update webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_update=True)) - self.assertEqual(job.args[1]['id'], site.pk) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_UPDATE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_update=True)) + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE) def test_enqueue_webhook_delete(self): # Delete an object via the REST API @@ -91,10 +91,10 @@ class WebhookTest(APITestCase): # Verify that a job was queued for the object update webhook self.assertEqual(self.queue.count, 1) job = self.queue.jobs[0] - self.assertEqual(job.args[0], Webhook.objects.get(type_delete=True)) - self.assertEqual(job.args[1]['id'], site.pk) - self.assertEqual(job.args[2], 'site') - self.assertEqual(job.args[3], ObjectChangeActionChoices.ACTION_DELETE) + self.assertEqual(job.kwargs['webhook'], Webhook.objects.get(type_delete=True)) + self.assertEqual(job.kwargs['data']['id'], site.pk) + self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE) def test_webhooks_worker(self): @@ -116,7 +116,7 @@ class WebhookTest(APITestCase): # Validate the outgoing request body body = json.loads(request.body) self.assertEqual(body['event'], 'created') - self.assertEqual(body['timestamp'], job.args[4]) + self.assertEqual(body['timestamp'], job.kwargs['timestamp']) self.assertEqual(body['model'], 'site') self.assertEqual(body['username'], 'testuser') self.assertEqual(body['request_id'], str(request_id)) @@ -138,4 +138,4 @@ class WebhookTest(APITestCase): # Patch the Session object with our dummy_send() method, then process the webhook for sending with patch.object(Session, 'send', dummy_send) as mock_send: - process_webhook(*job.args) + process_webhook(**job.kwargs) diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index 36077dc7a..f38a2ecd3 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -13,9 +13,10 @@ urlpatterns = [ path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'), path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'), path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'), - path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), - path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), - path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), + path('tags//', views.TagView.as_view(), name='tag'), + path('tags//edit/', views.TagEditView.as_view(), name='tag_edit'), + path('tags//delete/', views.TagDeleteView.as_view(), name='tag_delete'), + path('tags//changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}), # Config contexts path('config-contexts/', views.ConfigContextListView.as_view(), name='configcontext_list'), @@ -31,6 +32,14 @@ urlpatterns = [ path('image-attachments//edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'), path('image-attachments//delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'), + # Journal entries + path('journal-entries/', views.JournalEntryListView.as_view(), name='journalentry_list'), + path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), + path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), + path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), + path('journal-entries//edit/', views.JournalEntryEditView.as_view(), name='journalentry_edit'), + path('journal-entries//delete/', views.JournalEntryDeleteView.as_view(), name='journalentry_delete'), + # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', views.ObjectChangeView.as_view(), name='objectchange'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 99295de1a..cd564b8d1 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,19 +4,19 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.http import Http404, HttpResponseForbidden from django.shortcuts import get_object_or_404, redirect, render +from django.urls import reverse from django.views.generic import View from django_rq.queues import get_connection -from django_tables2 import RequestConfig from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.utils import copy_safe_request, get_subquery, shallow_compare_dict +from utilities.tables import paginate_table +from utilities.utils import copy_safe_request, count_related, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filters, forms, tables from .choices import JobResultStatusChoices -from .models import ConfigContext, ImageAttachment, ObjectChange, JobResult, Tag, TaggedItem +from .models import ConfigContext, ImageAttachment, JournalEntry, ObjectChange, JobResult, Tag, TaggedItem from .reports import get_report, get_reports, run_report from .scripts import get_scripts, run_script @@ -27,17 +27,33 @@ from .scripts import get_scripts, run_script class TagListView(generic.ObjectListView): queryset = Tag.objects.annotate( - items=get_subquery(TaggedItem, 'tag') + items=count_related(TaggedItem, 'tag') ) filterset = filters.TagFilterSet filterset_form = forms.TagFilterForm table = tables.TagTable +class TagView(generic.ObjectView): + queryset = Tag.objects.all() + + def get_extra_context(self, request, instance): + tagged_items = TaggedItem.objects.filter(tag=instance) + taggeditem_table = tables.TaggedItemTable( + data=tagged_items, + orderable=False + ) + paginate_table(taggeditem_table, request) + + return { + 'taggeditem_table': taggeditem_table, + 'tagged_item_count': tagged_items.count(), + } + + class TagEditView(generic.ObjectEditView): queryset = Tag.objects.all() model_form = forms.TagForm - template_name = 'extras/tag_edit.html' class TagDeleteView(generic.ObjectDeleteView): @@ -52,7 +68,7 @@ class TagBulkImportView(generic.BulkImportView): class TagBulkEditView(generic.BulkEditView): queryset = Tag.objects.annotate( - items=get_subquery(TaggedItem, 'tag') + items=count_related(TaggedItem, 'tag') ) table = tables.TagTable form = forms.TagBulkEditForm @@ -60,7 +76,7 @@ class TagBulkEditView(generic.BulkEditView): class TagBulkDeleteView(generic.BulkDeleteView): queryset = Tag.objects.annotate( - items=get_subquery(TaggedItem, 'tag') + items=count_related(TaggedItem, 'tag') ) table = tables.TagTable @@ -179,16 +195,18 @@ class ObjectChangeView(generic.ObjectView): next_change = objectchanges.filter(time__gt=instance.time).order_by('time').first() prev_change = objectchanges.filter(time__lt=instance.time).order_by('-time').first() - if prev_change: + if instance.prechange_data and instance.postchange_data: diff_added = shallow_compare_dict( - prev_change.object_data, - instance.object_data, + instance.prechange_data or dict(), + instance.postchange_data or dict(), exclude=['last_updated'], ) - diff_removed = {x: prev_change.object_data.get(x) for x in diff_added} + diff_removed = { + x: instance.prechange_data.get(x) for x in diff_added + } if instance.prechange_data else {} else: - # No previous change; this is the initial change that added the object - diff_added = diff_removed = instance.object_data + diff_added = None + diff_removed = None return { 'diff_added': diff_added, @@ -228,23 +246,12 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(objectchanges_table) + paginate_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. if self.base_template is None: self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" - # TODO: This can be removed once an object view has been established for every model. - try: - template.loader.get_template(self.base_template) - except template.TemplateDoesNotExist: - self.base_template = 'base.html' return render(request, 'extras/object_changelog.html', { 'object': obj, @@ -280,6 +287,106 @@ class ImageAttachmentDeleteView(generic.ObjectDeleteView): return imageattachment.parent.get_absolute_url() +# +# Journal entries +# + +class JournalEntryListView(generic.ObjectListView): + queryset = JournalEntry.objects.all() + filterset = filters.JournalEntryFilterSet + filterset_form = forms.JournalEntryFilterForm + table = tables.JournalEntryTable + action_buttons = ('export',) + + +class JournalEntryEditView(generic.ObjectEditView): + queryset = JournalEntry.objects.all() + model_form = forms.JournalEntryForm + + def alter_obj(self, obj, request, args, kwargs): + if not obj.pk: + obj.created_by = request.user + return obj + + def get_return_url(self, request, instance): + if not instance.assigned_object: + return reverse('extras:journalentry_list') + obj = instance.assigned_object + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal' + return reverse(viewname, kwargs={'pk': obj.pk}) + + +class JournalEntryDeleteView(generic.ObjectDeleteView): + queryset = JournalEntry.objects.all() + + def get_return_url(self, request, instance): + obj = instance.assigned_object + viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_journal' + return reverse(viewname, kwargs={'pk': obj.pk}) + + +class JournalEntryBulkEditView(generic.BulkEditView): + queryset = JournalEntry.objects.prefetch_related('created_by') + filterset = filters.JournalEntryFilterSet + table = tables.JournalEntryTable + form = forms.JournalEntryBulkEditForm + + +class JournalEntryBulkDeleteView(generic.BulkDeleteView): + queryset = JournalEntry.objects.prefetch_related('created_by') + filterset = filters.JournalEntryFilterSet + table = tables.JournalEntryTable + + +class ObjectJournalView(View): + """ + Show all journal entries for an object. + + base_template: The name of the template to extend. If not provided, "/.html" will be used. + """ + base_template = None + + def get(self, request, model, **kwargs): + + # Handle QuerySet restriction of parent object if needed + if hasattr(model.objects, 'restrict'): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs) + else: + obj = get_object_or_404(model, **kwargs) + + # Gather all changes for this object (and its related objects) + content_type = ContentType.objects.get_for_model(model) + journalentries = JournalEntry.objects.restrict(request.user, 'view').prefetch_related('created_by').filter( + assigned_object_type=content_type, + assigned_object_id=obj.pk + ) + journalentry_table = tables.ObjectJournalTable(journalentries) + paginate_table(journalentry_table, request) + + if request.user.has_perm('extras.add_journalentry'): + form = forms.JournalEntryForm( + initial={ + 'assigned_object_type': ContentType.objects.get_for_model(obj), + 'assigned_object_id': obj.pk + } + ) + else: + form = None + + # Default to using "/.html" as the template, if it exists. Otherwise, + # fall back to using base.html. + if self.base_template is None: + self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" + + return render(request, 'extras/object_journal.html', { + 'object': obj, + 'form': form, + 'table': journalentry_table, + 'base_template': self.base_template, + 'active_tab': 'journal', + }) + + # # Reports # diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index f062e5752..bd645aca9 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -6,6 +6,7 @@ from django.utils import timezone from django_rq import get_queue from utilities.api import get_serializer_for_model +from utilities.utils import serialize_object from .choices import * from .models import Webhook from .registry import registry @@ -44,6 +45,7 @@ def enqueue_webhooks(instance, user, request_id, action): webhooks = Webhook.objects.filter(content_types=content_type, enabled=True, **{action_flag: True}) if webhooks.exists(): + # Get the Model's API serializer class and serialize the object serializer_class = get_serializer_for_model(instance.__class__) serializer_context = { @@ -51,16 +53,23 @@ def enqueue_webhooks(instance, user, request_id, action): } serializer = serializer_class(instance, context=serializer_context) + # Gather pre- and post-change snapshots + snapshots = { + 'prechange': getattr(instance, '_prechange_snapshot', None), + 'postchange': serialize_object(instance) if action != ObjectChangeActionChoices.ACTION_DELETE else None, + } + # Enqueue the webhooks webhook_queue = get_queue('default') for webhook in webhooks: webhook_queue.enqueue( "extras.webhooks_worker.process_webhook", - webhook, - serializer.data, - instance._meta.model_name, - action, - str(timezone.now()), - user.username, - request_id + webhook=webhook, + model_name=instance._meta.model_name, + event=action, + data=serializer.data, + snapshots=snapshots, + timestamp=str(timezone.now()), + username=user.username, + request_id=request_id ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index e47478f9f..ce63e14ce 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -12,7 +12,7 @@ logger = logging.getLogger('netbox.webhooks_worker') @job('default') -def process_webhook(webhook, data, model_name, event, timestamp, username, request_id): +def process_webhook(webhook, model_name, event, data, snapshots, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ @@ -22,7 +22,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'model': model_name, 'username': username, 'request_id': request_id, - 'data': data + 'data': data, + 'snapshots': snapshots, } # Build the headers for the HTTP request diff --git a/netbox/generate_secret_key.py b/netbox/generate_secret_key.py index 3c88aa710..c3de29cee 100755 --- a/netbox/generate_secret_key.py +++ b/netbox/generate_secret_key.py @@ -1,7 +1,6 @@ #!/usr/bin/env python # This script will generate a random 50-character string suitable for use as a SECRET_KEY. -import random +import secrets charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*(-_=+)' -secure_random = random.SystemRandom() -print(''.join(secure_random.sample(charset, 50))) +print(''.join(secrets.choice(charset) for _ in range(50))) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 660db2b22..a5eb63911 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -27,7 +27,7 @@ class NestedVRFSerializer(WritableNestedSerializer): class Meta: model = models.VRF - fields = ['id', 'url', 'name', 'rd', 'display_name', 'prefix_count'] + fields = ['id', 'url', 'display', 'name', 'rd', 'display_name', 'prefix_count'] # @@ -39,7 +39,7 @@ class NestedRouteTargetSerializer(WritableNestedSerializer): class Meta: model = models.RouteTarget - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] # @@ -52,7 +52,7 @@ class NestedRIRSerializer(WritableNestedSerializer): class Meta: model = models.RIR - fields = ['id', 'url', 'name', 'slug', 'aggregate_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'aggregate_count'] class NestedAggregateSerializer(WritableNestedSerializer): @@ -61,7 +61,7 @@ class NestedAggregateSerializer(WritableNestedSerializer): class Meta: model = models.Aggregate - fields = ['id', 'url', 'family', 'prefix'] + fields = ['id', 'url', 'display', 'family', 'prefix'] # @@ -75,7 +75,7 @@ class NestedRoleSerializer(WritableNestedSerializer): class Meta: model = models.Role - fields = ['id', 'url', 'name', 'slug', 'prefix_count', 'vlan_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count'] class NestedVLANGroupSerializer(WritableNestedSerializer): @@ -84,7 +84,7 @@ class NestedVLANGroupSerializer(WritableNestedSerializer): class Meta: model = models.VLANGroup - fields = ['id', 'url', 'name', 'slug', 'vlan_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'vlan_count'] class NestedVLANSerializer(WritableNestedSerializer): @@ -92,7 +92,7 @@ class NestedVLANSerializer(WritableNestedSerializer): class Meta: model = models.VLAN - fields = ['id', 'url', 'vid', 'name', 'display_name'] + fields = ['id', 'url', 'display', 'vid', 'name', 'display_name'] # @@ -105,7 +105,7 @@ class NestedPrefixSerializer(WritableNestedSerializer): class Meta: model = models.Prefix - fields = ['id', 'url', 'family', 'prefix'] + fields = ['id', 'url', 'display', 'family', 'prefix'] # @@ -118,7 +118,7 @@ class NestedIPAddressSerializer(WritableNestedSerializer): class Meta: model = models.IPAddress - fields = ['id', 'url', 'family', 'address'] + fields = ['id', 'url', 'display', 'family', 'address'] # @@ -130,4 +130,4 @@ class NestedServiceSerializer(WritableNestedSerializer): class Meta: model = models.Service - fields = ['id', 'url', 'name', 'protocol', 'ports'] + fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 7552ae0d2..931e2cc47 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,12 +6,12 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.choices import * from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF -from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField, ValidatedModelSerializer +from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField +from netbox.api.serializers import OrganizationalModelSerializer +from netbox.api.serializers import PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from utilities.api import get_serializer_for_model from virtualization.api.nested_serializers import NestedVirtualMachineSerializer @@ -22,19 +22,30 @@ from .nested_serializers import * # VRFs # -class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VRFSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vrf-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) - import_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) - export_targets = NestedRouteTargetSerializer(required=False, allow_null=True, many=True) + 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 + ) ipaddress_count = serializers.IntegerField(read_only=True) prefix_count = serializers.IntegerField(read_only=True) class Meta: model = VRF fields = [ - 'id', 'url', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', - 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', 'prefix_count', + 'id', 'url', 'display', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', + 'export_targets', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'ipaddress_count', + 'prefix_count', ] @@ -42,14 +53,14 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Route targets # -class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class RouteTargetSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:routetarget-detail') tenant = NestedTenantSerializer(required=False, allow_null=True) class Meta: model = RouteTarget fields = [ - 'id', 'url', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'tenant', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -57,16 +68,19 @@ class RouteTargetSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # RIRs/aggregates # -class RIRSerializer(ValidatedModelSerializer): +class RIRSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:rir-detail') aggregate_count = serializers.IntegerField(read_only=True) class Meta: model = RIR - fields = ['id', 'url', 'name', 'slug', 'is_private', 'description', 'aggregate_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', + 'last_updated', 'aggregate_count', + ] -class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class AggregateSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:aggregate-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) rir = NestedRIRSerializer() @@ -75,8 +89,8 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Aggregate fields = [ - 'id', 'url', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'family', 'prefix', 'rir', 'tenant', 'date_added', 'description', 'tags', + 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -85,24 +99,37 @@ class AggregateSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # VLANs # -class RoleSerializer(ValidatedModelSerializer): +class RoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:role-detail') prefix_count = serializers.IntegerField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = Role - fields = ['id', 'url', 'name', 'slug', 'weight', 'description', 'prefix_count', 'vlan_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', + 'prefix_count', 'vlan_count', + ] -class VLANGroupSerializer(ValidatedModelSerializer): +class VLANGroupSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail') - site = NestedSiteSerializer(required=False, allow_null=True) + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + app_label='dcim', + model__in=['region', 'sitegroup', 'site', 'location', 'rack'] + ), + required=False + ) + scope = serializers.SerializerMethodField(read_only=True) vlan_count = serializers.IntegerField(read_only=True) class Meta: model = VLANGroup - fields = ['id', 'url', 'name', 'slug', 'site', 'description', 'vlan_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', + 'created', 'last_updated', 'vlan_count', + ] validators = [] def validate(self, data): @@ -118,8 +145,16 @@ class VLANGroupSerializer(ValidatedModelSerializer): return data + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope, prefix='Nested') + context = {'request': self.context['request']} -class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): + return serializer(obj.scope, context=context).data + + +class VLANSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlan-detail') site = NestedSiteSerializer(required=False, allow_null=True) group = NestedVLANGroupSerializer(required=False, allow_null=True) @@ -131,7 +166,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = VLAN fields = [ - 'id', 'url', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', + 'id', 'url', 'display', 'site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description', 'tags', 'display_name', 'custom_fields', 'created', 'last_updated', 'prefix_count', ] validators = [] @@ -154,7 +189,7 @@ class VLANSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Prefixes # -class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class PrefixSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:prefix-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) site = NestedSiteSerializer(required=False, allow_null=True) @@ -167,7 +202,7 @@ class PrefixSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Prefix fields = [ - 'id', 'url', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', + 'id', 'url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] read_only_fields = ['family'] @@ -224,7 +259,7 @@ class AvailablePrefixSerializer(serializers.Serializer): # IP addresses # -class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class IPAddressSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail') family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) vrf = NestedVRFSerializer(required=False, allow_null=True) @@ -243,7 +278,7 @@ class IPAddressSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = IPAddress fields = [ - 'id', 'url', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', + 'id', 'url', 'display', 'family', 'address', 'vrf', 'tenant', 'status', 'role', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'nat_inside', 'nat_outside', 'dns_name', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] @@ -282,7 +317,7 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ServiceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) @@ -297,6 +332,6 @@ class ServiceSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Service fields = [ - 'id', 'url', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', 'description', 'tags', - 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'device', 'virtual_machine', 'name', 'ports', 'protocol', 'ipaddresses', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 9d09bbe03..1e1177772 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,4 +1,6 @@ from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, PermissionDenied +from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_yasg.utils import swagger_auto_schema @@ -12,7 +14,7 @@ from ipam import filters from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF from netbox.api.views import ModelViewSet from utilities.constants import ADVISORY_LOCK_KEYS -from utilities.utils import get_subquery +from utilities.utils import count_related from . import serializers @@ -32,8 +34,8 @@ class VRFViewSet(CustomFieldModelViewSet): queryset = VRF.objects.prefetch_related('tenant').prefetch_related( 'import_targets', 'export_targets', 'tags' ).annotate( - ipaddress_count=get_subquery(IPAddress, 'vrf'), - prefix_count=get_subquery(Prefix, 'vrf') + ipaddress_count=count_related(IPAddress, 'vrf'), + prefix_count=count_related(Prefix, 'vrf') ) serializer_class = serializers.VRFSerializer filterset_class = filters.VRFFilterSet @@ -53,9 +55,9 @@ class RouteTargetViewSet(CustomFieldModelViewSet): # RIRs # -class RIRViewSet(ModelViewSet): +class RIRViewSet(CustomFieldModelViewSet): queryset = RIR.objects.annotate( - aggregate_count=get_subquery(Aggregate, 'rir') + aggregate_count=count_related(Aggregate, 'rir') ) serializer_class = serializers.RIRSerializer filterset_class = filters.RIRFilterSet @@ -75,10 +77,10 @@ class AggregateViewSet(CustomFieldModelViewSet): # Roles # -class RoleViewSet(ModelViewSet): +class RoleViewSet(CustomFieldModelViewSet): queryset = Role.objects.annotate( - prefix_count=get_subquery(Prefix, 'role'), - vlan_count=get_subquery(VLAN, 'role') + prefix_count=count_related(Prefix, 'role'), + vlan_count=count_related(VLAN, 'role') ) serializer_class = serializers.RoleSerializer filterset_class = filters.RoleFilterSet @@ -162,7 +164,12 @@ class PrefixViewSet(CustomFieldModelViewSet): # Create the new Prefix(es) if serializer.is_valid(): - serializer.save() + try: + with transaction.atomic(): + created = serializer.save() + self._validate_objects(created) + except ObjectDoesNotExist: + raise PermissionDenied() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -178,7 +185,7 @@ class PrefixViewSet(CustomFieldModelViewSet): @swagger_auto_schema(method='get', responses={200: serializers.AvailableIPSerializer(many=True)}) @swagger_auto_schema(method='post', responses={201: serializers.AvailableIPSerializer(many=True)}, - request_body=serializers.AvailableIPSerializer(many=False)) + request_body=serializers.AvailableIPSerializer(many=True)) @action(detail=True, url_path='available-ips', methods=['get', 'post'], queryset=IPAddress.objects.all()) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): @@ -225,7 +232,12 @@ class PrefixViewSet(CustomFieldModelViewSet): # Create the new IP address(es) if serializer.is_valid(): - serializer.save() + try: + with transaction.atomic(): + created = serializer.save() + self._validate_objects(created) + except ObjectDoesNotExist: + raise PermissionDenied() return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -270,9 +282,9 @@ class IPAddressViewSet(CustomFieldModelViewSet): # VLAN groups # -class VLANGroupViewSet(ModelViewSet): - queryset = VLANGroup.objects.prefetch_related('site').annotate( - vlan_count=get_subquery(VLAN, 'group') +class VLANGroupViewSet(CustomFieldModelViewSet): + queryset = VLANGroup.objects.annotate( + vlan_count=count_related(VLAN, 'group') ) serializer_class = serializers.VLANGroupSerializer filterset_class = filters.VLANGroupFilterSet @@ -286,7 +298,7 @@ class VLANViewSet(CustomFieldModelViewSet): queryset = VLAN.objects.prefetch_related( 'site', 'group', 'tenant', 'role', 'tags' ).annotate( - prefix_count=get_subquery(Prefix, 'vlan') + prefix_count=count_related(Prefix, 'vlan') ) serializer_class = serializers.VLANSerializer filterset_class = filters.VLANFilterSet diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index 176d77a88..5c5b9e8d3 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -1,15 +1,16 @@ import django_filters import netaddr +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db.models import Q from netaddr.core import AddrFormatError -from dcim.models import Device, Interface, Region, Site +from dcim.models import Device, Interface, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( - BaseFilterSet, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericArrayFilter, TagFilter, - TreeNodeMultipleChoiceFilter, + BaseFilterSet, ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, + NumericArrayFilter, TagFilter, TreeNodeMultipleChoiceFilter, ) from virtualization.models import VirtualMachine, VMInterface from .choices import * @@ -192,7 +193,7 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet field_name='prefix', lookup_expr='family' ) - prefix = django_filters.CharFilter( + prefix = MultiValueCharFilter( method='filter_prefix', label='Prefix', ) @@ -254,6 +255,19 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -304,13 +318,13 @@ class PrefixFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet return queryset.filter(qs_filter) def filter_prefix(self, queryset, name, value): - if not value.strip(): - return queryset - try: - query = str(netaddr.IPNetwork(value).cidr) - return queryset.filter(prefix=query) - except (AddrFormatError, ValueError): - return queryset.none() + query_values = [] + for v in value: + try: + query_values.append(netaddr.IPNetwork(v)) + except (AddrFormatError, ValueError): + pass + return queryset.filter(prefix__in=query_values) def search_within(self, queryset, name, value): value = value.strip() @@ -522,33 +536,38 @@ class IPAddressFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilter class VLANGroupFilterSet(BaseFilterSet, NameSlugSearchFilterSet): - region_id = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - label='Region (ID)', + scope_type = ContentTypeFilter() + region = django_filters.NumberFilter( + method='filter_scope' ) - region = TreeNodeMultipleChoiceFilter( - queryset=Region.objects.all(), - field_name='site__region', - lookup_expr='in', - to_field_name='slug', - label='Region (slug)', + sitegroup = django_filters.NumberFilter( + method='filter_scope' ) - site_id = django_filters.ModelMultipleChoiceFilter( - queryset=Site.objects.all(), - label='Site (ID)', + site = django_filters.NumberFilter( + method='filter_scope' ) - site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', - queryset=Site.objects.all(), - to_field_name='slug', - label='Site (slug)', + location = django_filters.NumberFilter( + method='filter_scope' + ) + rack = django_filters.NumberFilter( + method='filter_scope' + ) + clustergroup = django_filters.NumberFilter( + method='filter_scope' + ) + cluster = django_filters.NumberFilter( + method='filter_scope' ) class Meta: model = VLANGroup - fields = ['id', 'name', 'slug', 'description'] + fields = ['id', 'name', 'slug', 'description', 'scope_id'] + + def filter_scope(self, queryset, name, value): + return queryset.filter( + scope_type=ContentType.objects.get(model=name), + scope_id=value + ) class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): @@ -569,6 +588,19 @@ class VLANFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSet, to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 29a6d295e..53a36606f 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,6 +1,7 @@ from django import forms +from django.utils.translation import gettext as _ -from dcim.models import Device, Interface, Rack, Region, Site +from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -8,11 +9,11 @@ from extras.models import Tag from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( - add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, CSVModelForm, - DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, + add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, CSVChoiceField, CSVModelChoiceField, DatePicker, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField, NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) -from virtualization.models import Cluster, VirtualMachine, VMInterface +from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from .choices import * from .constants import * from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF @@ -50,6 +51,11 @@ class VRFForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'name', 'rd', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('VRF', ('name', 'rd', 'enforce_unique', 'description', 'tags')), + ('Route Targets', ('import_targets', 'export_targets')), + ('Tenancy', ('tenant_group', 'tenant')), + ) labels = { 'rd': "RD", } @@ -98,20 +104,20 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF - field_order = ['q', 'import_target', 'export_target', 'tenant_group', 'tenant'] + field_order = ['q', 'import_target_id', 'export_target_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - import_target = DynamicModelMultipleChoiceField( + import_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), - to_field_name='name', - required=False + required=False, + label=_('Import targets') ) - export_target = DynamicModelMultipleChoiceField( + export_target_id = DynamicModelMultipleChoiceField( queryset=RouteTarget.objects.all(), - to_field_name='name', - required=False + required=False, + label=_('Export targets') ) tag = TagFilterField(model) @@ -168,20 +174,20 @@ class RouteTargetBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulk class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = RouteTarget - field_order = ['q', 'name', 'tenant_group', 'tenant', 'importing_vrfs', 'exporting_vrfs'] + field_order = ['q', 'name', 'tenant_group_id', 'tenant_id', 'importing_vrfs', 'exporting_vrfs'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) importing_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label='Imported by VRF' + label=_('Imported by VRF') ) exporting_vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label='Exported by VRF' + label=_('Exported by VRF') ) tag = TagFilterField(model) @@ -190,7 +196,7 @@ class RouteTargetFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilter # RIRs # -class RIRForm(BootstrapMixin, forms.ModelForm): +class RIRForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -200,7 +206,7 @@ class RIRForm(BootstrapMixin, forms.ModelForm): ] -class RIRCSVForm(CSVModelForm): +class RIRCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -211,10 +217,28 @@ class RIRCSVForm(CSVModelForm): } +class RIRBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=RIR.objects.all(), + widget=forms.MultipleHiddenInput + ) + is_private = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['is_private', 'description'] + + class RIRFilterForm(BootstrapMixin, forms.Form): is_private = forms.NullBooleanField( required=False, - label='Private', + label=_('Private'), widget=StaticSelect2( choices=BOOLEAN_WITH_BLANK_CHOICES ) @@ -240,6 +264,10 @@ class AggregateForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): fields = [ 'prefix', 'rir', 'date_added', 'description', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('Aggregate', ('prefix', 'rir', 'date_added', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) help_texts = { 'prefix': "IPv4 or IPv6 network", 'rir': "Regional Internet Registry responsible for this prefix", @@ -300,21 +328,21 @@ class AggregateBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Aggregate + field_order = ['q', 'family', 'rir', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label='Address family', + label=_('Address family'), widget=StaticSelect2() ) - rir = DynamicModelMultipleChoiceField( + rir_id = DynamicModelMultipleChoiceField( queryset=RIR.objects.all(), - to_field_name='slug', required=False, - label='RIR' + label=_('RIR') ) tag = TagFilterField(model) @@ -323,7 +351,7 @@ class AggregateFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # Roles # -class RoleForm(BootstrapMixin, forms.ModelForm): +class RoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -333,7 +361,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm): ] -class RoleCSVForm(CSVModelForm): +class RoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -341,6 +369,23 @@ class RoleCSVForm(CSVModelForm): fields = Role.csv_headers +class RoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Role.objects.all(), + widget=forms.MultipleHiddenInput + ) + weight = forms.IntegerField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Prefixes # @@ -349,8 +394,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -359,12 +403,20 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, null_option='None', query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) vlan_group = DynamicModelChoiceField( @@ -383,7 +435,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): queryset=VLAN.objects.all(), required=False, label='VLAN', - display_field='display_name', query_params={ 'site_id': '$site', 'group_id': '$vlan_group', @@ -404,6 +455,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'description', 'tenant_group', 'tenant', 'tags', ] + fieldsets = ( + ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')), + ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), + ('Tenancy', ('tenant_group', 'tenant')), + ) widgets = { 'status': StaticSelect2(), } @@ -465,12 +521,14 @@ class PrefixCSVForm(CustomFieldModelCSVForm): if data: - # Limit vlan queryset by assigned site and group - params = { - f"site__{self.fields['site'].to_field_name}": data.get('site'), - f"group__{self.fields['vlan_group'].to_field_name}": data.get('vlan_group'), - } - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + # Limit VLAN queryset by assigned site and/or group (if specified) + params = {} + if data.get('site'): + params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') + if data.get('vlan_group'): + params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') + if params: + self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): @@ -480,21 +538,24 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ) region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False, - to_field_name='slug' + required=False + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) prefix_length = forms.IntegerField( min_value=PREFIX_LENGTH_MIN, @@ -533,15 +594,15 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Prefix field_order = [ - 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region', 'site', - 'role', 'tenant_group', 'tenant', 'is_pool', 'expand', + 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'region_id', + 'site_group_id', 'site_id', 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', ] mask_length__lte = forms.IntegerField( widget=forms.HiddenInput() ) q = forms.CharField( required=False, - label='Search' + label=_('Search') ) within_include = forms.CharField( required=False, @@ -550,59 +611,64 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) 'placeholder': 'Prefix', } ), - label='Search within' + label=_('Search within') ) family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label='Address family', + label=_('Address family'), widget=StaticSelect2() ) mask_length = forms.ChoiceField( required=False, choices=PREFIX_MASK_LENGTH_CHOICES, - label='Mask length', + label=_('Mask length'), widget=StaticSelect2() ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label='Assigned VRF', + label=_('Assigned VRF'), null_option='Global' ) present_in_vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='Present in VRF' + label=_('Present in VRF') ) status = forms.MultipleChoiceField( choices=PrefixStatusChoices, required=False, widget=StaticSelect2Multiple() ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, null_option='None', query_params={ - 'region': '$region' - } + 'region_id': '$region_id' + }, + label=_('Site') ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Role') ) is_pool = forms.NullBooleanField( required=False, - label='Is a pool', + label=_('Is a pool'), widget=StaticSelect2( choices=BOOLEAN_WITH_BLANK_CHOICES ) @@ -618,7 +684,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name', initial_params={ 'interfaces': '$interface' } @@ -648,8 +713,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) nat_region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -659,19 +723,27 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel 'sites': '$nat_site' } ) + nat_site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label='Site group', + initial_params={ + 'sites': '$nat_site' + } + ) nat_site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, label='Site', query_params={ - 'region_id': '$nat_region' + 'region_id': '$nat_region', + 'group_id': '$nat_site_group', } ) nat_rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, label='Rack', - display_field='display_name', null_option='None', query_params={ 'site_id': '$site' @@ -681,7 +753,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel queryset=Device.objects.all(), required=False, label='Device', - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$nat_rack', @@ -703,14 +774,12 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel nat_vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), required=False, label='IP Address', - display_field='address', query_params={ 'device_id': '$nat_device', 'virtual_machine_id': '$nat_virtual_machine', @@ -766,7 +835,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: - parent = self.instance.assigned_object.parent + parent = self.instance.assigned_object.parent_object if ( self.instance.address.version == 4 and parent.primary_ip4_id == self.instance.pk or self.instance.address.version == 6 and parent.primary_ip6_id == self.instance.pk @@ -774,6 +843,7 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel self.initial['primary_for_parent'] = True def clean(self): + super().clean() # Cannot select both a device interface and a VM interface if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): @@ -792,18 +862,20 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. interface = self.instance.assigned_object - if interface and self.cleaned_data['primary_for_parent']: - if ipaddress.address.version == 4: - interface.parent.primary_ip4 = ipaddress - else: - interface.parent.primary_ip6 = ipaddress - interface.parent.save() - elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: - interface.parent.primary_ip4 = None - interface.parent.save() - elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: - interface.parent.primary_ip6 = None - interface.parent.save() + if interface: + parent = interface.parent_object + if self.cleaned_data['primary_for_parent']: + if ipaddress.address.version == 4: + parent.primary_ip4 = ipaddress + else: + parent.primary_ip6 = ipaddress + parent.save() + elif ipaddress.address.version == 4 and parent.primary_ip4 == ipaddress: + parent.primary_ip4 = None + parent.save() + elif ipaddress.address.version == 6 and parent.primary_ip6 == ipaddress: + parent.primary_ip6 = None + parent.save() return ipaddress @@ -818,8 +890,7 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), @@ -950,8 +1021,7 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd vrf = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - display_field='display_name' + label='VRF' ) mask_length = forms.IntegerField( min_value=IPADDRESS_MASK_LENGTH_MIN, @@ -1004,11 +1074,11 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo model = IPAddress field_order = [ 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'present_in_vrf_id', 'status', 'role', - 'assigned_to_interface', 'tenant_group', 'tenant', + 'assigned_to_interface', 'tenant_group_id', 'tenant_id', ] q = forms.CharField( required=False, - label='Search' + label=_('Search') ) parent = forms.CharField( required=False, @@ -1022,25 +1092,25 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo family = forms.ChoiceField( required=False, choices=add_blank_choice(IPAddressFamilyChoices), - label='Address family', + label=_('Address family'), widget=StaticSelect2() ) mask_length = forms.ChoiceField( required=False, choices=IPADDRESS_MASK_LENGTH_CHOICES, - label='Mask length', + label=_('Mask length'), widget=StaticSelect2() ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), required=False, - label='Assigned VRF', + label=_('Assigned VRF'), null_option='Global' ) present_in_vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='Present in VRF' + label=_('Present in VRF') ) status = forms.MultipleChoiceField( choices=IPAddressStatusChoices, @@ -1054,7 +1124,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo ) assigned_to_interface = forms.NullBooleanField( required=False, - label='Assigned to an interface', + label=_('Assigned to an interface'), widget=StaticSelect2( choices=BOOLEAN_WITH_BLANK_CHOICES ) @@ -1066,7 +1136,7 @@ class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterFo # VLAN groups # -class VLANGroupForm(BootstrapMixin, forms.ModelForm): +class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -1074,11 +1144,54 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): 'sites': '$site' } ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + } + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, + initial_params={ + 'locations': '$location' + }, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', + } + ) + location = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + initial_params={ + 'racks': '$rack' + }, + query_params={ + 'site_id': '$site', + } + ) + rack = DynamicModelChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site', + 'location_id': '$location', + } + ) + cluster_group = DynamicModelChoiceField( + queryset=ClusterGroup.objects.all(), + required=False, + initial_params={ + 'clusters': '$cluster' + } + ) + cluster = DynamicModelChoiceField( + queryset=Cluster.objects.all(), + required=False, + query_params={ + 'group_id': '$cluster_group', } ) slug = SlugField() @@ -1086,11 +1199,45 @@ class VLANGroupForm(BootstrapMixin, forms.ModelForm): class Meta: model = VLANGroup fields = [ - 'region', 'site', 'name', 'slug', 'description', + 'name', 'slug', 'description', 'region', 'site_group', 'site', 'location', 'rack', 'cluster_group', + 'cluster', ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) -class VLANGroupCSVForm(CSVModelForm): + if instance is not None and instance.scope: + if type(instance.scope) is Rack: + initial['rack'] = instance.scope + elif type(instance.scope) is Location: + initial['location'] = instance.scope + elif type(instance.scope) is Site: + initial['site'] = instance.scope + elif type(instance.scope) is SiteGroup: + initial['site_group'] = instance.scope + elif type(instance.scope) is Region: + initial['region'] = instance.scope + elif type(instance.scope) is Cluster: + initial['cluster'] = instance.scope + elif type(instance.scope) is ClusterGroup: + initial['cluster_group'] = instance.scope + + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + def clean(self): + super().clean() + + # Assign scope object + self.instance.scope = self.cleaned_data['rack'] or self.cleaned_data['location'] or \ + self.cleaned_data['site'] or self.cleaned_data['site_group'] or \ + self.cleaned_data['region'] or self.cleaned_data['cluster'] or \ + self.cleaned_data['cluster_group'] or None + + +class VLANGroupCSVForm(CustomFieldModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), required=False, @@ -1104,20 +1251,49 @@ class VLANGroupCSVForm(CSVModelForm): fields = VLANGroup.csv_headers +class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=VLANGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['site', 'description'] + + class VLANGroupFilterForm(BootstrapMixin, forms.Form): region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') + ) + sitegroup = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') ) site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, - null_option='None', - query_params={ - 'region': '$region' - } + label=_('Site') + ) + location = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) + rack = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack') ) @@ -1126,28 +1302,59 @@ class VLANGroupFilterForm(BootstrapMixin, forms.Form): # class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): + # VLANGroup assignment fields + scope_type = forms.ChoiceField( + choices=( + ('', ''), + ('dcim.region', 'Region'), + ('dcim.sitegroup', 'Site group'), + ('dcim.site', 'Site'), + ('dcim.location', 'Location'), + ('dcim.rack', 'Rack'), + ('virtualization.clustergroup', 'Cluster group'), + ('virtualization.cluster', 'Cluster'), + ), + required=False, + widget=StaticSelect2, + label='Group scope' + ) + group = DynamicModelChoiceField( + queryset=VLANGroup.objects.all(), + required=False, + query_params={ + 'scope_type': '$scope_type', + }, + label='VLAN Group' + ) + + # Site assignment fields region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, initial_params={ 'sites': '$site' - } + }, + label='Region' + ) + sitegroup = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + initial_params={ + 'sites': '$site' + }, + label='Site group' ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, null_option='None', query_params={ - 'region_id': '$region' - } - ) - group = DynamicModelChoiceField( - queryset=VLANGroup.objects.all(), - required=False, - query_params={ - 'site_id': '$site' + 'region_id': '$region', + 'group_id': '$sitegroup', } ) + + # Other fields role = DynamicModelChoiceField( queryset=Role.objects.all(), required=False @@ -1213,15 +1420,6 @@ class VLANCSVForm(CustomFieldModelCSVForm): 'name': 'VLAN name', } - def __init__(self, data=None, *args, **kwargs): - super().__init__(data, *args, **kwargs) - - if data: - - # Limit vlan queryset by assigned group - params = {f"site__{self.fields['site'].to_field_name}": data.get('site')} - self.fields['group'].queryset = self.fields['group'].queryset.filter(**params) - class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField( @@ -1230,14 +1428,18 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ) region = DynamicModelChoiceField( queryset=Region.objects.all(), - required=False, - to_field_name='slug' + required=False + ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) group = DynamicModelChoiceField( @@ -1273,44 +1475,51 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN - field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = [ + 'q', 'region_id', 'site_group_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + ] q = forms.CharField( required=False, label='Search' ) - region = DynamicModelMultipleChoiceField( + region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Region') ) - site = DynamicModelMultipleChoiceField( + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), - to_field_name='slug', required=False, null_option='None', query_params={ 'region': '$region' - } + }, + label=_('Site') ) group_id = DynamicModelMultipleChoiceField( queryset=VLANGroup.objects.all(), required=False, - label='VLAN group', null_option='None', query_params={ 'region': '$region' - } + }, + label=_('VLAN group') ) status = forms.MultipleChoiceField( choices=VLANStatusChoices, required=False, widget=StaticSelect2Multiple() ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), - to_field_name='slug', required=False, - null_option='None' + null_option='None', + label=_('Role') ) tag = TagFilterField(model) @@ -1366,7 +1575,7 @@ class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Service q = forms.CharField( required=False, - label='Search' + label=_('Search') ) protocol = forms.ChoiceField( choices=add_blank_choice(ServiceProtocolChoices), diff --git a/netbox/ipam/migrations/0044_standardize_models.py b/netbox/ipam/migrations/0044_standardize_models.py new file mode 100644 index 000000000..2762c9973 --- /dev/null +++ b/netbox/ipam/migrations/0044_standardize_models.py @@ -0,0 +1,77 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0043_add_tenancy_to_aggregates'), + ] + + operations = [ + migrations.AddField( + model_name='rir', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='role', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AddField( + model_name='vlangroup', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/ipam/migrations/0045_vlangroup_scope.py b/netbox/ipam/migrations/0045_vlangroup_scope.py new file mode 100644 index 000000000..8795750d2 --- /dev/null +++ b/netbox/ipam/migrations/0045_vlangroup_scope.py @@ -0,0 +1,36 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0044_standardize_models'), + ] + + operations = [ + migrations.RenameField( + model_name='vlangroup', + old_name='site', + new_name='scope_id', + ), + migrations.AlterField( + model_name='vlangroup', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='vlangroup', + name='scope_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster']), null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.contenttype'), + ), + migrations.AlterModelOptions( + name='vlangroup', + options={'ordering': ('name', 'pk'), 'verbose_name': 'VLAN group', 'verbose_name_plural': 'VLAN groups'}, + ), + migrations.AlterUniqueTogether( + name='vlangroup', + unique_together={('scope_type', 'scope_id', 'name'), ('scope_type', 'scope_id', 'slug')}, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py new file mode 100644 index 000000000..7eecf8cfc --- /dev/null +++ b/netbox/ipam/models/__init__.py @@ -0,0 +1,17 @@ +from .ip import * +from .services import * +from .vlans import * +from .vrfs import * + +__all__ = ( + 'Aggregate', + 'IPAddress', + 'Prefix', + 'RIR', + 'Role', + 'RouteTarget', + 'Service', + 'VLAN', + 'VLANGroup', + 'VRF', +) diff --git a/netbox/ipam/models.py b/netbox/ipam/models/ip.py similarity index 62% rename from netbox/ipam/models.py rename to netbox/ipam/models/ip.py index 0ae9db7b2..b11a88d54 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models/ip.py @@ -2,26 +2,22 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError -from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import F from django.urls import reverse -from taggit.managers import TaggableManager -from dcim.models import Device, Interface -from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem +from dcim.models import Device from extras.utils import extras_features +from netbox.models import OrganizationalModel, PrimaryModel +from ipam.choices import * +from ipam.constants import * +from ipam.fields import IPNetworkField, IPAddressField +from ipam.managers import IPAddressManager +from ipam.querysets import PrefixQuerySet +from ipam.validators import DNSValidator from utilities.querysets import RestrictedQuerySet -from utilities.utils import array_to_string, serialize_object -from virtualization.models import VirtualMachine, VMInterface -from .choices import * -from .constants import * -from .fields import IPNetworkField, IPAddressField -from .managers import IPAddressManager -from .querysets import PrefixQuerySet -from .validators import DNSValidator +from virtualization.models import VirtualMachine __all__ = ( @@ -30,139 +26,11 @@ __all__ = ( 'Prefix', 'RIR', 'Role', - 'RouteTarget', - 'Service', - 'VLAN', - 'VLANGroup', - 'VRF', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VRF(ChangeLoggedModel, CustomFieldModel): - """ - A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing - table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF - are said to exist in the "global" table.) - """ - name = models.CharField( - max_length=100 - ) - rd = models.CharField( - max_length=VRF_RD_MAX_LENGTH, - unique=True, - blank=True, - null=True, - verbose_name='Route distinguisher', - help_text='Unique route distinguisher (as defined in RFC 4364)' - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vrfs', - blank=True, - null=True - ) - enforce_unique = models.BooleanField( - default=True, - verbose_name='Enforce unique space', - help_text='Prevent duplicate prefixes/IP addresses within this VRF' - ) - description = models.CharField( - max_length=200, - blank=True - ) - import_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='importing_vrfs', - blank=True - ) - export_targets = models.ManyToManyField( - to='ipam.RouteTarget', - related_name='exporting_vrfs', - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] - clone_fields = [ - 'tenant', 'enforce_unique', 'description', - ] - - class Meta: - ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique - verbose_name = 'VRF' - verbose_name_plural = 'VRFs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vrf', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.rd, - self.tenant.name if self.tenant else None, - self.enforce_unique, - self.description, - ) - - @property - def display_name(self): - if self.rd: - return f'{self.name} ({self.rd})' - return self.name - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class RouteTarget(ChangeLoggedModel, CustomFieldModel): - """ - A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. - """ - name = models.CharField( - max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) - unique=True, - help_text='Route target value (formatted in accordance with RFC 4360)' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='route_targets', - blank=True, - null=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'description', 'tenant'] - - class Meta: - ordering = ['name'] - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:routetarget', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.description, - self.tenant.name if self.tenant else None, - ) - - -class RIR(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address space. This can be an organization like ARIN or RIPE, or a governing standard such as RFC 1918. @@ -198,7 +66,7 @@ class RIR(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug) + return reverse('ipam:rir', args=[self.pk]) def to_csv(self): return ( @@ -210,7 +78,7 @@ class RIR(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Aggregate(ChangeLoggedModel, CustomFieldModel): +class Aggregate(PrimaryModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -237,7 +105,6 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -317,7 +184,8 @@ class Aggregate(ChangeLoggedModel, CustomFieldModel): return int(float(child_prefixes.size) / self.prefix.size * 100) -class Role(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or "Management." @@ -348,6 +216,9 @@ class Role(ChangeLoggedModel): def __str__(self): return self.name + def get_absolute_url(self): + return reverse('ipam:role', args=[self.pk]) + def to_csv(self): return ( self.name, @@ -358,7 +229,7 @@ class Role(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Prefix(ChangeLoggedModel, CustomFieldModel): +class Prefix(PrimaryModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -421,7 +292,6 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = PrefixQuerySet.as_manager() @@ -616,7 +486,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class IPAddress(ChangeLoggedModel, CustomFieldModel): +class IPAddress(PrimaryModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -693,7 +563,6 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = IPAddressManager() @@ -734,13 +603,12 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): }) # Enforce unique IP space (if applicable) - if self.role not in IPADDRESS_ROLES_NONUNIQUE and (( - self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE - ) or ( - self.vrf and self.vrf.enforce_unique - )): + if (self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE) or (self.vrf and self.vrf.enforce_unique): duplicate_ips = self.get_duplicates() - if duplicate_ips: + if duplicate_ips and ( + self.role not in IPADDRESS_ROLES_NONUNIQUE or + any(dip.role not in IPADDRESS_ROLES_NONUNIQUE for dip in duplicate_ips) + ): raise ValidationError({ 'address': "Duplicate IP address found in {}: {}".format( "VRF {}".format(self.vrf) if self.vrf else "global table", @@ -778,13 +646,7 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): def to_objectchange(self, action): # Annotate the assigned object, if any - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=self.assigned_object, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=self.assigned_object) def to_csv(self): @@ -832,274 +694,3 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): def get_role_class(self): return IPAddressRoleChoices.CSS_CLASSES.get(self.role) - - -class VLANGroup(ChangeLoggedModel): - """ - A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. - """ - name = models.CharField( - max_length=100 - ) - slug = models.SlugField( - max_length=100 - ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlan_groups', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['name', 'slug', 'site', 'description'] - - class Meta: - ordering = ('site', 'name', 'pk') # (site, name) may be non-unique - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - verbose_name = 'VLAN group' - verbose_name_plural = 'VLAN groups' - - def __str__(self): - return self.name - - def get_absolute_url(self): - return reverse('ipam:vlangroup_vlans', args=[self.pk]) - - def to_csv(self): - return ( - self.name, - self.slug, - self.site.name if self.site else None, - self.description, - ) - - def get_next_available_vid(self): - """ - Return the first available VLAN ID (1-4094) in the group. - """ - vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) - for i in range(1, 4095): - if i not in vlan_ids: - return i - return None - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class VLAN(ChangeLoggedModel, CustomFieldModel): - """ - A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned - to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, - within which all VLAN IDs and names but be unique. - - Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero - or more Prefixes assigned to it. - """ - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - group = models.ForeignKey( - to='ipam.VLANGroup', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - vid = models.PositiveSmallIntegerField( - verbose_name='ID', - validators=[MinValueValidator(1), MaxValueValidator(4094)] - ) - name = models.CharField( - max_length=64 - ) - tenant = models.ForeignKey( - to='tenancy.Tenant', - on_delete=models.PROTECT, - related_name='vlans', - blank=True, - null=True - ) - status = models.CharField( - max_length=50, - choices=VLANStatusChoices, - default=VLANStatusChoices.STATUS_ACTIVE - ) - role = models.ForeignKey( - to='ipam.Role', - on_delete=models.SET_NULL, - related_name='vlans', - blank=True, - null=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] - clone_fields = [ - 'site', 'group', 'tenant', 'status', 'role', 'description', - ] - - class Meta: - ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique - unique_together = [ - ['group', 'vid'], - ['group', 'name'], - ] - verbose_name = 'VLAN' - verbose_name_plural = 'VLANs' - - def __str__(self): - return self.display_name or super().__str__() - - def get_absolute_url(self): - return reverse('ipam:vlan', args=[self.pk]) - - def clean(self): - super().clean() - - # Validate VLAN group - if self.group and self.group.site != self.site: - raise ValidationError({ - 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) - }) - - def to_csv(self): - return ( - self.site.name if self.site else None, - self.group.name if self.group else None, - self.vid, - self.name, - self.tenant.name if self.tenant else None, - self.get_status_display(), - self.role.name if self.role else None, - self.description, - ) - - @property - def display_name(self): - return f'{self.name} ({self.vid})' - - def get_status_class(self): - return VLANStatusChoices.CSS_CLASSES.get(self.status) - - def get_interfaces(self): - # Return all device interfaces assigned to this VLAN - return Interface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - def get_vminterfaces(self): - # Return all VM interfaces assigned to this VLAN - return VMInterface.objects.filter( - Q(untagged_vlan_id=self.pk) | - Q(tagged_vlans=self.pk) - ).distinct() - - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Service(ChangeLoggedModel, CustomFieldModel): - """ - A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may - optionally be tied to one or more specific IPAddresses belonging to its parent. - """ - device = models.ForeignKey( - to='dcim.Device', - on_delete=models.CASCADE, - related_name='services', - verbose_name='device', - null=True, - blank=True - ) - virtual_machine = models.ForeignKey( - to='virtualization.VirtualMachine', - on_delete=models.CASCADE, - related_name='services', - null=True, - blank=True - ) - name = models.CharField( - max_length=100 - ) - protocol = models.CharField( - max_length=50, - choices=ServiceProtocolChoices - ) - ports = ArrayField( - base_field=models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ] - ), - verbose_name='Port numbers' - ) - ipaddresses = models.ManyToManyField( - to='ipam.IPAddress', - related_name='services', - blank=True, - verbose_name='IP addresses' - ) - description = models.CharField( - max_length=200, - blank=True - ) - tags = TaggableManager(through=TaggedItem) - - objects = RestrictedQuerySet.as_manager() - - csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] - - class Meta: - ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - - def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' - - def get_absolute_url(self): - return reverse('ipam:service', args=[self.pk]) - - @property - def parent(self): - return self.device or self.virtual_machine - - def clean(self): - super().clean() - - # A Service must belong to a Device *or* to a VirtualMachine - if self.device and self.virtual_machine: - raise ValidationError("A service cannot be associated with both a device and a virtual machine.") - if not self.device and not self.virtual_machine: - raise ValidationError("A service must be associated with either a device or a virtual machine.") - - def to_csv(self): - return ( - self.device.name if self.device else None, - self.virtual_machine.name if self.virtual_machine else None, - self.name, - self.get_protocol_display(), - self.ports, - self.description, - ) - - @property - def port_list(self): - return array_to_string(self.ports) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py new file mode 100644 index 000000000..336f3a269 --- /dev/null +++ b/netbox/ipam/models/services.py @@ -0,0 +1,106 @@ +from django.contrib.postgres.fields import ArrayField +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse + +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet +from utilities.utils import array_to_string + + +__all__ = ( + 'Service', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class Service(PrimaryModel): + """ + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may + optionally be tied to one or more specific IPAddresses belonging to its parent. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='services', + verbose_name='device', + null=True, + blank=True + ) + virtual_machine = models.ForeignKey( + to='virtualization.VirtualMachine', + on_delete=models.CASCADE, + related_name='services', + null=True, + blank=True + ) + name = models.CharField( + max_length=100 + ) + protocol = models.CharField( + max_length=50, + choices=ServiceProtocolChoices + ) + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' + ) + ipaddresses = models.ManyToManyField( + to='ipam.IPAddress', + related_name='services', + blank=True, + verbose_name='IP addresses' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['device', 'virtual_machine', 'name', 'protocol', 'ports', 'description'] + + class Meta: + ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique + + def __str__(self): + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' + + def get_absolute_url(self): + return reverse('ipam:service', args=[self.pk]) + + @property + def parent(self): + return self.device or self.virtual_machine + + def clean(self): + super().clean() + + # A Service must belong to a Device *or* to a VirtualMachine + if self.device and self.virtual_machine: + raise ValidationError("A service cannot be associated with both a device and a virtual machine.") + if not self.device and not self.virtual_machine: + raise ValidationError("A service must be associated with either a device or a virtual machine.") + + def to_csv(self): + return ( + self.device.name if self.device else None, + self.virtual_machine.name if self.virtual_machine else None, + self.name, + self.get_protocol_display(), + self.ports, + self.description, + ) + + @property + def port_list(self): + return array_to_string(self.ports) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py new file mode 100644 index 000000000..0c184adff --- /dev/null +++ b/netbox/ipam/models/vlans.py @@ -0,0 +1,222 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.urls import reverse + +from dcim.models import Interface +from extras.utils import extras_features +from ipam.choices import * +from ipam.constants import * +from netbox.models import OrganizationalModel, PrimaryModel +from utilities.querysets import RestrictedQuerySet +from virtualization.models import VMInterface + + +__all__ = ( + 'VLAN', + 'VLANGroup', +) + + +@extras_features('custom_fields', 'export_templates', 'webhooks') +class VLANGroup(OrganizationalModel): + """ + A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. + """ + name = models.CharField( + max_length=100 + ) + slug = models.SlugField( + max_length=100 + ) + scope_type = models.ForeignKey( + to=ContentType, + on_delete=models.CASCADE, + limit_choices_to=Q( + model__in=['region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster'] + ), + blank=True, + null=True + ) + scope_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + scope = GenericForeignKey( + ct_field='scope_type', + fk_field='scope_id' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description'] + + class Meta: + ordering = ('name', 'pk') # Name may be non-unique + unique_together = [ + ['scope_type', 'scope_id', 'name'], + ['scope_type', 'scope_id', 'slug'], + ] + verbose_name = 'VLAN group' + verbose_name_plural = 'VLAN groups' + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:vlangroup', args=[self.pk]) + + def clean(self): + super().clean() + + # Validate scope assignment + if self.scope_type and not self.scope_id: + raise ValidationError("Cannot set scope_type without scope_id.") + if self.scope_id and not self.scope_type: + raise ValidationError("Cannot set scope_id without scope_type.") + + def to_csv(self): + return ( + self.name, + self.slug, + f'{self.scope_type.app_label}.{self.scope_type.model}', + self.scope_id, + self.description, + ) + + def get_next_available_vid(self): + """ + Return the first available VLAN ID (1-4094) in the group. + """ + vlan_ids = VLAN.objects.filter(group=self).values_list('vid', flat=True) + for i in range(1, 4095): + if i not in vlan_ids: + return i + return None + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VLAN(PrimaryModel): + """ + A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned + to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, + within which all VLAN IDs and names but be unique. + + Like Prefixes, each VLAN is assigned an operational status and optionally a user-defined Role. A VLAN can have zero + or more Prefixes assigned to it. + """ + site = models.ForeignKey( + to='dcim.Site', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + group = models.ForeignKey( + to='ipam.VLANGroup', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + vid = models.PositiveSmallIntegerField( + verbose_name='ID', + validators=[MinValueValidator(1), MaxValueValidator(4094)] + ) + name = models.CharField( + max_length=64 + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vlans', + blank=True, + null=True + ) + status = models.CharField( + max_length=50, + choices=VLANStatusChoices, + default=VLANStatusChoices.STATUS_ACTIVE + ) + role = models.ForeignKey( + to='ipam.Role', + on_delete=models.SET_NULL, + related_name='vlans', + blank=True, + null=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['site', 'group', 'vid', 'name', 'tenant', 'status', 'role', 'description'] + clone_fields = [ + 'site', 'group', 'tenant', 'status', 'role', 'description', + ] + + class Meta: + ordering = ('site', 'group', 'vid', 'pk') # (site, group, vid) may be non-unique + unique_together = [ + ['group', 'vid'], + ['group', 'name'], + ] + verbose_name = 'VLAN' + verbose_name_plural = 'VLANs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vlan', args=[self.pk]) + + def clean(self): + super().clean() + + # Validate VLAN group (if assigned) + if self.group and self.site and self.group.scope != self.site: + raise ValidationError({ + 'group': f"VLAN is assigned to group {self.group} (scope: {self.group.scope}); cannot also assign to " + f"site {self.site}." + }) + + def to_csv(self): + return ( + self.site.name if self.site else None, + self.group.name if self.group else None, + self.vid, + self.name, + self.tenant.name if self.tenant else None, + self.get_status_display(), + self.role.name if self.role else None, + self.description, + ) + + @property + def display_name(self): + return f'{self.name} ({self.vid})' + + def get_status_class(self): + return VLANStatusChoices.CSS_CLASSES.get(self.status) + + def get_interfaces(self): + # Return all device interfaces assigned to this VLAN + return Interface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() + + def get_vminterfaces(self): + # Return all VM interfaces assigned to this VLAN + return VMInterface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py new file mode 100644 index 000000000..9eb2c6ab6 --- /dev/null +++ b/netbox/ipam/models/vrfs.py @@ -0,0 +1,135 @@ +from django.db import models +from django.urls import reverse + +from extras.utils import extras_features +from ipam.constants import * +from netbox.models import PrimaryModel +from utilities.querysets import RestrictedQuerySet + + +__all__ = ( + 'RouteTarget', + 'VRF', +) + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VRF(PrimaryModel): + """ + A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing + table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF + are said to exist in the "global" table.) + """ + name = models.CharField( + max_length=100 + ) + rd = models.CharField( + max_length=VRF_RD_MAX_LENGTH, + unique=True, + blank=True, + null=True, + verbose_name='Route distinguisher', + help_text='Unique route distinguisher (as defined in RFC 4364)' + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='vrfs', + blank=True, + null=True + ) + enforce_unique = models.BooleanField( + default=True, + verbose_name='Enforce unique space', + help_text='Prevent duplicate prefixes/IP addresses within this VRF' + ) + description = models.CharField( + max_length=200, + blank=True + ) + import_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='importing_vrfs', + blank=True + ) + export_targets = models.ManyToManyField( + to='ipam.RouteTarget', + related_name='exporting_vrfs', + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] + clone_fields = [ + 'tenant', 'enforce_unique', 'description', + ] + + class Meta: + ordering = ('name', 'rd', 'pk') # (name, rd) may be non-unique + verbose_name = 'VRF' + verbose_name_plural = 'VRFs' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipam:vrf', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.rd, + self.tenant.name if self.tenant else None, + self.enforce_unique, + self.description, + ) + + @property + def display_name(self): + if self.rd: + return f'{self.name} ({self.rd})' + return self.name + + +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class RouteTarget(PrimaryModel): + """ + A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. + """ + name = models.CharField( + max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4) + unique=True, + help_text='Route target value (formatted in accordance with RFC 4360)' + ) + description = models.CharField( + max_length=200, + blank=True + ) + tenant = models.ForeignKey( + to='tenancy.Tenant', + on_delete=models.PROTECT, + related_name='route_targets', + blank=True, + null=True + ) + + objects = RestrictedQuerySet.as_manager() + + csv_headers = ['name', 'description', 'tenant'] + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:routetarget', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.description, + self.tenant.name if self.tenant else None, + ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 02196198c..f41c7cfff 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,28 +3,22 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from dcim.models import Interface -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, + BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, + ToggleColumn, UtilizationColumn, ) from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF AVAILABLE_LABEL = mark_safe('Available') -UTILIZATION_GRAPH = """ -{% load helpers %} -{% if record.pk %}{% utilization_graph record.get_utilization %}{% else %}—{% endif %} -""" - PREFIX_LINK = """ -{% if record.children %} - -{% else %} - -{% endif %} - {{ record.prefix }} - +{% load helpers %} +{% for i in record.parents|as_range %} + +{% endfor %} +{{ record.prefix }} """ PREFIX_ROLE_LINK = """ @@ -39,7 +33,7 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% else %} {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available {% endif %} @@ -52,8 +46,8 @@ IPADDRESS_ASSIGN_LINK = """ VRF_LINK = """ {% if record.vrf %} {{ record.vrf }} -{% elif prefix.vrf %} - {{ prefix.vrf }} +{% elif object.vrf %} + {{ object.vrf }} {% else %} Global {% endif %} @@ -96,7 +90,7 @@ VLAN_ROLE_LINK = """ VLANGROUP_ADD_VLAN = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} - + {% endif %} @@ -104,23 +98,13 @@ VLANGROUP_ADD_VLAN = """ """ VLAN_MEMBER_TAGGED = """ -{% if record.untagged_vlan_id == vlan.pk %} +{% if record.untagged_vlan_id == object.pk %} {% else %} {% endif %} """ -TENANT_LINK = """ -{% if record.tenant %} - {{ record.tenant }} -{% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* -{% else %} - — -{% endif %} -""" - # # VRFs @@ -132,9 +116,7 @@ class VRFTable(BaseTable): rd = tables.Column( verbose_name='RD' ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() enforce_unique = BooleanColumn( verbose_name='Unique' ) @@ -165,9 +147,7 @@ class VRFTable(BaseTable): class RouteTargetTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() tags = TagColumn( url_name='ipam:vrf_list' ) @@ -193,7 +173,7 @@ class RIRTable(BaseTable): url_params={'rir': 'slug'}, verbose_name='Aggregates' ) - actions = ButtonsColumn(RIR, pk_field='slug') + actions = ButtonsColumn(RIR) class Meta(BaseTable.Meta): model = RIR @@ -210,9 +190,7 @@ class AggregateTable(BaseTable): prefix = tables.LinkColumn( verbose_name='Aggregate' ) - tenant = tables.TemplateColumn( - template_code=TENANT_LINK - ) + tenant = TenantColumn() date_added = tables.DateColumn( format="Y-m-d", verbose_name='Added' @@ -227,8 +205,8 @@ class AggregateDetailTable(AggregateTable): child_count = tables.Column( verbose_name='Prefixes' ) - utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, + utilization = UtilizationColumn( + accessor='get_utilization', orderable=False ) tags = TagColumn( @@ -246,6 +224,9 @@ class AggregateDetailTable(AggregateTable): class RoleTable(BaseTable): pk = ToggleColumn() + name = tables.Column( + linkify=True + ) prefix_count = LinkedCountColumn( viewname='ipam:prefix_list', url_params={'role': 'slug'}, @@ -256,7 +237,7 @@ class RoleTable(BaseTable): url_params={'role': 'slug'}, verbose_name='VLANs' ) - actions = ButtonsColumn(Role, pk_field='slug') + actions = ButtonsColumn(Role) class Meta(BaseTable.Meta): model = Role @@ -272,7 +253,7 @@ class PrefixTable(BaseTable): pk = ToggleColumn() prefix = tables.TemplateColumn( template_code=PREFIX_LINK, - attrs={'th': {'style': 'padding-left: 17px'}} + attrs={'td': {'class': 'text-nowrap'}} ) status = ChoiceFieldColumn( default=AVAILABLE_LABEL @@ -281,9 +262,7 @@ class PrefixTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - tenant = tables.TemplateColumn( - template_code=TENANT_LINK - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -310,13 +289,11 @@ class PrefixTable(BaseTable): class PrefixDetailTable(PrefixTable): - utilization = tables.TemplateColumn( - template_code=UTILIZATION_GRAPH, + utilization = UtilizationColumn( + accessor='get_utilization', orderable=False ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() tags = TagColumn( url_name='ipam:prefix_list' ) @@ -349,9 +326,7 @@ class IPAddressTable(BaseTable): default=AVAILABLE_LABEL ) role = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=TENANT_LINK - ) + tenant = TenantColumn() assigned_object = tables.Column( linkify=True, orderable=False, @@ -381,9 +356,7 @@ class IPAddressDetailTable(IPAddressTable): orderable=False, verbose_name='NAT (Inside)' ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() assigned = BooleanColumn( accessor='assigned_object_id', verbose_name='Assigned' @@ -430,8 +403,9 @@ class InterfaceIPAddressTable(BaseTable): verbose_name='VRF' ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=TENANT_LINK + tenant = TenantColumn() + actions = ButtonsColumn( + model=IPAddress ) class Meta(BaseTable.Meta): @@ -446,9 +420,9 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + scope_type = ContentTypeColumn() + scope = tables.Column( + linkify=True ) vlan_count = LinkedCountColumn( viewname='ipam:vlan_list', @@ -462,8 +436,8 @@ class VLANGroupTable(BaseTable): class Meta(BaseTable.Meta): model = VLANGroup - fields = ('pk', 'name', 'site', 'vlan_count', 'slug', 'description', 'actions') - default_columns = ('pk', 'name', 'site', 'vlan_count', 'description', 'actions') + fields = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'actions') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') # @@ -476,17 +450,13 @@ class VLANTable(BaseTable): template_code=VLAN_LINK, verbose_name='ID' ) - site = tables.LinkColumn( - viewname='dcim:site', - args=[Accessor('site__slug')] + site = tables.Column( + linkify=True ) - group = tables.LinkColumn( - viewname='ipam:vlangroup_vlans', - args=[Accessor('group__pk')] - ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT + group = tables.Column( + linkify=True ) + tenant = TenantColumn() status = ChoiceFieldColumn( default=AVAILABLE_LABEL ) @@ -508,9 +478,7 @@ class VLANDetailTable(VLANTable): orderable=False, verbose_name='Prefixes' ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() tags = TagColumn( url_name='ipam:vlan_list' ) @@ -568,9 +536,7 @@ class InterfaceVLANTable(BaseTable): accessor=Accessor('group__name'), verbose_name='Group' ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() status = ChoiceFieldColumn() role = tables.TemplateColumn( template_code=VLAN_ROLE_LINK diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index db98713d0..b2b4b9e8f 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -22,7 +22,7 @@ class AppTest(APITestCase): class VRFTest(APIViewTestCases.APIViewTestCase): model = VRF - brief_fields = ['display_name', 'id', 'name', 'prefix_count', 'rd', 'url'] + brief_fields = ['display', 'display_name', 'id', 'name', 'prefix_count', 'rd', 'url'] create_data = [ { 'name': 'VRF 4', @@ -54,7 +54,7 @@ class VRFTest(APIViewTestCases.APIViewTestCase): class RouteTargetTest(APIViewTestCases.APIViewTestCase): model = RouteTarget - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'name': '65000:1004', @@ -83,7 +83,7 @@ class RouteTargetTest(APIViewTestCases.APIViewTestCase): class RIRTest(APIViewTestCases.APIViewTestCase): model = RIR - brief_fields = ['aggregate_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['aggregate_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'RIR 4', @@ -115,7 +115,7 @@ class RIRTest(APIViewTestCases.APIViewTestCase): class AggregateTest(APIViewTestCases.APIViewTestCase): model = Aggregate - brief_fields = ['family', 'id', 'prefix', 'url'] + brief_fields = ['display', 'family', 'id', 'prefix', 'url'] bulk_update_data = { 'description': 'New description', } @@ -154,7 +154,7 @@ class AggregateTest(APIViewTestCases.APIViewTestCase): class RoleTest(APIViewTestCases.APIViewTestCase): model = Role - brief_fields = ['id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] + brief_fields = ['display', 'id', 'name', 'prefix_count', 'slug', 'url', 'vlan_count'] create_data = [ { 'name': 'Role 4', @@ -186,7 +186,7 @@ class RoleTest(APIViewTestCases.APIViewTestCase): class PrefixTest(APIViewTestCases.APIViewTestCase): model = Prefix - brief_fields = ['family', 'id', 'prefix', 'url'] + brief_fields = ['display', 'family', 'id', 'prefix', 'url'] create_data = [ { 'prefix': '192.168.4.0/24', @@ -360,7 +360,7 @@ class PrefixTest(APIViewTestCases.APIViewTestCase): class IPAddressTest(APIViewTestCases.APIViewTestCase): model = IPAddress - brief_fields = ['address', 'family', 'id', 'url'] + brief_fields = ['address', 'display', 'family', 'id', 'url'] create_data = [ { 'address': '192.168.0.4/24', @@ -389,7 +389,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase): class VLANGroupTest(APIViewTestCases.APIViewTestCase): model = VLANGroup - brief_fields = ['id', 'name', 'slug', 'url', 'vlan_count'] + brief_fields = ['display', 'id', 'name', 'slug', 'url', 'vlan_count'] create_data = [ { 'name': 'VLAN Group 4', @@ -421,7 +421,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase): class VLANTest(APIViewTestCases.APIViewTestCase): model = VLAN - brief_fields = ['display_name', 'id', 'name', 'url', 'vid'] + brief_fields = ['display', 'display_name', 'id', 'name', 'url', 'vid'] bulk_update_data = { 'description': 'New description', } @@ -481,7 +481,7 @@ class VLANTest(APIViewTestCases.APIViewTestCase): class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service - brief_fields = ['id', 'name', 'ports', 'protocol', 'url'] + brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] bulk_update_data = { 'description': 'New description', } diff --git a/netbox/ipam/tests/test_filters.py b/netbox/ipam/tests/test_filters.py index 2fead3d93..90af26e9b 100644 --- a/netbox/ipam/tests/test_filters.py +++ b/netbox/ipam/tests/test_filters.py @@ -1,10 +1,10 @@ from django.test import TestCase -from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site +from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Rack, Region, Site, SiteGroup from ipam.choices import * from ipam.filters import * from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF -from virtualization.models import Cluster, ClusterType, VirtualMachine, VMInterface +from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from tenancy.models import Tenant, TenantGroup @@ -343,14 +343,21 @@ class PrefixTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + 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.objects.bulk_create(sites) @@ -422,6 +429,11 @@ class PrefixTestCase(TestCase): params = {'family': '6'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) + def test_prefix(self): + prefixes = Prefix.objects.all()[:2] + params = {'prefix': [prefixes[0].prefix, prefixes[1].prefix]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_is_pool(self): params = {'is_pool': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -468,6 +480,13 @@ class PrefixTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} @@ -696,27 +715,39 @@ class VLANGroupTestCase(TestCase): @classmethod 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'), - ) - # Can't use bulk_create for models with MPTT fields - for r in regions: - r.save() + region = Region(name='Region 1', slug='region-1') + region.save() - sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), - ) - Site.objects.bulk_create(sites) + sitegroup = SiteGroup(name='Site Group 1', slug='site-group-1') + sitegroup.save() + + site = Site(name='Site 1', slug='site-1') + site.save() + + location = Location(name='Location 1', slug='location-1', site=site) + location.save() + + rack = Rack(name='Rack 1', site=site) + rack.save() + + clustertype = ClusterType(name='Cluster Type 1', slug='cluster-type-1') + clustertype.save() + + clustergroup = ClusterGroup(name='Cluster Group 1', slug='cluster-group-1') + clustergroup.save() + + cluster = Cluster(name='Cluster 1', type=clustertype) + cluster.save() vlan_groups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0], description='A'), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1], description='B'), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=sites[2], description='C'), - VLANGroup(name='VLAN Group 4', slug='vlan-group-4', site=None), + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='A'), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='B'), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='C'), + VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location, description='D'), + VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack, description='E'), + VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup, description='F'), + VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster, description='G'), + VLANGroup(name='VLAN Group 8', slug='vlan-group-8'), ) VLANGroup.objects.bulk_create(vlan_groups) @@ -737,18 +768,32 @@ class VLANGroupTestCase(TestCase): 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]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'region': [regions[0].slug, regions[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'region': Region.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_sitegroup(self): + params = {'sitegroup': SiteGroup.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_site(self): - sites = Site.objects.all()[:2] - params = {'site_id': [sites[0].pk, sites[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'site': [sites[0].slug, sites[1].slug]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'site': Site.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_location(self): + params = {'location': Location.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_rack(self): + params = {'rack': Rack.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_clustergroup(self): + params = {'clustergroup': ClusterGroup.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_cluster(self): + params = {'cluster': Cluster.objects.first().pk} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) class VLANTestCase(TestCase): @@ -763,14 +808,21 @@ class VLANTestCase(TestCase): Region(name='Test Region 2', slug='test-region-2'), Region(name='Test Region 3', slug='test-region-3'), ) - # Can't use bulk_create for models with MPTT fields for r in regions: r.save() + site_groups = ( + SiteGroup(name='Site Group 1', slug='site-group-1'), + SiteGroup(name='Site Group 2', slug='site-group-2'), + SiteGroup(name='Site Group 3', slug='site-group-3'), + ) + for site_group in site_groups: + site_group.save() + sites = ( - Site(name='Test Site 1', slug='test-site-1', region=regions[0]), - Site(name='Test Site 2', slug='test-site-2', region=regions[1]), - Site(name='Test Site 3', slug='test-site-3', region=regions[2]), + 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.objects.bulk_create(sites) @@ -782,9 +834,9 @@ class VLANTestCase(TestCase): Role.objects.bulk_create(roles) groups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=None), + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=None), ) VLANGroup.objects.bulk_create(groups) @@ -832,6 +884,13 @@ class VLANTestCase(TestCase): params = {'region': [regions[0].slug, regions[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site_group(self): + site_groups = SiteGroup.objects.all()[:2] + params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'site_group': [site_groups[0].slug, site_groups[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_site(self): sites = Site.objects.all()[:2] params = {'site_id': [sites[0].pk, sites[1].pk]} diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 6091aa70e..a47862165 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -259,6 +259,18 @@ class TestIPAddress(TestCase): duplicate_ip = IPAddress(vrf=vrf, address=netaddr.IPNetwork('192.0.2.1/24')) self.assertRaises(ValidationError, duplicate_ip.clean) + @override_settings(ENFORCE_GLOBAL_UNIQUE=True) + def test_duplicate_nonunique_nonrole_role(self): + IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24')) + duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) + self.assertRaises(ValidationError, duplicate_ip.clean) + + @override_settings(ENFORCE_GLOBAL_UNIQUE=True) + def test_duplicate_nonunique_role_nonrole(self): + IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) + duplicate_ip = IPAddress(address=netaddr.IPNetwork('192.0.2.1/24')) + self.assertRaises(ValidationError, duplicate_ip.clean) + @override_settings(ENFORCE_GLOBAL_UNIQUE=True) def test_duplicate_nonunique_role(self): IPAddress.objects.create(address=netaddr.IPNetwork('192.0.2.1/24'), role=IPAddressRoleChoices.ROLE_VIP) diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index db96bb896..387bdd2b5 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -118,6 +118,10 @@ class RIRTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "RIR 6,rir-6,Sixth RIR", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Aggregate @@ -187,6 +191,10 @@ class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Role 6,role-6,1000", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Prefix @@ -210,6 +218,7 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): Role(name='Role 1', slug='role-1'), Role(name='Role 2', slug='role-2'), ) + Role.objects.bulk_create(roles) Prefix.objects.bulk_create([ Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), @@ -305,18 +314,22 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) VLANGroup.objects.bulk_create([ - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=site), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=site), - VLANGroup(name='VLAN Group 3', slug='vlan-group-3', site=site), + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[0]), + VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=sites[0]), ]) cls.form_data = { 'name': 'VLAN Group X', 'slug': 'vlan-group-x', - 'site': site.pk, + 'site': sites[1].pk, 'description': 'A new VLAN group', } @@ -327,6 +340,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "VLAN Group 6,vlan-group-6,Sixth VLAN group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = VLAN @@ -341,8 +358,8 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): Site.objects.bulk_create(sites) vlangroups = ( - VLANGroup(name='VLAN Group 1', slug='vlan-group-1', site=sites[0]), - VLANGroup(name='VLAN Group 2', slug='vlan-group-2', site=sites[1]), + VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=sites[0]), + VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sites[1]), ) VLANGroup.objects.bulk_create(vlangroups) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 9b0dc581b..262537b8b 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView from . import views from .models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF @@ -17,6 +17,7 @@ urlpatterns = [ path('vrfs//edit/', views.VRFEditView.as_view(), name='vrf_edit'), path('vrfs//delete/', views.VRFDeleteView.as_view(), name='vrf_delete'), path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='vrf_changelog', kwargs={'model': VRF}), + path('vrfs//journal/', ObjectJournalView.as_view(), name='vrf_journal', kwargs={'model': VRF}), # Route targets path('route-targets/', views.RouteTargetListView.as_view(), name='routetarget_list'), @@ -28,15 +29,18 @@ urlpatterns = [ path('route-targets//edit/', views.RouteTargetEditView.as_view(), name='routetarget_edit'), path('route-targets//delete/', views.RouteTargetDeleteView.as_view(), name='routetarget_delete'), path('route-targets//changelog/', ObjectChangeLogView.as_view(), name='routetarget_changelog', kwargs={'model': RouteTarget}), + path('route-targets//journal/', ObjectJournalView.as_view(), name='routetarget_journal', kwargs={'model': RouteTarget}), # RIRs path('rirs/', views.RIRListView.as_view(), name='rir_list'), path('rirs/add/', views.RIREditView.as_view(), name='rir_add'), path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'), + path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'), path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'), - path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), - path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), - path('vrfs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), + path('rirs//', views.RIRView.as_view(), name='rir'), + path('rirs//edit/', views.RIREditView.as_view(), name='rir_edit'), + path('rirs//delete/', views.RIRDeleteView.as_view(), name='rir_delete'), + path('rirs//changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}), # Aggregates path('aggregates/', views.AggregateListView.as_view(), name='aggregate_list'), @@ -48,15 +52,18 @@ urlpatterns = [ path('aggregates//edit/', views.AggregateEditView.as_view(), name='aggregate_edit'), path('aggregates//delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'), path('aggregates//changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}), + path('aggregates//journal/', ObjectJournalView.as_view(), name='aggregate_journal', kwargs={'model': Aggregate}), # Roles path('roles/', views.RoleListView.as_view(), name='role_list'), path('roles/add/', views.RoleEditView.as_view(), name='role_add'), path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'), + path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'), path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'), - path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), - path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), - path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), + path('roles//', views.RoleView.as_view(), name='role'), + path('roles//edit/', views.RoleEditView.as_view(), name='role_edit'), + path('roles//delete/', views.RoleDeleteView.as_view(), name='role_delete'), + path('roles//changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}), # Prefixes path('prefixes/', views.PrefixListView.as_view(), name='prefix_list'), @@ -68,6 +75,7 @@ urlpatterns = [ path('prefixes//edit/', views.PrefixEditView.as_view(), name='prefix_edit'), path('prefixes//delete/', views.PrefixDeleteView.as_view(), name='prefix_delete'), path('prefixes//changelog/', ObjectChangeLogView.as_view(), name='prefix_changelog', kwargs={'model': Prefix}), + path('prefixes//journal/', ObjectJournalView.as_view(), name='prefix_journal', kwargs={'model': Prefix}), path('prefixes//prefixes/', views.PrefixPrefixesView.as_view(), name='prefix_prefixes'), path('prefixes//ip-addresses/', views.PrefixIPAddressesView.as_view(), name='prefix_ipaddresses'), @@ -79,6 +87,7 @@ urlpatterns = [ path('ip-addresses/edit/', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), path('ip-addresses/delete/', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), path('ip-addresses//changelog/', ObjectChangeLogView.as_view(), name='ipaddress_changelog', kwargs={'model': IPAddress}), + path('ip-addresses//journal/', ObjectJournalView.as_view(), name='ipaddress_journal', kwargs={'model': IPAddress}), path('ip-addresses/assign/', views.IPAddressAssignView.as_view(), name='ipaddress_assign'), path('ip-addresses//', views.IPAddressView.as_view(), name='ipaddress'), path('ip-addresses//edit/', views.IPAddressEditView.as_view(), name='ipaddress_edit'), @@ -88,10 +97,11 @@ urlpatterns = [ path('vlan-groups/', views.VLANGroupListView.as_view(), name='vlangroup_list'), path('vlan-groups/add/', views.VLANGroupEditView.as_view(), name='vlangroup_add'), path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'), + path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'), path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'), + path('vlan-groups//', views.VLANGroupView.as_view(), name='vlangroup'), path('vlan-groups//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), - path('vlan-groups//vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'), path('vlan-groups//changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}), # VLANs @@ -106,6 +116,7 @@ urlpatterns = [ path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), + path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), # Services path('services/', views.ServiceListView.as_view(), name='service_list'), @@ -116,5 +127,6 @@ urlpatterns = [ path('services//edit/', views.ServiceEditView.as_view(), name='service_edit'), path('services//delete/', views.ServiceDeleteView.as_view(), name='service_delete'), path('services//changelog/', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), + path('services//journal/', ObjectJournalView.as_view(), name='service_journal', kwargs={'model': Service}), ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1cbac27f6..0339aff07 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -1,12 +1,11 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render -from django_tables2 import RequestConfig from dcim.models import Device, Interface from netbox.views import generic -from utilities.paginator import EnhancedPaginator, get_paginate_count -from utilities.utils import get_subquery +from utilities.tables import paginate_table +from utilities.utils import count_related from virtualization.models import VirtualMachine, VMInterface from . import filters, forms, tables from .constants import * @@ -30,6 +29,7 @@ class VRFView(generic.ObjectView): def get_extra_context(self, request, instance): prefix_count = Prefix.objects.restrict(request.user, 'view').filter(vrf=instance).count() + ipaddress_count = IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance).count() import_targets_table = tables.RouteTargetTable( instance.import_targets.prefetch_related('tenant'), @@ -42,6 +42,7 @@ class VRFView(generic.ObjectView): return { 'prefix_count': prefix_count, + 'ipaddress_count': ipaddress_count, 'import_targets_table': import_targets_table, 'export_targets_table': export_targets_table, } @@ -50,7 +51,6 @@ class VRFView(generic.ObjectView): class VRFEditView(generic.ObjectEditView): queryset = VRF.objects.all() model_form = forms.VRFForm - template_name = 'ipam/vrf_edit.html' class VRFDeleteView(generic.ObjectDeleteView): @@ -140,7 +140,7 @@ class RouteTargetBulkDeleteView(generic.BulkDeleteView): class RIRListView(generic.ObjectListView): queryset = RIR.objects.annotate( - aggregate_count=get_subquery(Aggregate, 'rir') + aggregate_count=count_related(Aggregate, 'rir') ) filterset = filters.RIRFilterSet filterset_form = forms.RIRFilterForm @@ -148,6 +148,23 @@ class RIRListView(generic.ObjectListView): template_name = 'ipam/rir_list.html' +class RIRView(generic.ObjectView): + queryset = RIR.objects.all() + + def get_extra_context(self, request, instance): + aggregates = Aggregate.objects.restrict(request.user, 'view').filter( + rir=instance + ) + + aggregates_table = tables.AggregateTable(aggregates) + aggregates_table.columns.hide('rir') + paginate_table(aggregates_table, request) + + return { + 'aggregates_table': aggregates_table, + } + + class RIREditView(generic.ObjectEditView): queryset = RIR.objects.all() model_form = forms.RIRForm @@ -163,9 +180,18 @@ class RIRBulkImportView(generic.BulkImportView): table = tables.RIRTable +class RIRBulkEditView(generic.BulkEditView): + queryset = RIR.objects.annotate( + aggregate_count=count_related(Aggregate, 'rir') + ) + filterset = filters.RIRFilterSet + table = tables.RIRTable + form = forms.RIRBulkEditForm + + class RIRBulkDeleteView(generic.BulkDeleteView): queryset = RIR.objects.annotate( - aggregate_count=get_subquery(Aggregate, 'rir') + aggregate_count=count_related(Aggregate, 'rir') ) filterset = filters.RIRFilterSet table = tables.RIRTable @@ -221,12 +247,7 @@ class AggregateView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -245,7 +266,6 @@ class AggregateView(generic.ObjectView): class AggregateEditView(generic.ObjectEditView): queryset = Aggregate.objects.all() model_form = forms.AggregateForm - template_name = 'ipam/aggregate_edit.html' class AggregateDeleteView(generic.ObjectDeleteView): @@ -277,12 +297,29 @@ class AggregateBulkDeleteView(generic.BulkDeleteView): class RoleListView(generic.ObjectListView): queryset = Role.objects.annotate( - prefix_count=get_subquery(Prefix, 'role'), - vlan_count=get_subquery(VLAN, 'role') + prefix_count=count_related(Prefix, 'role'), + vlan_count=count_related(VLAN, 'role') ) table = tables.RoleTable +class RoleView(generic.ObjectView): + queryset = Role.objects.all() + + def get_extra_context(self, request, instance): + prefixes = Prefix.objects.restrict(request.user, 'view').filter( + role=instance + ) + + prefixes_table = tables.PrefixTable(prefixes) + prefixes_table.columns.hide('role') + paginate_table(prefixes_table, request) + + return { + 'prefixes_table': prefixes_table, + } + + class RoleEditView(generic.ObjectEditView): queryset = Role.objects.all() model_form = forms.RoleForm @@ -298,6 +335,13 @@ class RoleBulkImportView(generic.BulkImportView): table = tables.RoleTable +class RoleBulkEditView(generic.BulkEditView): + queryset = Role.objects.all() + filterset = filters.RoleFilterSet + table = tables.RoleTable + form = forms.RoleBulkEditForm + + class RoleBulkDeleteView(generic.BulkDeleteView): queryset = Role.objects.all() table = tables.RoleTable @@ -357,7 +401,7 @@ class PrefixView(generic.ObjectView): class PrefixPrefixesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_prefixes.html' + template_name = 'ipam/prefix/prefixes.html' def get_extra_context(self, request, instance): # Child prefixes table @@ -372,12 +416,7 @@ class PrefixPrefixesView(generic.ObjectView): prefix_table = tables.PrefixDetailTable(child_prefixes) if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(prefix_table) + paginate_table(prefix_table, request) # Compile permissions list for rendering the object table permissions = { @@ -400,7 +439,7 @@ class PrefixPrefixesView(generic.ObjectView): class PrefixIPAddressesView(generic.ObjectView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_ipaddresses.html' + template_name = 'ipam/prefix/ip_addresses.html' def get_extra_context(self, request, instance): # Find all IPAddresses belonging to this Prefix @@ -415,12 +454,7 @@ class PrefixIPAddressesView(generic.ObjectView): ip_table = tables.IPAddressTable(ipaddresses) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): ip_table.columns.show('pk') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(ip_table) + paginate_table(ip_table, request) # Compile permissions list for rendering the object table permissions = { @@ -444,7 +478,6 @@ class PrefixIPAddressesView(generic.ObjectView): class PrefixEditView(generic.ObjectEditView): queryset = Prefix.objects.all() model_form = forms.PrefixForm - template_name = 'ipam/prefix_edit.html' class PrefixDeleteView(generic.ObjectDeleteView): @@ -519,12 +552,6 @@ class IPAddressView(generic.ObjectView): ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(related_ips_table) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, @@ -632,17 +659,41 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView): # class VLANGroupListView(generic.ObjectListView): - queryset = VLANGroup.objects.prefetch_related('site').annotate( - vlan_count=get_subquery(VLAN, 'group') + queryset = VLANGroup.objects.annotate( + vlan_count=count_related(VLAN, 'group') ) filterset = filters.VLANGroupFilterSet filterset_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable +class VLANGroupView(generic.ObjectView): + queryset = VLANGroup.objects.all() + + 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)) + ) + vlans_count = vlans.count() + vlans = add_available_vlans(instance, vlans) + + vlans_table = tables.VLANDetailTable(vlans) + if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): + vlans_table.columns.show('pk') + vlans_table.columns.hide('site') + vlans_table.columns.hide('group') + paginate_table(vlans_table, request) + + return { + 'vlans_count': vlans_count, + 'vlans_table': vlans_table, + } + + class VLANGroupEditView(generic.ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm + template_name = 'ipam/vlangroup_edit.html' class VLANGroupDeleteView(generic.ObjectDeleteView): @@ -655,49 +706,21 @@ class VLANGroupBulkImportView(generic.BulkImportView): table = tables.VLANGroupTable -class VLANGroupBulkDeleteView(generic.BulkDeleteView): - queryset = VLANGroup.objects.prefetch_related('site').annotate( - vlan_count=get_subquery(VLAN, 'group') +class VLANGroupBulkEditView(generic.BulkEditView): + queryset = VLANGroup.objects.annotate( + vlan_count=count_related(VLAN, 'group') ) filterset = filters.VLANGroupFilterSet table = tables.VLANGroupTable + form = forms.VLANGroupBulkEditForm -class VLANGroupVLANsView(generic.ObjectView): - queryset = VLANGroup.objects.all() - template_name = 'ipam/vlangroup_vlans.html' - - 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)) - ) - vlans = add_available_vlans(instance, vlans) - - vlan_table = tables.VLANDetailTable(vlans) - if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): - vlan_table.columns.show('pk') - vlan_table.columns.hide('site') - vlan_table.columns.hide('group') - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request), - } - RequestConfig(request, paginate).configure(vlan_table) - - # Compile permissions list for rendering the object table - permissions = { - 'add': request.user.has_perm('ipam.add_vlan'), - 'change': request.user.has_perm('ipam.change_vlan'), - 'delete': request.user.has_perm('ipam.delete_vlan'), - } - - return { - 'first_available_vlan': instance.get_next_available_vid(), - 'bulk_querystring': f'group_id={instance.pk}', - 'vlan_table': vlan_table, - 'permissions': permissions, - } +class VLANGroupBulkDeleteView(generic.BulkDeleteView): + queryset = VLANGroup.objects.annotate( + vlan_count=count_related(VLAN, 'group') + ) + filterset = filters.VLANGroupFilterSet + table = tables.VLANGroupTable # @@ -728,17 +751,12 @@ class VLANView(generic.ObjectView): class VLANInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_interfaces.html' + template_name = 'ipam/vlan/interfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_interfaces().prefetch_related('device') members_table = tables.VLANDevicesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, @@ -748,17 +766,12 @@ class VLANInterfacesView(generic.ObjectView): class VLANVMInterfacesView(generic.ObjectView): queryset = VLAN.objects.all() - template_name = 'ipam/vlan_vminterfaces.html' + template_name = 'ipam/vlan/vminterfaces.html' def get_extra_context(self, request, instance): interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine') members_table = tables.VLANVirtualMachinesTable(interfaces) - - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(members_table) + paginate_table(members_table, request) return { 'members_table': members_table, @@ -804,7 +817,7 @@ class ServiceListView(generic.ObjectListView): filterset = filters.ServiceFilterSet filterset_form = forms.ServiceFilterForm table = tables.ServiceTable - action_buttons = ('export',) + action_buttons = ('import', 'export') class ServiceView(generic.ObjectView): diff --git a/netbox/netbox/api/__init__.py b/netbox/netbox/api/__init__.py index 78ab7431d..334ee09f7 100644 --- a/netbox/netbox/api/__init__.py +++ b/netbox/netbox/api/__init__.py @@ -1,4 +1,4 @@ -from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField, TimeZoneField +from .fields import ChoiceField, ContentTypeField, SerializedPKRelatedField from .routers import OrderedDefaultRouter from .serializers import BulkOperationSerializer, ValidatedModelSerializer, WritableNestedSerializer @@ -9,7 +9,6 @@ __all__ = ( 'ContentTypeField', 'OrderedDefaultRouter', 'SerializedPKRelatedField', - 'TimeZoneField', 'ValidatedModelSerializer', 'WritableNestedSerializer', ) diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index fb3eef76f..d73cbcac2 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -104,21 +104,6 @@ class ContentTypeField(RelatedField): return f"{obj.app_label}.{obj.model}" -class TimeZoneField(serializers.Field): - """ - Represent a pytz time zone. - """ - def to_representation(self, obj): - return obj.zone if obj else None - - def to_internal_value(self, data): - if not data: - return "" - if data not in pytz.common_timezones: - raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data)) - return pytz.timezone(data) - - 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 1d0397e4d..bc4ecf871 100644 --- a/netbox/netbox/api/metadata.py +++ b/netbox/netbox/api/metadata.py @@ -1,10 +1,45 @@ +from django.core.exceptions import PermissionDenied +from django.http import Http404 from django.utils.encoding import force_str +from rest_framework import exceptions from rest_framework.metadata import SimpleMetadata +from rest_framework.request import clone_request from netbox.api import ContentTypeField -class ContentTypeMetadata(SimpleMetadata): +class BulkOperationMetadata(SimpleMetadata): + + def determine_actions(self, request, view): + """ + Replace the stock determine_actions() method to assess object permissions only + when viewing a specific object. This is necessary to support OPTIONS requests + with bulk update in place (see #5470). + """ + actions = {} + for method in {'PUT', 'POST'} & set(view.allowed_methods): + view.request = clone_request(request, method) + try: + # Test global permissions + if hasattr(view, 'check_permissions'): + view.check_permissions(view.request) + # Test object permissions (if viewing a specific object) + if method == 'PUT' and view.lookup_url_kwarg and hasattr(view, 'get_object'): + view.get_object() + except (exceptions.APIException, PermissionDenied, Http404): + pass + else: + # If user has appropriate permissions for the view, include + # appropriate metadata about the fields that should be supplied. + serializer = view.get_serializer() + actions[method] = self.get_serializer_info(serializer) + finally: + view.request = request + + return actions + + +class ContentTypeMetadata(BulkOperationMetadata): def get_field_info(self, field): field_info = super().get_field_info(field) diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 3a8641efc..d17751e25 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -1,12 +1,23 @@ +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldError, MultipleObjectsReturned, ObjectDoesNotExist from django.db.models import ManyToManyField from rest_framework import serializers from rest_framework.exceptions import ValidationError +from rest_framework.fields import CreateOnlyDefault +from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues +from extras.models import CustomField, Tag from utilities.utils import dict_to_filter_params -class ValidatedModelSerializer(serializers.ModelSerializer): +class BaseModelSerializer(serializers.ModelSerializer): + display = serializers.SerializerMethodField(read_only=True) + + def get_display(self, obj): + return str(obj) + + +class ValidatedModelSerializer(BaseModelSerializer): """ Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144) @@ -35,11 +46,45 @@ class ValidatedModelSerializer(serializers.ModelSerializer): return data -class WritableNestedSerializer(serializers.ModelSerializer): +class CustomFieldModelSerializer(ValidatedModelSerializer): + """ + Extends ModelSerializer to render any CustomFields and their values associated with an object. + """ + custom_fields = CustomFieldsDataField( + source='custom_field_data', + default=CreateOnlyDefault(CustomFieldDefaultValues()) + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance is not None: + + # Retrieve the set of CustomFields which apply to this type of object + content_type = ContentType.objects.get_for_model(self.Meta.model) + fields = CustomField.objects.filter(content_types=content_type) + + # Populate CustomFieldValues for each instance from database + if type(self.instance) in (list, tuple): + for obj in self.instance: + self._populate_custom_fields(obj, fields) + else: + self._populate_custom_fields(self.instance, fields) + + def _populate_custom_fields(self, instance, custom_fields): + instance.custom_fields = {} + for field in custom_fields: + instance.custom_fields[field.name] = instance.cf.get(field.name) + + +# +# Nested serializers +# + +class WritableNestedSerializer(BaseModelSerializer): """ Returns a nested representation of an object on read, but accepts only a primary key on write. """ - def to_internal_value(self, data): if data is None: @@ -85,5 +130,71 @@ class WritableNestedSerializer(serializers.ModelSerializer): ) +# +# Nested tags serialization +# + +# Declared here for use by PrimaryModelSerializer, but should be imported from extras.api.nested_serializers +class NestedTagSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + + class Meta: + model = Tag + fields = ['id', 'url', 'display', 'name', 'slug', 'color'] + + +# +# Base model serializers +# + +class OrganizationalModelSerializer(CustomFieldModelSerializer): + """ + Adds support for custom fields. + """ + pass + + +class PrimaryModelSerializer(CustomFieldModelSerializer): + """ + Adds support for custom fields and tags. + """ + tags = NestedTagSerializer(many=True, required=False) + + def create(self, validated_data): + tags = validated_data.pop('tags', None) + instance = super().create(validated_data) + + if tags is not None: + return self._save_tags(instance, tags) + return instance + + def update(self, instance, validated_data): + tags = validated_data.pop('tags', None) + + # Cache tags on instance for change logging + instance._tags = tags or [] + + instance = super().update(instance, validated_data) + + if tags is not None: + return self._save_tags(instance, tags) + return instance + + def _save_tags(self, instance, tags): + if tags: + instance.tags.set(*[t.name for t in tags]) + else: + instance.tags.clear() + + return instance + + +class NestedGroupModelSerializer(CustomFieldModelSerializer): + """ + Extends OrganizationalModelSerializer to include MPTT support. + """ + _depth = serializers.IntegerField(source='level', read_only=True) + + class BulkOperationSerializer(serializers.Serializer): id = serializers.IntegerField() diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 881effd71..585b75686 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -9,11 +9,11 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied from django.db import transaction from django.db.models import ProtectedError from django_rq.queues import get_connection -from rest_framework import mixins, status +from rest_framework import status from rest_framework.response import Response from rest_framework.reverse import reverse from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import ModelViewSet as ModelViewSet_ from rq.worker import Worker from netbox.api import BulkOperationSerializer @@ -76,6 +76,8 @@ class BulkUpdateModelMixin: data_list = [] for obj in objects: data = update_data.get(obj.id) + if hasattr(obj, 'snapshot'): + obj.snapshot() serializer = self.get_serializer(obj, data=data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) @@ -113,6 +115,8 @@ class BulkDestroyModelMixin: def perform_bulk_destroy(self, objects): with transaction.atomic(): for obj in objects: + if hasattr(obj, 'snapshot'): + obj.snapshot() self.perform_destroy(obj) @@ -120,17 +124,23 @@ class BulkDestroyModelMixin: # Viewsets # -class ModelViewSet(mixins.CreateModelMixin, - mixins.RetrieveModelMixin, - mixins.UpdateModelMixin, - mixins.DestroyModelMixin, - mixins.ListModelMixin, - BulkUpdateModelMixin, - BulkDestroyModelMixin, - GenericViewSet): +class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): """ - Accept either a single object or a list of objects to create. + Extend DRF's ModelViewSet to support bulk update and delete functions. """ + brief = False + brief_prefetch_fields = [] + + def get_object_with_snapshot(self): + """ + Save a pre-change snapshot of the object immediately after retrieving it. This snapshot will be used to + record the "before" data in the changelog. + """ + obj = super().get_object() + if hasattr(obj, 'snapshot'): + obj.snapshot() + return obj + def get_serializer(self, *args, **kwargs): # If a list of objects has been provided, initialize the serializer with many=True @@ -142,22 +152,34 @@ class ModelViewSet(mixins.CreateModelMixin, def get_serializer_class(self): logger = logging.getLogger('netbox.api.views.ModelViewSet') - # If 'brief' has been passed as a query param, find and return the nested serializer for this model, if one - # exists - request = self.get_serializer_context()['request'] - if request.query_params.get('brief'): + # If using 'brief' mode, find and return the nested serializer for this model, if one exists + if self.brief: logger.debug("Request is for 'brief' format; initializing nested serializer") try: serializer = get_serializer_for_model(self.queryset.model, prefix='Nested') logger.debug(f"Using serializer {serializer}") return serializer except SerializerNotFound: - pass + logger.debug(f"Nested serializer for {self.queryset.model} not found!") # Fall back to the hard-coded serializer class logger.debug(f"Using serializer {self.serializer_class}") return self.serializer_class + def get_queryset(self): + # If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any) + if self.brief: + return super().get_queryset().prefetch_related(None).prefetch_related(*self.brief_prefetch_fields) + + return super().get_queryset() + + def initialize_request(self, request, *args, **kwargs): + # Check if brief=True has been passed + if request.method == 'GET' and request.GET.get('brief'): + self.brief = True + + return super().initialize_request(request, *args, **kwargs) + def initial(self, request, *args, **kwargs): super().initial(request, *args, **kwargs) @@ -213,6 +235,11 @@ class ModelViewSet(mixins.CreateModelMixin, except ObjectDoesNotExist: raise PermissionDenied() + def update(self, request, *args, **kwargs): + # Hotwire get_object() to ensure we save a pre-change snapshot + self.get_object = self.get_object_with_snapshot + return super().update(request, *args, **kwargs) + def perform_update(self, serializer): model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') @@ -226,6 +253,11 @@ class ModelViewSet(mixins.CreateModelMixin, except ObjectDoesNotExist: raise PermissionDenied() + def destroy(self, request, *args, **kwargs): + # Hotwire get_object() to ensure we save a pre-change snapshot + self.get_object = self.get_object_with_snapshot + return super().destroy(request, *args, **kwargs) + def perform_destroy(self, instance): model = self.queryset.model logger = logging.getLogger('netbox.api.views.ModelViewSet') diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 51c73bccc..0dadb55bc 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -79,7 +79,7 @@ BANNER_BOTTOM = '' # Text to include on the login page above the login form. HTML is allowed. BANNER_LOGIN = '' -# Base URL path if accessing NetBox within a directory. For example, if installed at http://example.com/netbox/, set: +# Base URL path if accessing NetBox within a directory. For example, if installed at https://example.com/netbox/, set: # BASE_PATH = 'netbox/' BASE_PATH = '' @@ -154,6 +154,9 @@ LOGIN_TIMEOUT = None # Setting this to True will display a "maintenance mode" banner at the top of every page. MAINTENANCE_MODE = False +# The URL to use when mapping physical addresses or GPS coordinates +MAPS_URL = 'https://maps.google.com/?q=' + # An API consumer can request an arbitrary number of objects =by appending the "limit" parameter to the URL (e.g. # "?limit=1000"). This setting defines the maximum limit. Setting it to 0 or None will allow an API consumer to request # all objects by specifying "?limit=0". @@ -183,7 +186,7 @@ NAPALM_PASSWORD = '' # NAPALM timeout (in seconds). (Default: 30) NAPALM_TIMEOUT = 30 -# NAPALM optional arguments (see http://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must +# NAPALM optional arguments (see https://napalm.readthedocs.io/en/latest/support/#optional-arguments). Arguments must # be provided as a dictionary. NAPALM_ARGS = {} diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index a074bde4e..797a11965 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,17 +1,15 @@ from collections import OrderedDict -from django.db.models import Count - -from circuits.filters import CircuitFilterSet, ProviderFilterSet -from circuits.models import Circuit, Provider -from circuits.tables import CircuitTable, ProviderTable +from circuits.filters import CircuitFilterSet, CloudFilterSet, ProviderFilterSet +from circuits.models import Circuit, Cloud, Provider +from circuits.tables import CircuitTable, CloudTable, ProviderTable from dcim.filters import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackGroupFilterSet, + CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet, ) -from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, RackGroup, Site, VirtualChassis +from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackGroupTable, SiteTable, + CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable, VirtualChassisTable, ) from ipam.filters import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet @@ -23,7 +21,7 @@ from secrets.tables import SecretTable from tenancy.filters import TenantFilterSet from tenancy.models import Tenant from tenancy.tables import TenantTable -from utilities.utils import get_subquery +from utilities.utils import count_related from virtualization.filters import ClusterFilterSet, VirtualMachineFilterSet from virtualization.models import Cluster, VirtualMachine from virtualization.tables import ClusterTable, VirtualMachineDetailTable @@ -33,7 +31,7 @@ SEARCH_TYPES = OrderedDict(( # Circuits ('provider', { 'queryset': Provider.objects.annotate( - count_circuits=get_subquery(Circuit, 'provider') + count_circuits=count_related(Circuit, 'provider') ), 'filterset': ProviderFilterSet, 'table': ProviderTable, @@ -42,11 +40,17 @@ SEARCH_TYPES = OrderedDict(( ('circuit', { 'queryset': Circuit.objects.prefetch_related( 'type', 'provider', 'tenant', 'terminations__site' - ).annotate_sites(), + ), 'filterset': CircuitFilterSet, 'table': CircuitTable, 'url': 'circuits:circuit_list', }), + ('cloud', { + 'queryset': Cloud.objects.prefetch_related('provider'), + 'filterset': CloudFilterSet, + 'table': CloudTable, + 'url': 'circuits:cloud_list', + }), # DCIM ('site', { 'queryset': Site.objects.prefetch_related('region', 'tenant'), @@ -55,26 +59,26 @@ SEARCH_TYPES = OrderedDict(( 'url': 'dcim:site_list', }), ('rack', { - 'queryset': Rack.objects.prefetch_related('site', 'group', 'tenant', 'role'), + 'queryset': Rack.objects.prefetch_related('site', 'location', 'tenant', 'role'), 'filterset': RackFilterSet, 'table': RackTable, 'url': 'dcim:rack_list', }), - ('rackgroup', { - 'queryset': RackGroup.objects.add_related_count( - RackGroup.objects.all(), + ('location', { + 'queryset': Location.objects.add_related_count( + Location.objects.all(), Rack, - 'group', + 'location', 'rack_count', cumulative=True ).prefetch_related('site'), - 'filterset': RackGroupFilterSet, - 'table': RackGroupTable, - 'url': 'dcim:rackgroup_list', + 'filterset': LocationFilterSet, + 'table': LocationTable, + 'url': 'dcim:location_list', }), ('devicetype', { 'queryset': DeviceType.objects.prefetch_related('manufacturer').annotate( - instance_count=get_subquery(Device, 'device_type') + instance_count=count_related(Device, 'device_type') ), 'filterset': DeviceTypeFilterSet, 'table': DeviceTypeTable, @@ -90,7 +94,7 @@ SEARCH_TYPES = OrderedDict(( }), ('virtualchassis', { 'queryset': VirtualChassis.objects.prefetch_related('master').annotate( - member_count=get_subquery(Device, 'virtual_chassis') + member_count=count_related(Device, 'virtual_chassis') ), 'filterset': VirtualChassisFilterSet, 'table': VirtualChassisTable, @@ -111,8 +115,8 @@ SEARCH_TYPES = OrderedDict(( # Virtualization ('cluster', { 'queryset': Cluster.objects.prefetch_related('type', 'group').annotate( - device_count=get_subquery(Device, 'cluster'), - vm_count=get_subquery(VirtualMachine, 'cluster') + device_count=count_related(Device, 'cluster'), + vm_count=count_related(VirtualMachine, 'cluster') ), 'filterset': ClusterFilterSet, 'table': ClusterTable, diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index 36198a384..cf61cc03f 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -11,7 +11,7 @@ OBJ_TYPE_CHOICES = ( ('DCIM', ( ('site', 'Sites'), ('rack', 'Racks'), - ('rackgroup', 'Rack Groups'), + ('location', 'Locations'), ('devicetype', 'Device types'), ('device', 'Devices'), ('virtualchassis', 'Virtual Chassis'), diff --git a/netbox/netbox/models.py b/netbox/netbox/models.py new file mode 100644 index 000000000..0e66ba90d --- /dev/null +++ b/netbox/netbox/models.py @@ -0,0 +1,223 @@ +import logging +from collections import OrderedDict + +from django.contrib.contenttypes.fields import GenericRelation +from django.core.serializers.json import DjangoJSONEncoder +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey +from taggit.managers import TaggableManager + +from extras.choices import ObjectChangeActionChoices +from utilities.mptt import TreeManager +from utilities.utils import serialize_object + +__all__ = ( + 'BigIDModel', + 'ChangeLoggedModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'PrimaryModel', +) + + +# +# Mixins +# + +class ChangeLoggingMixin(models.Model): + """ + Provides change logging support. + """ + created = models.DateField( + auto_now_add=True, + blank=True, + null=True + ) + last_updated = models.DateTimeField( + auto_now=True, + blank=True, + null=True + ) + + class Meta: + abstract = True + + def snapshot(self): + """ + Save a snapshot of the object's current state in preparation for modification. + """ + logger = logging.getLogger('netbox') + logger.debug(f"Taking a snapshot of {self}") + self._prechange_snapshot = serialize_object(self) + + def to_objectchange(self, action, related_object=None): + """ + Return a new ObjectChange representing a change made to this object. This will typically be called automatically + by ChangeLoggingMiddleware. + """ + from extras.models import ObjectChange + objectchange = ObjectChange( + changed_object=self, + related_object=related_object, + object_repr=str(self), + action=action + ) + 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) + + return objectchange + + +class CustomFieldsMixin(models.Model): + """ + Provides support for custom fields. + """ + custom_field_data = models.JSONField( + encoder=DjangoJSONEncoder, + blank=True, + default=dict + ) + + class Meta: + abstract = True + + @property + def cf(self): + """ + Convenience wrapper for custom field data. + """ + return self.custom_field_data + + def get_custom_fields(self): + """ + Return a dictionary of custom fields for a single object in the form {: value}. + """ + from extras.models import CustomField + + fields = CustomField.objects.get_for_model(self) + return OrderedDict([ + (field, self.custom_field_data.get(field.name)) for field in fields + ]) + + def clean(self): + super().clean() + from extras.models import CustomField + + custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + + # Validate all field values + for field_name, value in self.custom_field_data.items(): + if field_name not in custom_fields: + raise ValidationError(f"Unknown field name '{field_name}' in custom field data.") + try: + custom_fields[field_name].validate(value) + except ValidationError as e: + raise ValidationError(f"Invalid value for custom field '{field_name}': {e.message}") + + # Check for missing required values + for cf in custom_fields.values(): + if cf.required and cf.name not in self.custom_field_data: + raise ValidationError(f"Missing required custom field '{cf.name}'.") + + +# +# Base model classes + +class BigIDModel(models.Model): + """ + Abstract base model for all data objects. Ensures the use of a 64-bit PK. + """ + id = models.BigAutoField( + primary_key=True + ) + + class Meta: + abstract = True + + +class ChangeLoggedModel(ChangeLoggingMixin, BigIDModel): + """ + Base model for all objects which support change logging. + """ + class Meta: + abstract = True + + +class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + tags = TaggableManager( + through='extras.TaggedItem' + ) + + class Meta: + abstract = True + + +class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + +class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8c3c992e2..bd9b0e44c 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.10.1-dev' +VERSION = '2.11-beta1' # Hostname HOSTNAME = platform.node() @@ -29,6 +29,12 @@ if platform.python_version_tuple() < ('3', '6'): raise RuntimeError( "NetBox requires Python 3.6 or higher (current: Python {})".format(platform.python_version()) ) +# TODO: Remove in NetBox v2.12 +if platform.python_version_tuple() < ('3', '7'): + warnings.warn( + "Support for Python 3.6 will be dropped in NetBox v2.12. Please upgrade to Python 3.7 or later at your " + "earliest convenience." + ) # @@ -88,10 +94,9 @@ LOGGING = getattr(configuration, 'LOGGING', {}) LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False) LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None) MAINTENANCE_MODE = getattr(configuration, 'MAINTENANCE_MODE', False) +MAPS_URL = getattr(configuration, 'MAPS_URL', 'https://maps.google.com/?q=') MAX_PAGE_SIZE = getattr(configuration, 'MAX_PAGE_SIZE', 1000) MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/') -STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) -STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) METRICS_ENABLED = getattr(configuration, 'METRICS_ENABLED', False) NAPALM_ARGS = getattr(configuration, 'NAPALM_ARGS', {}) NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '') @@ -118,18 +123,23 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i') SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s') +STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None) +STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {}) TIME_FORMAT = getattr(configuration, 'TIME_FORMAT', 'g:i a') TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC') # Validate update repo URL and timeout if RELEASE_CHECK_URL: - try: - URLValidator(RELEASE_CHECK_URL) - except ValidationError: - raise ImproperlyConfigured( + validator = URLValidator( + message=( "RELEASE_CHECK_URL must be a valid API URL. Example: " "https://api.github.com/repos/netbox-community/netbox" ) + ) + try: + validator(RELEASE_CHECK_URL) + except ValidationError as err: + raise ImproperlyConfigured(str(err)) # Enforce a minimum cache timeout for update checks if RELEASE_CHECK_TIMEOUT < 3600: @@ -375,6 +385,8 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH) CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS +DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' + # 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 = ( @@ -391,6 +403,7 @@ if CACHING_REDIS_USING_SENTINEL: 'locations': CACHING_REDIS_SENTINELS, 'service_name': CACHING_REDIS_SENTINEL_SERVICE, 'db': CACHING_REDIS_DATABASE, + 'password': CACHING_REDIS_PASSWORD, } else: if CACHING_REDIS_SSL: @@ -425,7 +438,7 @@ CACHEOPS = { 'circuits.*': {'ops': 'all'}, 'dcim.inventoryitem': None, # MPTT models are exempt due to raw SQL 'dcim.region': None, # MPTT models are exempt due to raw SQL - 'dcim.rackgroup': None, # MPTT models are exempt due to raw SQL + 'dcim.location': None, # MPTT models are exempt due to raw SQL 'dcim.*': {'ops': 'all'}, 'ipam.*': {'ops': 'all'}, 'extras.*': {'ops': 'all'}, @@ -467,6 +480,7 @@ REST_FRAMEWORK = { 'DEFAULT_FILTER_BACKENDS': ( 'django_filters.rest_framework.DjangoFilterBackend', ), + 'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata', 'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.OptionalLimitOffsetPagination', 'DEFAULT_PERMISSION_CLASSES': ( 'netbox.api.authentication.TokenPermissions', diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index eb7b2542f..dadd97b98 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -14,7 +14,7 @@ from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe from django.views.generic import View -from django_tables2 import RequestConfig +from django_tables2.export import TableExport from extras.models import CustomField, ExportTemplate from utilities.error_handlers import handle_protectederror @@ -22,8 +22,8 @@ from utilities.exceptions import AbortTransaction from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, ) -from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model +from utilities.tables import paginate_table from utilities.utils import csv_format, normalize_querydict, prepare_cloned_fields from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin @@ -137,32 +137,35 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if self.filterset: self.queryset = self.filterset(request.GET, self.queryset).qs - # Check for export template rendering - if request.GET.get('export'): - et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET.get('export')) - try: - return et.render_to_response(self.queryset) - except Exception as e: - messages.error( - request, - "There was an error rendering the selected export template ({}): {}".format( - et.name, e + # Check for export rendering (except for table-based) + if 'export' in request.GET and request.GET['export'] != 'table': + + # An export template has been specified + if request.GET['export']: + et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + try: + return et.render_to_response(self.queryset) + except Exception as e: + messages.error( + request, + "There was an error rendering the selected export template ({}): {}".format( + et.name, e + ) ) - ) - # Check for YAML export support - elif 'export' in request.GET and hasattr(model, 'to_yaml'): - response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml') - filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - return response + # Check for YAML export support + elif hasattr(model, 'to_yaml'): + response = HttpResponse(self.queryset_to_yaml(), content_type='text/yaml') + filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response - # Fall back to built-in CSV formatting if export requested but no template specified - elif 'export' in request.GET and hasattr(model, 'to_csv'): - response = HttpResponse(self.queryset_to_csv(), content_type='text/csv') - filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - return response + # Fall back to built-in CSV formatting if export requested but no template specified + elif 'export' in request.GET and hasattr(model, 'to_csv'): + response = HttpResponse(self.queryset_to_csv(), content_type='text/csv') + filename = 'netbox_{}.csv'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response # Compile a dictionary indicating which permissions are available to the current user for this model permissions = {} @@ -175,12 +178,24 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') - # Apply the request context - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) + # Handle table-based export + if request.GET.get('export') == 'table': + exclude_columns = {'pk'} + exclude_columns.update({ + col for col in table.base_columns if col not in table.visible_columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns, + dataset_kwargs={}, + ) + return exporter.response( + filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + # Paginate the objects table + paginate_table(table, request) context = { 'content_type': content_type, @@ -218,11 +233,18 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_object(self, kwargs): # Look up an existing object by slug or PK, if provided. if 'slug' in kwargs: - return get_object_or_404(self.queryset, slug=kwargs['slug']) + obj = get_object_or_404(self.queryset, slug=kwargs['slug']) elif 'pk' in kwargs: - return get_object_or_404(self.queryset, pk=kwargs['pk']) + obj = get_object_or_404(self.queryset, pk=kwargs['pk']) # Otherwise, return a new instance. - return self.queryset.model() + else: + return self.queryset.model() + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + return obj def alter_obj(self, obj, request, url_args, url_kwargs): # Allow views to add extra info to an object before it is processed. For example, a parent object can be defined @@ -328,9 +350,15 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_object(self, kwargs): # Look up object by slug if one has been provided. Otherwise, use PK. if 'slug' in kwargs: - return get_object_or_404(self.queryset, slug=kwargs['slug']) + obj = get_object_or_404(self.queryset, slug=kwargs['slug']) else: - return get_object_or_404(self.queryset, pk=kwargs['pk']) + obj = get_object_or_404(self.queryset, pk=kwargs['pk']) + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + return obj def get(self, request, **kwargs): obj = self.get_object(kwargs) @@ -771,6 +799,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): updated_objects = [] for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']): + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + # Update standard fields. If a field is listed in _nullify, delete its value. for name in standard_fields: @@ -792,14 +824,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if form.cleaned_data[name]: getattr(obj, name).set(form.cleaned_data[name]) # Normal fields - elif form.cleaned_data[name] not in (None, ''): + elif form.cleaned_data[name] not in (None, '', []): setattr(obj, name, form.cleaned_data[name]) # Update custom fields for name in custom_fields: if name in form.nullable_fields and name in nullified_fields: - obj.custom_field_data.pop(name, None) - else: + obj.custom_field_data[name] = None + elif form.cleaned_data.get(name) not in (None, ''): obj.custom_field_data[name] = form.cleaned_data[name] obj.full_clean() @@ -898,6 +930,11 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): with transaction.atomic(): renamed_pks = [] for obj in selected_objects: + + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + find = form.cleaned_data['find'] replace = form.cleaned_data['replace'] if form.cleaned_data['use_regex']: @@ -986,14 +1023,19 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Delete objects queryset = self.queryset.filter(pk__in=pk_list) + deleted_count = queryset.count() try: - deleted_count = queryset.delete()[1][model._meta.label] + for obj in queryset: + # Take a snapshot of change-logged models + 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)) - msg = 'Deleted {} {}'.format(deleted_count, model._meta.verbose_name_plural) + msg = f"Deleted {deleted_count} {model._meta.verbose_name_plural}" logger.info(msg) messages.success(request, msg) return redirect(self.get_return_url(request)) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 681565b9e..2efa3978f 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -14,21 +14,21 @@ body { .wrapper { min-height: 100%; height: auto !important; - margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */ + margin: 0 auto -48px; /* the bottom margin is the negative value of the footer's height */ padding-bottom: 30px; } .navbar-brand { padding: 12px 15px 8px; } .footer, .push { - height: 60px; /* .push must be the same height as .footer */ + height: 48px; /* .push must be the same height as .footer */ } .footer { background-color: #f5f5f5; border-top: 1px solid #d0d0d0; } footer p { - margin: 20px 0; + margin: 12px 0; } #navbar_search { padding: 0 8px; @@ -165,6 +165,9 @@ table.attr-table td:nth-child(1) { td.min-width { width: 1%; } +td p:last-child { + margin-bottom: 0; +} /* Paginator */ div.paginator { @@ -177,6 +180,10 @@ nav ul.pagination { margin-top: 0; margin-bottom: 8px !important; } +.pagination > li > a > .mdi::before { + top: 0; + font-size: 14px; +} /* Devices */ table.component-list td.subtable { @@ -358,9 +365,6 @@ table.report th a { .text-nowrap { white-space: nowrap; } -.banner-bottom { - margin-bottom: 50px; -} .panel table { margin-bottom: 0; } diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 0feb7fc2f..f189adc1b 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -103,6 +103,12 @@ $(document).ready(function() { return data.text; } + // Speed selector + $("a.set_speed").click(function(e) { + e.preventDefault(); + $("#id_" + $(this).attr("target")).val($(this).attr("data")); + }); + // Color Picker $('.netbox-select2-color-picker').select2({ allowClear: true, diff --git a/netbox/project-static/js/interface_filtering.js b/netbox/project-static/js/interface_filtering.js index fecb156f4..51ac70198 100644 --- a/netbox/project-static/js/interface_filtering.js +++ b/netbox/project-static/js/interface_filtering.js @@ -1,11 +1,10 @@ // Inteface filtering $('input.interface-filter').on('input', function() { - var filter = new RegExp(this.value); - var interface; + let filter = new RegExp(this.value); + let interface; for (interface of $('table > tbody > tr')) { - // Slice off 'interface_' at the start of the ID - if (filter.test(interface.id.slice(10))) { + if (filter.test(interface.getAttribute('data-name'))) { // Match the toggle in case the filter now matches the interface $(interface).find('input:checkbox[name=pk]').prop('checked', $('input.toggle').prop('checked')); $(interface).show(); diff --git a/netbox/secrets/api/nested_serializers.py b/netbox/secrets/api/nested_serializers.py index aaec27c1f..ff97b9db9 100644 --- a/netbox/secrets/api/nested_serializers.py +++ b/netbox/secrets/api/nested_serializers.py @@ -14,7 +14,7 @@ class NestedSecretSerializer(WritableNestedSerializer): class Meta: model = Secret - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedSecretRoleSerializer(WritableNestedSerializer): @@ -23,4 +23,4 @@ class NestedSecretRoleSerializer(WritableNestedSerializer): class Meta: model = SecretRole - fields = ['id', 'url', 'name', 'slug', 'secret_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'secret_count'] diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py index b08b87bc5..c615d9156 100644 --- a/netbox/secrets/api/serializers.py +++ b/netbox/secrets/api/serializers.py @@ -2,11 +2,10 @@ from django.contrib.contenttypes.models import ContentType from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers -from extras.api.customfields import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from netbox.api import ContentTypeField +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from secrets.constants import SECRET_ASSIGNMENT_MODELS from secrets.models import Secret, SecretRole -from netbox.api import ContentTypeField, ValidatedModelSerializer from utilities.api import get_serializer_for_model from .nested_serializers import * @@ -15,16 +14,19 @@ from .nested_serializers import * # Secrets # -class SecretRoleSerializer(ValidatedModelSerializer): +class SecretRoleSerializer(OrganizationalModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secretrole-detail') secret_count = serializers.IntegerField(read_only=True) class Meta: model = SecretRole - fields = ['id', 'url', 'name', 'slug', 'description', 'secret_count'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'secret_count', + ] -class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class SecretSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='secrets-api:secret-detail') assigned_object_type = ContentTypeField( queryset=ContentType.objects.filter(SECRET_ASSIGNMENT_MODELS) @@ -36,8 +38,8 @@ class SecretSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Secret fields = [ - 'id', 'url', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name', 'plaintext', - 'hash', 'tags', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'assigned_object_type', 'assigned_object_id', 'assigned_object', 'role', 'name', + 'plaintext', 'hash', 'tags', 'custom_fields', 'created', 'last_updated', ] validators = [] diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py index 1153b0508..3650abd30 100644 --- a/netbox/secrets/api/views.py +++ b/netbox/secrets/api/views.py @@ -8,11 +8,12 @@ from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ViewSet +from extras.api.views import CustomFieldModelViewSet from netbox.api.views import ModelViewSet from secrets import filters from secrets.exceptions import InvalidKey from secrets.models import Secret, SecretRole, SessionKey, UserKey -from utilities.utils import get_subquery +from utilities.utils import count_related from . import serializers ERR_USERKEY_MISSING = "No UserKey found for the current user." @@ -33,9 +34,9 @@ class SecretsRootView(APIRootView): # Secret Roles # -class SecretRoleViewSet(ModelViewSet): +class SecretRoleViewSet(CustomFieldModelViewSet): queryset = SecretRole.objects.annotate( - secret_count=get_subquery(Secret, 'role') + secret_count=count_related(Secret, 'role') ) serializer_class = serializers.SecretRoleSerializer filterset_class = filters.SecretRoleFilterSet diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 8e976c8ea..b5fc85ce6 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -1,7 +1,7 @@ from Crypto.Cipher import PKCS1_OAEP from Crypto.PublicKey import RSA from django import forms -from django.contrib.contenttypes.models import ContentType +from django.utils.translation import gettext as _ from dcim.models import Device from extras.forms import ( @@ -9,7 +9,7 @@ from extras.forms import ( ) from extras.models import Tag from utilities.forms import ( - BootstrapMixin, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + BootstrapMixin, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, TagFilterField, ) from virtualization.models import VirtualMachine @@ -43,7 +43,7 @@ def validate_rsa_key(key, is_secret=True): # Secret roles # -class SecretRoleForm(BootstrapMixin, forms.ModelForm): +class SecretRoleForm(BootstrapMixin, CustomFieldModelForm): slug = SlugField() class Meta: @@ -51,7 +51,7 @@ class SecretRoleForm(BootstrapMixin, forms.ModelForm): fields = ('name', 'slug', 'description') -class SecretRoleCSVForm(CSVModelForm): +class SecretRoleCSVForm(CustomFieldModelCSVForm): slug = SlugField() class Meta: @@ -59,6 +59,20 @@ class SecretRoleCSVForm(CSVModelForm): fields = SecretRole.csv_headers +class SecretRoleBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=SecretRole.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Secrets # @@ -66,8 +80,7 @@ class SecretRoleCSVForm(CSVModelForm): class SecretForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), - required=False, - display_field='display_name' + required=False ) virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all(), @@ -122,6 +135,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm): self.fields['plaintext'].required = True def clean(self): + super().clean() if not self.cleaned_data['device'] and not self.cleaned_data['virtual_machine']: raise forms.ValidationError("Secrets must be assigned to a device or virtual machine.") @@ -220,12 +234,12 @@ class SecretFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Secret q = forms.CharField( required=False, - label='Search' + label=_('Search') ) - role = DynamicModelMultipleChoiceField( + role_id = DynamicModelMultipleChoiceField( queryset=SecretRole.objects.all(), - to_field_name='slug', - required=False + required=False, + label=_('Role') ) tag = TagFilterField(model) diff --git a/netbox/secrets/migrations/0013_standardize_models.py b/netbox/secrets/migrations/0013_standardize_models.py new file mode 100644 index 000000000..9de8dec95 --- /dev/null +++ b/netbox/secrets/migrations/0013_standardize_models.py @@ -0,0 +1,37 @@ +import django.core.serializers.json +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('secrets', '0012_standardize_name_length'), + ] + + operations = [ + migrations.AddField( + model_name='secretrole', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='secret', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='secretrole', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sessionkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userkey', + name='id', + field=models.BigAutoField(primary_key=True, serialize=False), + ), + ] diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 0fa6fd04e..dc3a65747 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -12,10 +12,9 @@ from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.encoding import force_bytes -from taggit.managers import TaggableManager -from extras.models import ChangeLoggedModel, CustomFieldModel, TaggedItem from extras.utils import extras_features +from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet from .exceptions import InvalidKey from .hashers import SecretValidationHasher @@ -31,7 +30,7 @@ __all__ = ( ) -class UserKey(models.Model): +class UserKey(BigIDModel): """ A UserKey stores a user's personal RSA (public) encryption key, which is used to generate their unique encrypted copy of the master encryption key. The encrypted instance of the master key can be decrypted only with the user's @@ -164,7 +163,7 @@ class UserKey(models.Model): self.save() -class SessionKey(models.Model): +class SessionKey(BigIDModel): """ A SessionKey stores a User's temporary key to be used for the encryption and decryption of secrets. """ @@ -234,7 +233,8 @@ class SessionKey(models.Model): return session_key -class SecretRole(ChangeLoggedModel): +@extras_features('custom_fields', 'export_templates', 'webhooks') +class SecretRole(OrganizationalModel): """ A SecretRole represents an arbitrary functional classification of Secrets. For example, a user might define roles such as "Login Credentials" or "SNMP Communities." @@ -263,7 +263,7 @@ class SecretRole(ChangeLoggedModel): return self.name def get_absolute_url(self): - return "{}?role={}".format(reverse('secrets:secret_list'), self.slug) + return reverse('secrets:secretrole', args=[self.pk]) def to_csv(self): return ( @@ -274,7 +274,7 @@ class SecretRole(ChangeLoggedModel): @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') -class Secret(ChangeLoggedModel, CustomFieldModel): +class Secret(PrimaryModel): """ A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible SHA-256 hash is stored along with the ciphertext for validation upon decryption. Each Secret is assigned to exactly @@ -310,7 +310,6 @@ class Secret(ChangeLoggedModel, CustomFieldModel): max_length=128, editable=False ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index 0d8559a2b..dd91985ec 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -16,7 +16,7 @@ class SecretRoleTable(BaseTable): url_params={'role': 'slug'}, verbose_name='Secrets' ) - actions = ButtonsColumn(SecretRole, pk_field='slug') + actions = ButtonsColumn(SecretRole) class Meta(BaseTable.Meta): model = SecretRole diff --git a/netbox/secrets/tests/test_api.py b/netbox/secrets/tests/test_api.py index 34608d68b..12ee799da 100644 --- a/netbox/secrets/tests/test_api.py +++ b/netbox/secrets/tests/test_api.py @@ -21,7 +21,7 @@ class AppTest(APITestCase): class SecretRoleTest(APIViewTestCases.APIViewTestCase): model = SecretRole - brief_fields = ['id', 'name', 'secret_count', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'secret_count', 'slug', 'url'] create_data = [ { 'name': 'Secret Role 4', @@ -53,7 +53,7 @@ class SecretRoleTest(APIViewTestCases.APIViewTestCase): class SecretTest(APIViewTestCases.APIViewTestCase): model = Secret - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] def setUp(self): super().setUp() diff --git a/netbox/secrets/tests/test_views.py b/netbox/secrets/tests/test_views.py index bff039656..5be77d418 100644 --- a/netbox/secrets/tests/test_views.py +++ b/netbox/secrets/tests/test_views.py @@ -34,6 +34,10 @@ class SecretRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Secret Role 6,secret-role-6", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + # TODO: Change base class to PrimaryObjectViewTestCase class SecretTestCase( diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py index 9dbb5d044..a47070152 100644 --- a/netbox/secrets/urls.py +++ b/netbox/secrets/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView from . import views from .models import Secret, SecretRole @@ -11,10 +11,12 @@ urlpatterns = [ path('secret-roles/', views.SecretRoleListView.as_view(), name='secretrole_list'), path('secret-roles/add/', views.SecretRoleEditView.as_view(), name='secretrole_add'), path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'), + path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'), path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'), - path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), - path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), - path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), + path('secret-roles//', views.SecretRoleView.as_view(), name='secretrole'), + path('secret-roles//edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'), + path('secret-roles//delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'), + path('secret-roles//changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}), # Secrets path('secrets/', views.SecretListView.as_view(), name='secret_list'), @@ -26,5 +28,6 @@ urlpatterns = [ path('secrets//edit/', views.SecretEditView.as_view(), name='secret_edit'), path('secrets//delete/', views.SecretDeleteView.as_view(), name='secret_delete'), path('secrets//changelog/', ObjectChangeLogView.as_view(), name='secret_changelog', kwargs={'model': Secret}), + path('secrets//journal/', ObjectJournalView.as_view(), name='secret_journal', kwargs={'model': Secret}), ] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 7bfa265d6..57d64f064 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -7,7 +7,8 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from netbox.views import generic -from utilities.utils import get_subquery +from utilities.tables import paginate_table +from utilities.utils import count_related from . import filters, forms, tables from .models import SecretRole, Secret, SessionKey, UserKey @@ -28,11 +29,28 @@ def get_session_key(request): class SecretRoleListView(generic.ObjectListView): queryset = SecretRole.objects.annotate( - secret_count=get_subquery(Secret, 'role') + secret_count=count_related(Secret, 'role') ) table = tables.SecretRoleTable +class SecretRoleView(generic.ObjectView): + queryset = SecretRole.objects.all() + + def get_extra_context(self, request, instance): + secrets = Secret.objects.restrict(request.user, 'view').filter( + role=instance + ) + + secrets_table = tables.SecretTable(secrets) + secrets_table.columns.hide('role') + paginate_table(secrets_table, request) + + return { + 'secrets_table': secrets_table, + } + + class SecretRoleEditView(generic.ObjectEditView): queryset = SecretRole.objects.all() model_form = forms.SecretRoleForm @@ -48,9 +66,18 @@ class SecretRoleBulkImportView(generic.BulkImportView): table = tables.SecretRoleTable +class SecretRoleBulkEditView(generic.BulkEditView): + queryset = SecretRole.objects.annotate( + secret_count=count_related(Secret, 'role') + ) + filterset = filters.SecretRoleFilterSet + table = tables.SecretRoleTable + form = forms.SecretRoleBulkEditForm + + class SecretRoleBulkDeleteView(generic.BulkDeleteView): queryset = SecretRole.objects.annotate( - secret_count=get_subquery(Secret, 'role') + secret_count=count_related(Secret, 'role') ) table = tables.SecretRoleTable diff --git a/netbox/templates/base.html b/netbox/templates/base.html index 86b582b3e..155c0d9b2 100644 --- a/netbox/templates/base.html +++ b/netbox/templates/base.html @@ -55,7 +55,7 @@ {% block content %}{% endblock %}
{% if settings.BANNER_BOTTOM %} -

- Docs · + Docs · API · Code · Help diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index c104b154b..1890570d1 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -1,60 +1,11 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} - -{% block header %} -

-
- -
-
-
-
- - - - -
-
-
-
-
- {% plugin_buttons object %} - {% if perms.circuits.add_circuit %} - {% clone_button object %} - {% endif %} - {% if perms.circuits.change_circuit %} - {% edit_button object %} - {% endif %} - {% if perms.circuits.delete_circuit %} - {% delete_button object %} - {% endif %} -
-

{{ object }}

- {% include 'inc/created_updated.html' %} -
- {% custom_links object %} -
- +{% block breadcrumbs %} +
  • Circuits
  • +
  • {{ object.provider }}
  • +
  • {{ object.cid }}
  • {% endblock %} {% block content %} @@ -74,7 +25,7 @@ Provider - {{ object.provider }} + {{ object.provider }} diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html deleted file mode 100644 index fe0c6847d..000000000 --- a/netbox/templates/circuits/circuit_edit.html +++ /dev/null @@ -1,62 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Circuit
    -
    - {% render_field form.provider %} - {% render_field form.cid %} - {% render_field form.type %} - {% render_field form.status %} - {% render_field form.install_date %} -
    - -
    -
    - {{ form.commit_rate }} - {% include 'circuits/inc/speed_widget.html' with target_field='commit_rate' %} -
    - {{ form.commit_rate.help_text }} -
    -
    - {% render_field form.description %} -
    -
    -
    -
    Tenancy
    -
    - {% render_field form.tenant_group %} - {% render_field form.tenant %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 43dba8438..ebad75976 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -1,97 +1,58 @@ -{% extends 'base.html' %} +{% extends 'generic/object_edit.html' %} {% load static %} {% load form_helpers %} -{% block content %} -
    - {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} -
    -
    -

    {% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}

    - {% if form.non_field_errors %} -
    -
    Errors
    -
    - {{ form.non_field_errors }} -
    -
    - {% endif %} -
    -
    Location
    -
    -
    - -
    -

    {{ obj.circuit.provider }}

    -
    -
    -
    - -
    -

    {{ obj.circuit.cid }}

    -
    -
    -
    - -
    -

    {{ form.term_side.value }}

    -
    -
    - {% render_field form.region %} - {% render_field form.site %} -
    -
    -
    -
    Termination Details
    -
    -
    - -
    -
    - {{ form.port_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='port_speed' %} -
    - {{ form.port_speed.help_text }} -
    -
    -
    - -
    -
    - {{ form.upstream_speed }} - {% include 'circuits/inc/speed_widget.html' with target_field='upstream_speed' %} -
    - {{ form.upstream_speed.help_text }} -
    -
    - {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - {% render_field form.description %} -
    -
    -
    -
    -
    -
    - {% if obj.pk %} - - {% else %} - - {% endif %} - Cancel -
    -
    -
    -{% endblock %} +{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %} -{% block javascript %} - +{% block form %} +
    +
    Termination
    +
    +
    + +
    +

    {{ obj.circuit.provider }}

    +
    +
    +
    + +
    +

    {{ obj.circuit.cid }}

    +
    +
    +
    + +
    +

    {{ form.term_side.value }}

    +
    +
    + {% with cloud_tab_active=form.initial.cloud %} + +
    +
    + {% render_field form.region %} + {% render_field form.site_group %} + {% render_field form.site %} +
    +
    + {% render_field form.cloud %} +
    +
    + {% endwith %} + {% render_field form.mark_connected %} +
    +
    +
    +
    Termination Details
    +
    + {% render_field form.port_speed %} + {% render_field form.upstream_speed %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} + {% render_field form.description %} +
    +
    {% endblock %} diff --git a/netbox/templates/circuits/circuittype.html b/netbox/templates/circuits/circuittype.html new file mode 100644 index 000000000..aee7bf944 --- /dev/null +++ b/netbox/templates/circuits/circuittype.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Circuit Types
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Circuit Type +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Circuits + {{ circuits_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} + {% if perms.circuits.add_circuit %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/circuits/cloud.html b/netbox/templates/circuits/cloud.html new file mode 100644 index 000000000..532118bf8 --- /dev/null +++ b/netbox/templates/circuits/cloud.html @@ -0,0 +1,68 @@ +{% extends 'generic/object.html' %} +{% load static %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Clouds
  • +
  • {{ object.provider }}
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Cloud +
    + + + + + + + + + + + + + +
    Provider + {{ object.provider }} +
    Name{{ object.name }}
    Description{{ object.description }}
    +
    +
    +
    + Comments +
    +
    + {% if object.comments %} + {{ object.comments|render_markdown }} + {% else %} + None + {% endif %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:cloud_list' %} + {% plugin_left_page object %} +
    +
    +
    +
    + Circuits +
    + {% include 'inc/table.html' with table=circuits_table %} +
    + {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 477788931..acfc4ee22 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -26,59 +26,71 @@
    {% if termination %} - - - - - - - + + + + + + - + + + {% else %} + + + + + {% endif %}
    Site - {% if termination.site.region %} - {{ termination.site.region }} / - {% endif %} - {{ termination.site }} -
    Termination - {% if termination.cable %} - {% if perms.dcim.delete_cable %} - + {% if termination.site %} +
    Site + {% if termination.site.region %} + {{ termination.site.region }} / {% endif %} - {{ termination.cable }} - - - - {% with peer=termination.get_cable_peer %} - to - {% if peer.device %} - {{ peer.device }} - {% elif peer.circuit %} - {{ peer.circuit }} + {{ termination.site }} +
    Termination + {% if termination.mark_connected %} + + Marked as connected + {% elif termination.cable %} + {% if perms.dcim.delete_cable %} + {% endif %} - ({{ peer }}) - {% endwith %} - {% else %} - {% if perms.dcim.add_cable %} -
    - - - - -
    + {{ termination.cable }} + + + + {% with peer=termination.get_cable_peer %} + to + {% if peer.device %} + {{ peer.device }} + {% elif peer.circuit %} + {{ peer.circuit }} + {% endif %} + ({{ peer }}) + {% endwith %} + {% else %} + {% if perms.dcim.add_cable %} +
    + + + + +
    + {% endif %} + Not defined {% endif %} - Not defined - {% endif %} -
    Cloud + {{ termination.cloud }} +
    Speed diff --git a/netbox/templates/circuits/inc/speed_widget.html b/netbox/templates/circuits/inc/speed_widget.html deleted file mode 100644 index 988418945..000000000 --- a/netbox/templates/circuits/inc/speed_widget.html +++ /dev/null @@ -1,17 +0,0 @@ - - - - diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index e96d5abcb..85cd4352c 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -1,60 +1,11 @@ -{% extends 'base.html' %} -{% load buttons %} +{% extends 'generic/object.html' %} {% load static %} -{% load custom_links %} {% load helpers %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} - -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.circuits.add_provider %} - {% clone_button object %} - {% endif %} - {% if perms.circuits.change_provider %} - {% edit_button object %} - {% endif %} - {% if perms.circuits.delete_provider %} - {% delete_button object %} - {% endif %} -
    -

    {{ object }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Providers
  • +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -94,13 +45,13 @@
    Circuits - {{ circuits_table.rows|length }} + {{ circuits_table.rows|length }}
    {% include 'inc/custom_fields_panel.html' %} - {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:object_list' %} + {% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='circuits:provider_list' %}
    Comments diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html deleted file mode 100644 index ee0a3a285..000000000 --- a/netbox/templates/circuits/provider_edit.html +++ /dev/null @@ -1,42 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Provider
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.asn %} -
    -
    -
    -
    Support Info
    -
    - {% render_field form.account %} - {% render_field form.portal_url %} - {% render_field form.noc_contact %} - {% render_field form.admin_contact %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/cable.html b/netbox/templates/dcim/cable.html index 3bc7869fe..a69bac6d6 100644 --- a/netbox/templates/dcim/cable.html +++ b/netbox/templates/dcim/cable.html @@ -1,42 +1,21 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} +{% load perms %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_cable %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_cable %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}Cable {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Cables
  • +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if request.user|can_change:object %} + {% edit_button object %} + {% endif %} + {% if request.user|can_delete:object %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} diff --git a/netbox/templates/dcim/cable_connect.html b/netbox/templates/dcim/cable_connect.html index f8e07dca8..3215bc8e9 100644 --- a/netbox/templates/dcim/cable_connect.html +++ b/netbox/templates/dcim/cable_connect.html @@ -38,6 +38,12 @@

    {{ termination_a.device.site.region }}

    +
    + +
    +

    {{ termination_a.device.site.group }}

    +
    +
    @@ -120,11 +126,14 @@ {% if 'termination_b_region' in form.fields %} {% render_field form.termination_b_region %} {% endif %} + {% if 'termination_b_site_group' in form.fields %} + {% render_field form.termination_b_site_group %} + {% endif %} {% if 'termination_b_site' in form.fields %} {% render_field form.termination_b_site %} {% endif %} - {% if 'termination_b_rackgroup' in form.fields %} - {% render_field form.termination_b_rackgroup %} + {% 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 %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index a36922612..db134889f 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -69,7 +69,8 @@
    Total segments: {{ traced_path|length }}
    Total length: {% if total_length %} - {{ total_length|floatformat:"-2" }} Meters + {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters / + {{ total_length|meters_to_feet|floatformat:"-2" }} Feet {% else %} N/A {% endif %} @@ -101,12 +102,12 @@ - {{ cablepath.origin.parent }} / {{ cablepath.origin }} + {{ cablepath.origin.parent_object }} / {{ cablepath.origin }} {% if cablepath.destination %} - {{ cablepath.destination }} ({{ cablepath.destination.parent }}) + {{ cablepath.destination }} ({{ cablepath.destination.parent_object }}) {% else %} Incomplete {% endif %} diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html index 5d113c86a..ce251670b 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -26,7 +26,11 @@ Type - {{ object.get_type_display }} + {{ object.get_type_display|placeholder }} + + + Speed + {{ object.get_speed_display|placeholder }} Description @@ -34,6 +38,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %}
    @@ -42,7 +47,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b64b4aff2..69071c937 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -26,7 +26,11 @@ - + + + + + @@ -34,6 +38,7 @@
    Cable
    Type{{ object.get_type_display }}{{ object.get_type_display|placeholder }}
    Speed{{ object.get_speed_display|placeholder }}
    Description
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -42,7 +47,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 97f7c8953..6c5d8588e 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -18,21 +18,41 @@
    Cable
    - + + + + + + + + + @@ -125,7 +145,7 @@ @@ -204,7 +224,7 @@ {% plugin_left_page object %}
    - {% if power_ports and poweroutlets %} + {% if object.powerports.exists and object.poweroutlets.exists %}
    Power Utilization @@ -217,10 +237,10 @@
    - {% for pp in power_ports %} - {% with utilization=pp.get_power_draw powerfeed=pp.connected_endpoint %} + {% for powerport in object.powerports.all %} + {% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoint %} - + {% if powerfeed.available_power %} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 631abde89..27cc3e992 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -1,96 +1,74 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load static %} {% load helpers %} {% load custom_links %} {% load plugins %} -{% block title %}{{ object }}{% endblock %} +{% block breadcrumbs %} +
  • Devices
  • +
  • {{ object.site }}
  • + {% if object.parent_bay %} +
  • {{ object.parent_bay.device }}
  • +
  • {{ object.parent_bay }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_device %} -
    - - -
    - {% endif %} - {% if perms.dcim.add_device %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_device %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_device %} - {% delete_button object %} - {% endif %} -
    -

    {{ object }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} +{% block buttons %} + {% if perms.dcim.change_device %} +
    + +
    + {% endif %} + {% if perms.dcim.add_device %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_device %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_device %} + {% delete_button object %} + {% endif %} +{% endblock %} + +{% block tabs %}
    {% if iface.connected_endpoint.device %} -
    SiteRegion {% if object.site.region %} - {{ object.site.region }} / + {% for region in object.site.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.site.region }} + {% else %} + None + {% endif %} +
    Site + {{ object.site }} +
    Location + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None {% endif %} - {{ object.site }}
    Rack {% if object.rack %} - {% if object.rack.group %} - {{ object.rack.group }} / - {% endif %} {{ object.rack }} {% else %} None @@ -74,7 +94,7 @@
    Device Type - {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U) + {{ object.device_type.display_name }} ({{ object.device_type.u_height }}U)
    Role - {{ object.device_role }} + {{ object.device_role }}
    Available Utilization
    {{ pp }}{{ powerport }} {{ utilization.outlet_count }} {{ utilization.allocated }}VA
    {{ iface }} + {{ iface.connected_endpoint.device }} @@ -61,6 +61,7 @@ $(document).ready(function() { // Glean configured hostnames/interfaces from the DOM var configured_device = row.children('td.configured_device').attr('data'); + var configured_chassis = row.children('td.configured_device').attr('data-chassis'); var configured_interface = row.children('td.configured_interface').attr('data'); var configured_interface_short = null; if (configured_interface) { @@ -81,9 +82,9 @@ $(document).ready(function() { // Apply colors to rows if (!configured_device && lldp_device) { row.addClass('info'); - } else if (configured_device == lldp_device && configured_interface == lldp_interface) { + } else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface == lldp_interface) { row.addClass('success'); - } else if (configured_device == lldp_device && configured_interface_short == lldp_interface) { + } else if ((configured_device == lldp_device || configured_chassis == lldp_device) && configured_interface_short == lldp_interface) { row.addClass('success'); } else { row.addClass('danger'); diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index c0ce453b3..a615092b9 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -1,43 +1,16 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} {% load perms %} +{% load custom_links %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if request.user|can_change:object %} - - Edit - - {% endif %} - {% if request.user|can_delete:object %} - - Delete - - {% endif %} -
    -

    {% block title %}{{ object.device }} / {{ object }}{% endblock %}

    - +{% block title %}{{ object.device }} / {{ object }}{% endblock %} + +{% block breadcrumbs %} +
  • Devices
  • +
  • {{ object.device }}
  • + {% if breadcrumb_url %} +
  • {{ object|meta:"verbose_name_plural"|bettertitle }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 023b7e43c..0859a163b 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -7,6 +7,7 @@
    {% render_field form.name %} {% render_field form.device_role %} + {% render_field form.tags %}
    @@ -22,8 +23,9 @@
    Location
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} - {% render_field form.rack_group %} + {% render_field form.location %} {% render_field form.rack %} {% if obj.device_type.is_child_device and obj.parent_bay %}
    @@ -92,12 +94,6 @@ {% render_field form.local_context_data %}
    -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    Comments
    diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 365625469..13b019812 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -30,6 +30,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/devicerole.html b/netbox/templates/dcim/devicerole.html new file mode 100644 index 000000000..c6cbf4952 --- /dev/null +++ b/netbox/templates/dcim/devicerole.html @@ -0,0 +1,76 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Device Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Device Role +
    + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    VM Role + {% if object.vm_role %} + + {% else %} + + {% endif %} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 6bc970174..95dd8909e 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -1,81 +1,59 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} {% load plugins %} {% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} -{% block header %} -
    -
    - -
    +{% block breadcrumbs %} +
  • Device Types
  • +
  • {{ object.manufacturer }}
  • +
  • {{ object.model }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_devicetype %} +
    + +
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_devicetype %} -
    - - -
    - {% endif %} - {% if perms.dcim.add_devicetype %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_devicetype %} - {% edit_button object use_pk=True %} - {% endif %} - {% if perms.dcim.delete_devicetype %} - {% delete_button object use_pk=True %} - {% endif %} -
    -

    {{ object.manufacturer }} {{ object.model }}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - + {% endif %} + {% if perms.dcim.add_devicetype %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_devicetype %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_devicetype %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} @@ -88,7 +66,7 @@ - + diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html deleted file mode 100644 index 5aba04b39..000000000 --- a/netbox/templates/dcim/devicetype_edit.html +++ /dev/null @@ -1,44 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Device Type
    -
    - {% render_field form.manufacturer %} - {% render_field form.model %} - {% render_field form.slug %} - {% render_field form.part_number %} - {% render_field form.u_height %} - {% render_field form.is_full_depth %} - {% render_field form.subdevice_role %} -
    -
    -
    -
    Rack Images
    -
    - {% render_field form.front_image %} - {% render_field form.rear_image %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 9dc0bc9f5..5a84889ab 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -44,6 +44,7 @@
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer }}
    Model Name
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -52,7 +53,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/inc/cable_toggle_buttons.html b/netbox/templates/dcim/inc/cable_toggle_buttons.html index 74fb5eb36..e3eb318ac 100644 --- a/netbox/templates/dcim/inc/cable_toggle_buttons.html +++ b/netbox/templates/dcim/inc/cable_toggle_buttons.html @@ -9,8 +9,3 @@ {% endif %} {% endif %} -{% if perms.dcim.delete_cable %} - - - -{% endif %} diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 1962248e7..26a7e1cd3 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -1,12 +1,12 @@ + {% endwith %} {% else %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 3ae0733a1..8dc8c07a2 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -1,6 +1,7 @@ {% extends 'dcim/device_component.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block content %}
    @@ -38,10 +39,20 @@ {% endif %}
    + + + +
    Cable - {% if termination.parent.provider %} + {% if termination.parent_object.provider %} - - {{ termination.parent.provider }} - {{ termination.parent }} + + {{ termination.parent_object.provider }} + {{ termination.parent_object }} {% else %} - {{ termination.parent }} + {{ termination.parent_object }} {% endif %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index a8e04e9dc..c0a2ff22a 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -9,18 +9,18 @@ {% include 'responsive_table.html' %} {{ endpoint.parent }}{{ endpoint.parent_object }} {{ endpoint }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + None + {% endif %} +
    LAG - {% if object.lag%} + {% if object.lag %} {{ object.lag }} {% else %} None @@ -66,6 +77,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -75,7 +87,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} @@ -226,7 +242,23 @@
    - {% include 'panel_table.html' with table=ipaddress_table heading="IP Addresses" %} +
    +
    + IP Addresses +
    + {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
    None
    + {% endif %} + {% if perms.ipam.add_ipaddress %} + + {% endif %} +
    diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index 1f549febd..8a0c85a12 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -19,11 +19,14 @@ {% render_field form.label %} {% render_field form.type %} {% render_field form.enabled %} + {% render_field form.parent %} {% render_field form.lag %} {% render_field form.mac_address %} {% render_field form.mtu %} {% render_field form.mgmt_only %} + {% render_field form.mark_connected %} {% render_field form.description %} + {% render_field form.tags %}
    @@ -34,12 +37,14 @@ {% render_field form.tagged_vlans %}
    -
    -
    Tags
    + {% if form.custom_fields %} +
    +
    Custom Fields
    - {% render_field form.tags %} + {% render_custom_fields form %}
    -
    +
    + {% endif %} {% endblock %} {% block buttons %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 4e0963f9d..6eec7f434 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -62,6 +62,7 @@
    Cable
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html new file mode 100644 index 000000000..c12c9f533 --- /dev/null +++ b/netbox/templates/dcim/location.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Location
  • + {% for location in object.get_ancestors %} +
  • {{ location }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Location +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html new file mode 100644 index 000000000..b2ecacbb1 --- /dev/null +++ b/netbox/templates/dcim/manufacturer.html @@ -0,0 +1,60 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Manufacturers
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Manufacturer +
    + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Device types + {{ devicetypes_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Device Types +
    + {% include 'inc/table.html' with table=devicetypes_table %} + {% if perms.dcim.add_devicetype %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html new file mode 100644 index 000000000..f620bc4c9 --- /dev/null +++ b/netbox/templates/dcim/platform.html @@ -0,0 +1,78 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Platforms
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Platform +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Manufacturer + {% if object.manufacturer %} + {{ object.manufacturer }} + {% else %} + None + {% endif %} +
    NAPALM Driver{{ object.napalm_driver|placeholder }}
    NAPALM Arguments
    {{ object.napalm_args }}
    Devices + {{ devices_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Devices +
    + {% include 'inc/table.html' with table=devices_table %} + {% if perms.dcim.add_device %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index eded66fec..283eed4c1 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -1,63 +1,18 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load static %} {% load custom_links %} {% load helpers %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.add_powerfeed %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_powerfeed %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_powerfeed %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Power Feeds
  • +
  • {{ object.power_panel.site }}
  • +
  • {{ object.power_panel }}
  • + {% if object.rack %} +
  • {{ object.rack }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -159,13 +114,17 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/powerfeed_edit.html b/netbox/templates/dcim/powerfeed_edit.html deleted file mode 100644 index 0a6581444..000000000 --- a/netbox/templates/dcim/powerfeed_edit.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Power Panel
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.power_panel %} -
    -
    -
    -
    Power Feed
    -
    - {% render_field form.rack %} - {% render_field form.name %} - {% render_field form.status %} -
    -
    -
    -
    Characteristics
    -
    - {% render_field form.type %} - {% render_field form.supply %} - {% render_field form.voltage %} - {% render_field form.amperage %} - {% render_field form.phase %} - {% render_field form.max_utilization %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index e5c9f8093..e76f067b1 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -42,6 +42,7 @@
    Cable {{ object.cable }} - +
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -50,7 +51,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 179697b4f..e906aec90 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -1,59 +1,14 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} -{% load static %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_powerpanel %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_powerpanel %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Power Panels
  • +
  • {{ object.site }}
  • + {% if object.location %} +
  • {{ object.location }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -71,10 +26,10 @@ - +
    Cable
    Rack GroupLocation - {% if object.rack_group %} - {{ object.rack_group }} + {% if object.location %} + {{ object.location }} {% else %} None {% endif %} diff --git a/netbox/templates/dcim/powerpanel_edit.html b/netbox/templates/dcim/powerpanel_edit.html deleted file mode 100644 index c1890cd47..000000000 --- a/netbox/templates/dcim/powerpanel_edit.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Power Panel
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.name %} - {% render_field form.tags %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index 7356bafb9..635251b0d 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -42,6 +42,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -50,7 +51,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 1e0813e5c..3bb0084a3 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -1,74 +1,46 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} -{% load custom_links %} {% load helpers %} {% load static %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - {% plugin_buttons object %} - - Previous Rack - - - Next Rack - - {% if perms.dcim.add_rack %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_rack %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_rack %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}Rack {{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - - {% custom_links object %} -
    - +{% block title %}Rack {{ object }}{% endblock %} + +{% block breadcrumbs %} +
  • Racks
  • +
  • {{ object.site }}
  • + {% if object.group %} + {% for group in object.group.get_ancestors %} +
  • {{ group }}
  • + {% endfor %} +
  • {{ object.group }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + + Previous Rack + + + Next Rack + + {% if perms.dcim.add_rack %} + {% clone_button object %} + {% endif %} + {% if perms.dcim.change_rack %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_rack %} + {% delete_button object %} + {% endif %} +{% endblock %} + +{% block tab_buttons %} + + {{ block.super }} {% endblock %} {% block content %} @@ -85,17 +57,17 @@ {% if object.site.region %} {{ object.site.region }} / {% endif %} - {{ object.site }} + {{ object.site }} - +
    Cable
    GroupLocation - {% if object.group %} - {% for group in object.group.get_ancestors %} - {{ group }} / + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / {% endfor %} - {{ object.group }} + {{ object.location }} {% else %} None {% endif %} @@ -330,12 +302,16 @@
    +

    Front

    {% include 'dcim/inc/rack_elevation.html' with face='front' %} +
    +

    Rear

    {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
    diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 94456ac73..af94a7197 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -6,12 +6,19 @@
    Rack
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} + {% render_field form.location %} {% render_field form.name %} - {% render_field form.facility_id %} - {% render_field form.group %} {% render_field form.status %} {% render_field form.role %} + {% render_field form.tags %} +
    +
    +
    +
    Inventory Control
    +
    + {% render_field form.facility_id %} {% render_field form.serial %} {% render_field form.asset_tag %}
    @@ -52,12 +59,6 @@
    {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    Comments
    diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 081533ae9..a42610e35 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -25,7 +25,8 @@ {% if page %}
    {% for rack in page %} -
    +
    +
    {{ rack.name }} {% if rack.role %} @@ -35,7 +36,7 @@
    {{ rack.facility_id }} {% endif %}
    - {% include 'dcim/inc/rack_elevation.html' with face=rack_face %} + {% include 'dcim/inc/rack_elevation.html' with object=rack face=rack_face %}
    {{ rack.name }} @@ -43,6 +44,7 @@ ({{ rack.facility_id }}) {% endif %}
    +
    {% endfor %}
    diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 8e05484d4..06e14e7db 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -1,56 +1,23 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} {% load static %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_rackreservation %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_rackreservation %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Rack Reservations
  • +
  • {{ object.rack }}
  • +
  • Units {{ object.unit_list }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_rackreservation %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_rackreservation %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} @@ -68,14 +35,14 @@ {% if rack.site.region %} {{ rack.site.region }} / {% endif %} - {{ rack.site }} + {{ rack.site }}
    Group {% if rack.group %} - {{ rack.group }} + {{ rack.group }} {% else %} None {% endif %} @@ -127,22 +94,20 @@ {% plugin_left_page object %}
    - {% with rack=object.rack %} -
    -
    -
    -

    Front

    -
    - {% include 'dcim/inc/rack_elevation.html' with face='front' %} -
    -
    -
    -

    Rear

    -
    - {% include 'dcim/inc/rack_elevation.html' with face='rear' %} +
    +
    +
    +

    Front

    + {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' %}
    - {% endwith %} +
    +
    +

    Rear

    +
    + {% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' %} +
    +
    {% plugin_right_page object %}
    diff --git a/netbox/templates/dcim/rackreservation_edit.html b/netbox/templates/dcim/rackreservation_edit.html deleted file mode 100644 index 1465dc02d..000000000 --- a/netbox/templates/dcim/rackreservation_edit.html +++ /dev/null @@ -1,33 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Rack Reservation
    -
    - {% render_field form.region %} - {% render_field form.site %} - {% render_field form.rack_group %} - {% render_field form.rack %} - {% render_field form.units %} - {% render_field form.user %} - {% render_field form.description %} - {% render_field form.tags %} -
    -
    -
    -
    Tenant Assignment
    -
    - {% render_field form.tenant_group %} - {% render_field form.tenant %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/rackrole.html b/netbox/templates/dcim/rackrole.html new file mode 100644 index 000000000..89306b481 --- /dev/null +++ b/netbox/templates/dcim/rackrole.html @@ -0,0 +1,66 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Rack Roles
  • +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Rack Role +
    + + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Racks + {{ racks_table.rows|length }} +
    +
    + {% plugin_left_page object %} +
    +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    +
    +
    + Racks +
    + {% include 'inc/table.html' with table=racks_table %} + {% if perms.dcim.add_rack %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 319850397..01eb0e9e6 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -38,6 +38,7 @@
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_left_page object %} @@ -46,7 +47,11 @@
    Connection
    - {% if object.cable %} + {% if object.mark_connected %} +
    + Marked as connected +
    + {% elif object.cable %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html new file mode 100644 index 000000000..c79336962 --- /dev/null +++ b/netbox/templates/dcim/region.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Region
  • + {% for region in object.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Region +
    +
    Cable
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    + + {% plugin_left_page object %} + +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    + +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index a2479ca1f..b68fc1f02 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -1,65 +1,17 @@ -{% extends 'base.html' %} -{% load buttons %} -{% load custom_links %} +{% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} -{% load static %} {% load tz %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.add_site %} - {% clone_button object %} - {% endif %} - {% if perms.dcim.change_site %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_site %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Sites
  • + {% if object.region %} + {% for region in object.region.get_ancestors %} +
  • {{ region }}
  • + {% endfor %} +
  • {{ object.region }}
  • + {% endif %} +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -89,6 +41,19 @@ {% endif %} + + Group + + {% if object.group %} + {% for group in object.group.get_ancestors %} + {{ group }} / + {% endfor %} + {{ object.group }} + {% else %} + None + {% endif %} + + Tenant @@ -137,7 +102,7 @@ {% if object.physical_address %} @@ -156,7 +121,7 @@ {% if object.latitude and object.longitude %} @@ -215,42 +180,44 @@
    - Rack Groups + Locations
    - {% for rg in rack_groups %} + {% for location in locations %} - - + + @@ -260,7 +227,7 @@ diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html deleted file mode 100644 index 274863fc4..000000000 --- a/netbox/templates/dcim/site_edit.html +++ /dev/null @@ -1,57 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
    -
    Site
    -
    - {% render_field form.name %} - {% render_field form.slug %} - {% render_field form.status %} - {% render_field form.region %} - {% render_field form.facility %} - {% render_field form.asn %} - {% render_field form.time_zone %} - {% render_field form.description %} -
    -
    -
    -
    Tenancy
    -
    - {% render_field form.tenant_group %} - {% render_field form.tenant %} -
    -
    -
    -
    Contact Info
    -
    - {% render_field form.physical_address %} - {% render_field form.shipping_address %} - {% render_field form.latitude %} - {% render_field form.longitude %} - {% render_field form.contact_name %} - {% render_field form.contact_phone %} - {% render_field form.contact_email %} -
    -
    - {% if form.custom_fields %} -
    -
    Custom Fields
    -
    - {% render_custom_fields form %} -
    -
    - {% endif %} -
    -
    Tags
    -
    - {% render_field form.tags %} -
    -
    -
    -
    Comments
    -
    - {% render_field form.comments %} -
    -
    -{% endblock %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html new file mode 100644 index 000000000..95cba7aeb --- /dev/null +++ b/netbox/templates/dcim/sitegroup.html @@ -0,0 +1,73 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} +
  • Site Groups
  • + {% for sitegroup in object.get_ancestors %} +
  • {{ sitegroup }}
  • + {% endfor %} +
  • {{ object }}
  • +{% endblock %} + +{% block content %} +
    +
    +
    +
    + Site Group +
    +
    {{ rg }}{{ rg.rack_count }} + {{ location }} + {{ location.rack_count }} - + All racks {{ stats.rack_count }} - +
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Parent + {% if object.parent %} + {{ object.parent }} + {% else %} + + {% endif %} +
    Sites + {{ sites_table.rows|length }} +
    +
    + {% plugin_left_page object %} + +
    + {% include 'inc/custom_fields_panel.html' %} + {% plugin_right_page object %} +
    + +
    +
    +
    +
    + Sites +
    + {% include 'inc/table.html' with table=sites_table %} + {% if perms.dcim.add_site %} + + {% endif %} +
    + {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %} + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html index 4235768a6..7a7fbcf55 100644 --- a/netbox/templates/dcim/trace/cable.html +++ b/netbox/templates/dcim/trace/cable.html @@ -1,6 +1,6 @@ {% load helpers %} -
    +
    {% if cable.label %}{{ cable.label }}{% else %}Cable #{{ cable.pk }}{% endif %} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index eb9972406..6887c01ca 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -1,57 +1,24 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load custom_links %} {% load helpers %} {% load plugins %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% plugin_buttons object %} - {% if perms.dcim.change_virtualchassis %} - {% edit_button object %} - {% endif %} - {% if perms.dcim.delete_virtualchassis %} - {% delete_button object %} - {% endif %} -
    -

    {% block title %}{{ object }}{% endblock %}

    - {% include 'inc/created_updated.html' %} -
    - {% custom_links object %} -
    - +{% block breadcrumbs %} +
  • Virtual Chassis
  • + {% if object.master %} +
  • {{ object.master.site }}
  • + {% endif %} +
  • {{ object }}
  • +{% endblock %} + +{% block buttons %} + {% if perms.dcim.change_virtualchassis %} + {% edit_button object %} + {% endif %} + {% if perms.dcim.delete_virtualchassis %} + {% delete_button object %} + {% endif %} {% endblock %} {% block content %} diff --git a/netbox/templates/dcim/virtualchassis_add.html b/netbox/templates/dcim/virtualchassis_add.html index 2a68cb5e4..6dc9fc59f 100644 --- a/netbox/templates/dcim/virtualchassis_add.html +++ b/netbox/templates/dcim/virtualchassis_add.html @@ -14,6 +14,7 @@
    Member Devices
    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.rack %} {% render_field form.members %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 408e51b75..626002a68 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -1,47 +1,10 @@ -{% extends 'base.html' %} +{% extends 'generic/object.html' %} {% load helpers %} {% load static %} -{% block header %} -
    -
    - -
    -
    -
    -
    - - - - -
    -
    -
    -
    -
    - {% if perms.extras.change_configcontext %} - - - Edit this config context - - {% endif %} -
    - -

    {% block title %}{{ object }}{% endblock %}

    +{% block breadcrumbs %} +
  • Config Contexts
  • +
  • {{ object }}
  • {% endblock %} {% block content %} @@ -103,6 +66,20 @@ {% endif %} + + Site Groups + + {% if object.site_groups.all %} +
      + {% for sitegroup in object.site_groups.all %} +
    • {{ sitegroup }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} + + Sites @@ -117,13 +94,27 @@ {% endif %} + + Device Types + + {% if object.device_types.all %} +
      + {% for devicetype in object.device_types.all %} +
    • {{ devicetype }}
    • + {% endfor %} +
    + {% else %} + None + {% endif %} + + Roles {% if object.roles.all %} {% else %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 08e92d258..37e03e0ac 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -15,7 +15,9 @@
    Assignment
    {% render_field form.regions %} + {% render_field form.site_groups %} {% render_field form.sites %} + {% render_field form.device_types %} {% render_field form.roles %} {% render_field form.platforms %} {% render_field form.cluster_groups %} diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html new file mode 100644 index 000000000..226726284 --- /dev/null +++ b/netbox/templates/extras/object_journal.html @@ -0,0 +1,34 @@ +{% extends base_template %} +{% load form_helpers %} + +{% block title %}{{ block.super }} - Journal{% endblock %} + +{% block content %} + {% if perms.extras.add_journalentry %} +
    +
    + New Journal Entry +
    +
    + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    +
    + {% render_field form.kind %} + {% render_field form.comments %} +
    +
    + + Cancel +
    +
    +
    +
    +
    +
    + {% endif %} + {% include 'panel_table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +{% endblock %} diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index 15265889e..9dcd8c2ac 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -83,6 +83,8 @@
    +
    +
    Difference @@ -113,13 +115,39 @@
    -
    +
    +
    +
    - Object Data + Pre-Change Data
    -
    {{ object.object_data|render_json }}
    + {% if object.prechange_data %} +
    {% for k, v in object.prechange_data.items %}{% spaceless %}
    +                      {{ k }}: {{ v|render_json }}
    +                    {% endspaceless %}
    +{% endfor %}
    + {% else %} + None + {% endif %} +
    +
    +
    +
    +
    +
    + Post-Change Data +
    +
    + {% if object.postchange_data %} +
    {% for k, v in object.postchange_data.items %}{% spaceless %}
    +                        {{ k }}: {{ v|render_json }}
    +                      {% endspaceless %}
    +{% endfor %}
    + {% else %} + None + {% endif %}
    diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 5808f707f..3f0839512 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -16,7 +16,7 @@

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script.Meta.description|render_markdown }}

    {{ script }}

    -

    {{ script.Meta.description }}

    +

    {{ script.Meta.description|render_markdown }}

    @@ -511,6 +528,9 @@