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..3b2026b34 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_change.yaml @@ -0,0 +1,38 @@ +--- +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: checkboxes + attributes: + label: Area + description: To what section(s) of the documentation does this change pertain? + options: + - label: Installation instructions + - label: Configuration parameters + - label: Functionality/features + - label: REST API + - label: Administration/development + - label: Other + - 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/CONTRIBUTING.md b/CONTRIBUTING.md index c86f810f3..cd62c5ab7 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://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ). 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 a12daa783..880fa8c08 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,11 @@ complete list of requirements, see `requirements.txt`. The code is available [on The complete documentation for NetBox can be found at [Read the Docs](https://netbox.readthedocs.io/en/stable/). -Questions? Comments? Start by perusing our [GitHub discussions](https://github.com/netbox-community/netbox/discussions) for the topic you have in mind. +### 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://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - 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 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/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-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/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/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/index.md b/docs/development/index.md index c0aea0eeb..bbcb1eac8 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://join.slack.com/t/netdev-community/shared_invite/zt-mtts8g0n-Sm6Wutn62q_M4OdsaIycrQ) - 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 f3338ffd3..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 @@ -58,6 +43,27 @@ Submit a pull request to merge the `feature` branch into the `develop` branch in ## 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 88e152193..1db490798 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -52,7 +52,7 @@ $ sudo -u postgres psql psql (12.5 (Ubuntu 12.5-0ubuntu0.20.04.1)) Type "help" for help. -postgres=# CREATE DATABASE netbox ENCODING 'UTF8' LC_COLLATE='C.UTF-8' LC_CTYPE='C.UTF-8'; +postgres=# CREATE DATABASE netbox; CREATE DATABASE postgres=# CREATE USER netbox WITH PASSWORD 'J5brHrAXFLQSif0K'; CREATE ROLE diff --git a/docs/installation/index.md b/docs/installation/index.md index 71e669295..2ae5e5299 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -23,6 +23,9 @@ The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 | 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) @@ -30,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/models/dcim/device.md b/docs/models/dcim/device.md index df14c0e07..2210e4028 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 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/location.md b/docs/models/dcim/location.md new file mode 100644 index 000000000..710d8e8fe --- /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 concept. 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/journalentry.md b/docs/models/extras/journalentry.md new file mode 100644 index 000000000..c95340a01 --- /dev/null +++ b/docs/models/extras/journalentry.md @@ -0,0 +1,5 @@ +# Journal Entries + +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 of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. + +Each journal entry has a user-populated `commnets` field. Each entry records the date and time, associated user, and object automatically upon being created. 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/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 2f7079f03..2b3792204 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -1,5 +1,24 @@ # NetBox v2.10 +## 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 diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index 82a68e06d..6bfdd414b 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -4,30 +4,98 @@ **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. + ### 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 of NetBox. Unlike the change log, which is typically limited in the amount of history it retains, journal entries never expire. + +#### 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 model. This is helpful for associating subinterfaces with their physical counterpart. For example, you might assign virtual interfaces Gi0/0.100 and Gi0/0.200 to 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 a pre- and post-change representation 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 an object creation will always be null, as will the post-change snapshot for an object deletion. + #### 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 you don't necessarily know or care what is connected to the far end of a cable, but still need to designate the near end termination. -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. +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 configuration of the current table. For example, if you modify the sites list to display on the site name, tenant, and status, the rendered CSV will include only these columns. + +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 Site Group 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 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. ### Enhancements * [#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 * [#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 +* [#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 vCPU allocations for virtual machines ### 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 +* [#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) ### 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 @@ -38,5 +106,33 @@ In addition to the new `mark_connected` boolean field, the REST API representati * 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/` +* 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/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py index 3aae01bf1..7c7d371ad 100644 --- a/netbox/circuits/api/nested_serializers.py +++ b/netbox/circuits/api/nested_serializers.py @@ -21,7 +21,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 +34,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 +42,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class Meta: model = Circuit - fields = ['id', 'url', 'cid'] + fields = ['id', 'url', 'display', 'cid'] class NestedCircuitTerminationSerializer(WritableNestedSerializer): @@ -51,4 +51,4 @@ class NestedCircuitTerminationSerializer(WritableNestedSerializer): class Meta: model = CircuitTermination - fields = ['id', 'url', 'circuit', 'term_side', 'cable', '_occupied'] + fields = ['id', 'url', 'display', 'circuit', 'term_side', 'cable', '_occupied'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index f6a9ae3a1..bae45e2b3 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,10 +4,10 @@ from circuits.choices import CircuitStatusChoices from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import CableTerminationSerializer, ConnectedEndpointSerializer -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from netbox.api import ChoiceField -from netbox.api.serializers import OrganizationalModelSerializer, WritableNestedSerializer +from netbox.api.serializers import ( + BaseModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, WritableNestedSerializer +) from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -16,15 +16,15 @@ 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', ] @@ -39,7 +39,8 @@ class CircuitTypeSerializer(OrganizationalModelSerializer): class Meta: model = CircuitType fields = [ - 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'circuit_count', ] @@ -50,12 +51,12 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'connected_endpoint', + 'id', 'url', 'display', 'site', '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) @@ -67,12 +68,13 @@ 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() @@ -81,7 +83,7 @@ class CircuitTerminationSerializer(CableTerminationSerializer, ConnectedEndpoint class Meta: model = CircuitTermination fields = [ - 'id', 'url', 'circuit', 'term_side', 'site', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', + 'id', 'url', 'display', 'circuit', 'term_side', 'site', '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/filters.py b/netbox/circuits/filters.py index fa563881c..03da662e7 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -2,7 +2,7 @@ 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 ( @@ -37,6 +37,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(), @@ -102,17 +115,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 +128,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: diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index adbf78fdc..002c73b9a 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -1,7 +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, ) @@ -142,6 +142,20 @@ class CircuitTypeForm(BootstrapMixin, CustomFieldModelForm): ] +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() @@ -320,18 +334,26 @@ 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', } ) class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', - 'description', + 'term_side', 'region', 'site_group', 'site', 'mark_connected', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", diff --git a/netbox/circuits/migrations/0025_standardize_models.py b/netbox/circuits/migrations/0025_standardize_models.py index 2b1d2664e..42745f35b 100644 --- a/netbox/circuits/migrations/0025_standardize_models.py +++ b/netbox/circuits/migrations/0025_standardize_models.py @@ -34,4 +34,14 @@ class Migration(migrations.Migration): 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/models.py b/netbox/circuits/models.py index 693fe5ae6..d19841e4f 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -1,14 +1,12 @@ 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 ObjectChange, TaggedItem +from extras.models import ObjectChange from extras.utils import extras_features -from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel +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 @@ -61,7 +59,6 @@ class Provider(PrimaryModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -185,7 +182,6 @@ class Circuit(PrimaryModel): ) objects = CircuitQuerySet.as_manager() - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'cid', 'provider', 'type', 'status', 'tenant', 'install_date', 'commit_rate', 'description', 'comments', @@ -235,7 +231,8 @@ class Circuit(PrimaryModel): return self._get_termination('Z') -class CircuitTermination(BigIDModel, PathEndpoint, CableTermination): +@extras_features('webhooks') +class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -289,21 +286,14 @@ class CircuitTermination(BigIDModel, PathEndpoint, CableTermination): 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/tables.py b/netbox/circuits/tables.py index e4b859d3c..efa7e4c49 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,7 +1,7 @@ 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 @@ -60,9 +60,7 @@ class CircuitTable(BaseTable): linkify=True ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() a_side = tables.Column( verbose_name='A Side' ) diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index df4c2070e..3341c72c3 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -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 = ['_occupied', 'cable', 'circuit', 'id', 'term_side', 'url'] + brief_fields = ['_occupied', 'cable', 'circuit', 'display', 'id', 'term_side', 'url'] @classmethod def setUpTestData(cls): diff --git a/netbox/circuits/tests/test_filters.py b/netbox/circuits/tests/test_filters.py index 9477bfbac..b9e1eac45 100644 --- a/netbox/circuits/tests/test_filters.py +++ b/netbox/circuits/tests/test_filters.py @@ -3,7 +3,7 @@ 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 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) @@ -223,6 +244,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]} diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 3356fca8f..de0d2c970 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -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 diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index a8306efa8..0b47b4b2c 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,7 +1,7 @@ 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 @@ -18,11 +18,13 @@ urlpatterns = [ 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}), # 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'), @@ -38,6 +40,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 eec05dab8..b3215c029 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -107,6 +107,15 @@ 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=count_related(Circuit, 'type') diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 7f0d402ff..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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + 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', '_occupied'] + fields = ['id', 'url', 'display', 'name', 'cable', '_occupied'] diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index b5672c1a8..4941b7bbc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,20 +6,13 @@ from rest_framework.validators import UniqueTogetherValidator 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 netbox.api.serializers 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 from netbox.api.serializers import ( - NestedGroupModelSerializer, OrganizationalModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + NestedGroupModelSerializer, OrganizationalModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, + WritableNestedSerializer, ) from tenancy.api.nested_serializers import NestedTenantSerializer from users.api.nested_serializers import NestedUserSerializer @@ -49,7 +42,7 @@ class CableTerminationSerializer(serializers.ModelSerializer): return None -class ConnectedEndpointSerializer(CustomFieldModelSerializer): +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) @@ -89,15 +82,29 @@ class RegionSerializer(NestedGroupModelSerializer): class Meta: model = Region fields = [ - 'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + '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) circuit_count = serializers.IntegerField(read_only=True) @@ -110,10 +117,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', ] @@ -121,17 +128,17 @@ class SiteSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Racks # -class RackGroupSerializer(NestedGroupModelSerializer): - 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) class Meta: - model = RackGroup + model = Location fields = [ - 'id', 'url', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', 'last_updated', - 'rack_count', '_depth', + 'id', 'url', 'display', 'name', 'slug', 'site', 'parent', 'description', 'custom_fields', 'created', + 'last_updated', 'rack_count', '_depth', ] @@ -142,15 +149,15 @@ class RackRoleSerializer(OrganizationalModelSerializer): class Meta: model = RackRole fields = [ - 'id', 'url', 'name', 'slug', 'color', 'description', 'custom_fields', 'created', 'last_updated', + '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) @@ -163,21 +170,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 @@ -197,7 +205,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() @@ -205,7 +213,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): @@ -257,12 +268,12 @@ class ManufacturerSerializer(OrganizationalModelSerializer): class Meta: model = Manufacturer fields = [ - 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', '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) @@ -271,9 +282,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', ] @@ -288,7 +299,9 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsolePortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): @@ -302,7 +315,9 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = ConsoleServerPortTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated'] + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'description', 'created', 'last_updated', + ] class PowerPortTemplateSerializer(ValidatedModelSerializer): @@ -317,8 +332,8 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerPortTemplate fields = [ - 'id', 'url', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'created', 'last_updated', ] @@ -342,8 +357,8 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): class Meta: model = PowerOutletTemplate fields = [ - 'id', 'url', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'created', - 'last_updated', + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'created', 'last_updated', ] @@ -355,7 +370,8 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): class Meta: model = InterfaceTemplate fields = [ - 'id', 'url', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'created', + 'last_updated', ] @@ -367,7 +383,8 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = RearPortTemplate fields = [ - 'id', 'url', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'positions', 'description', 'created', + 'last_updated', ] @@ -380,8 +397,8 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): class Meta: model = FrontPortTemplate fields = [ - 'id', 'url', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', - 'created', 'last_updated', + 'id', 'url', 'display', 'device_type', 'name', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'created', 'last_updated', ] @@ -391,7 +408,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): class Meta: model = DeviceBayTemplate - fields = ['id', 'url', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] + fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] # @@ -406,8 +423,8 @@ class DeviceRoleSerializer(OrganizationalModelSerializer): class Meta: model = DeviceRole fields = [ - 'id', 'url', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', 'last_updated', - 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'slug', 'color', 'vm_role', 'description', 'custom_fields', 'created', + 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -420,18 +437,19 @@ class PlatformSerializer(OrganizationalModelSerializer): class Meta: model = Platform fields = [ - 'id', 'url', 'name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description', 'custom_fields', - 'created', 'last_updated', '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) @@ -445,10 +463,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 = [] @@ -481,10 +499,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) @@ -496,7 +514,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( @@ -504,18 +526,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', 'mark_connected', 'cable', 'cable_peer', - 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + '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( @@ -523,18 +550,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', 'mark_connected', 'cable', 'cable_peer', - 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', - 'custom_fields', 'created', 'last_updated', '_occupied', + '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( @@ -557,13 +589,13 @@ class PowerOutletSerializer(TaggedObjectSerializer, CableTerminationSerializer, class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', - 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type', + '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( @@ -576,16 +608,17 @@ class PowerPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co class Meta: model = PowerPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + '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) @@ -601,10 +634,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', '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', + '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): @@ -621,7 +655,7 @@ class InterfaceSerializer(TaggedObjectSerializer, CableTerminationSerializer, Co return super().validate(data) -class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): +class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -630,8 +664,8 @@ class RearPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cus class Meta: model = RearPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', - 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -643,10 +677,10 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): class Meta: model = RearPort - fields = ['id', 'url', 'name', 'label'] + fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, CustomFieldModelSerializer): +class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() type = ChoiceField(choices=PortTypeChoices) @@ -656,13 +690,13 @@ class FrontPortSerializer(TaggedObjectSerializer, CableTerminationSerializer, Cu class Meta: model = FrontPort fields = [ - 'id', 'url', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', + '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, CustomFieldModelSerializer): +class DeviceBaySerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) @@ -670,8 +704,8 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = DeviceBay fields = [ - 'id', 'url', 'device', 'name', 'label', 'description', 'installed_device', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'device', 'name', 'label', 'description', 'installed_device', 'tags', + 'custom_fields', 'created', 'last_updated', ] @@ -679,7 +713,7 @@ class DeviceBaySerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Inventory items # -class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class InventoryItemSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() # Provide a default value to satisfy UniqueTogetherValidator @@ -690,8 +724,8 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer class Meta: model = InventoryItem fields = [ - 'id', 'url', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', + 'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth', ] @@ -699,7 +733,7 @@ class InventoryItemSerializer(TaggedObjectSerializer, CustomFieldModelSerializer # 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) @@ -715,7 +749,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', ] @@ -829,24 +863,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 @@ -855,15 +889,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( @@ -892,8 +921,8 @@ class PowerFeedSerializer( class Meta: model = PowerFeed fields = [ - 'id', 'url', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage', 'amperage', - 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', + '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 ae488a1e4..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 @@ -111,6 +105,22 @@ class RegionViewSet(CustomFieldModelViewSet): 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 # @@ -134,16 +144,16 @@ class SiteViewSet(CustomFieldModelViewSet): # Rack groups # -class RackGroupViewSet(CustomFieldModelViewSet): - 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 # @@ -164,7 +174,7 @@ class RackRoleViewSet(CustomFieldModelViewSet): class RackViewSet(CustomFieldModelViewSet): queryset = Rack.objects.prefetch_related( - 'site', 'group__site', 'role', 'tenant', 'tags' + 'site', 'location__site', 'role', 'tenant', 'tags' ).annotate( device_count=count_related(Device, 'rack'), powerfeed_count=count_related(PowerFeed, 'rack') @@ -345,7 +355,7 @@ class PlatformViewSet(CustomFieldModelViewSet): 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 @@ -522,7 +532,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): 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 @@ -619,7 +629,7 @@ class VirtualChassisViewSet(ModelViewSet): class PowerPanelViewSet(ModelViewSet): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d4cc0ecf4..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)'), ) ), diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index e89f156ac..8d19dc2f0 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -12,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__ = ( @@ -40,6 +34,7 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'LocationFilterSet', 'ManufacturerFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', @@ -51,13 +46,13 @@ __all__ = ( 'PowerPortFilterSet', 'PowerPortTemplateFilterSet', 'RackFilterSet', - 'RackGroupFilterSet', 'RackReservationFilterSet', 'RackRoleFilterSet', 'RearPortFilterSet', 'RearPortTemplateFilterSet', 'RegionFilterSet', 'SiteFilterSet', + 'SiteGroupFilterSet', 'VirtualChassisFilterSet', ) @@ -79,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', @@ -96,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: @@ -131,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', @@ -145,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)', @@ -156,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'] @@ -196,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)', @@ -206,18 +255,18 @@ 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, @@ -283,18 +332,18 @@ class RackReservationFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModel 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(), @@ -565,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)', @@ -575,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', @@ -721,6 +783,19 @@ class DeviceComponentFilterSet(CustomFieldModelFilterSet): 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(), @@ -844,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(), @@ -1038,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(), @@ -1226,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)', @@ -1236,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)', ) @@ -1281,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 fe050b99d..76de832da 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,19 +23,15 @@ from tenancy.forms import TenancyFilterForm, TenancyForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, - ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, NumericArrayField, SelectWithPK, - SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + ColorSelect, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, + NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) 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+\}' @@ -60,7 +56,7 @@ def get_device_by_name_or_pk(name): class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): field_order = [ - 'q', 'region_id', 'site_id' + 'q', 'region_id', 'site_group_id', 'site_id' ] q = forms.CharField( required=False, @@ -71,6 +67,11 @@ class DeviceComponentFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -200,6 +201,24 @@ class RegionCSVForm(CustomFieldModelCSVForm): 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( @@ -208,6 +227,63 @@ class RegionFilterForm(BootstrapMixin, forms.Form): ) +# +# 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') + ) + + # # Sites # @@ -217,6 +293,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( @@ -227,12 +307,14 @@ 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', 'facility', 'asn', 'time_zone', 'description', 'tags')), + ('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', @@ -278,6 +360,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, @@ -310,6 +398,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 @@ -332,7 +424,7 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class Meta: nullable_fields = [ - 'region', 'tenant', 'asn', 'description', 'time_zone', + 'region', 'group', 'tenant', 'asn', 'description', 'time_zone', ] @@ -353,14 +445,19 @@ class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label=_('Region') ) + group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Group') + ) tag = TagFilterField(model) # -# Rack groups +# Locations # -class RackGroupForm(BootstrapMixin, CustomFieldModelForm): +class LocationForm(BootstrapMixin, CustomFieldModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -368,14 +465,22 @@ class RackGroupForm(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', } ) parent = DynamicModelChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, query_params={ 'site_id': '$site' @@ -384,20 +489,20 @@ class RackGroupForm(BootstrapMixin, CustomFieldModelForm): 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(CustomFieldModelCSVForm): +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', @@ -407,11 +512,36 @@ class RackGroupCSVForm(CustomFieldModelCSVForm): ) class Meta: - model = RackGroup - fields = RackGroup.csv_headers + model = Location + fields = Location.csv_headers -class RackGroupFilterForm(BootstrapMixin, forms.Form): +class LocationBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Location.objects.all(), + widget=forms.MultipleHiddenInput + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + parent = DynamicModelChoiceField( + queryset=Location.objects.all(), + required=False, + query_params={ + 'site_id': '$site' + } + ) + 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, @@ -426,7 +556,7 @@ class RackGroupFilterForm(BootstrapMixin, forms.Form): label=_('Site') ) parent = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + queryset=Location.objects.all(), required=False, query_params={ 'region_id': '$region_id', @@ -461,6 +591,25 @@ class RackRoleCSVForm(CustomFieldModelCSVForm): } +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 # @@ -473,14 +622,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' @@ -499,9 +656,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", @@ -522,8 +679,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' ) @@ -568,9 +725,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): @@ -585,15 +742,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' @@ -661,13 +826,13 @@ 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_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, label=_('Search') @@ -685,14 +850,14 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): }, label=_('Site') ) - group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, null_option='None', query_params={ 'site_id': '$site_id' }, - label=_('Rack group') + label=_('Location') ) status = forms.MultipleChoiceField( choices=RackStatusChoices, @@ -723,15 +888,16 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): # class RackElevationFilterForm(RackFilterForm): - field_order = ['q', 'region_id', 'site_id', 'group_id', 'id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] + 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'), required=False, - display_field='display_name', query_params={ 'site_id': '$site_id', - 'group_id_id': '$group_id_id', + 'location_id': '$location_id', } ) @@ -748,15 +914,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' @@ -764,10 +938,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( @@ -788,10 +961,11 @@ class RackReservationForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = RackReservation fields = [ - 'region', 'site', 'rack_group', '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', 'rack_group', 'rack', 'units', 'user', 'description', 'tags')), + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -802,11 +976,11 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): 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(), @@ -827,21 +1001,21 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): 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) @@ -873,7 +1047,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): model = RackReservation - field_order = ['q', 'region_id', 'site_id', 'group_id', 'user_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'region_id', 'site_id', 'location_id', 'user_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, label=_('Search') @@ -891,16 +1065,15 @@ class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): }, 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, - display_field='username', label='User', widget=APISelectMultiple( api_url='/api/users/users/', @@ -930,6 +1103,20 @@ class ManufacturerCSVForm(CustomFieldModelCSVForm): 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 # @@ -1086,7 +1273,6 @@ class ComponentTemplateCreateForm(BootstrapMixin, ComponentForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1726,6 +1912,30 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm): } +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 # @@ -1763,6 +1973,29 @@ class PlatformCSVForm(CustomFieldModelCSVForm): 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 # @@ -1775,16 +2008,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' }, @@ -1795,10 +2036,9 @@ 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.IntegerField( @@ -1821,7 +2061,6 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ) device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -1864,9 +2103,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', + '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", @@ -2002,17 +2241,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, @@ -2023,7 +2262,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): @@ -2031,14 +2270,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) @@ -2097,7 +2336,6 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF device_type = DynamicModelChoiceField( queryset=DeviceType.objects.all(), required=False, - display_field='model', query_params={ 'manufacturer_id': '$manufacturer' } @@ -2134,7 +2372,7 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm): model = Device field_order = [ - 'q', 'region_id', 'site_id', 'rack_group_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', + 'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id', 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', ] q = forms.CharField( @@ -2152,10 +2390,10 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt '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_id': '$site_id' } @@ -2166,7 +2404,7 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt null_option='None', query_params={ 'site_id': '$site_id', - 'group_id': '$rack_group_id', + 'location_id': '$location_id', }, label=_('Rack') ) @@ -2183,7 +2421,6 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt device_type_id = DynamicModelMultipleChoiceField( queryset=DeviceType.objects.all(), required=False, - display_field='model', query_params={ 'manufacturer_id': '$manufacturer_id' }, @@ -2272,8 +2509,7 @@ 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, @@ -2312,6 +2548,11 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) @@ -2324,7 +2565,7 @@ class ConsolePortForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = ConsolePort fields = [ - 'device', 'name', 'label', 'type', 'mark_connected', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2338,11 +2579,16 @@ class ConsolePortCreateForm(ComponentCreateForm): required=False, widget=StaticSelect2() ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'mark_connected', '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', 'mark_connected']), + form_from_model(ConsolePort, ['type', 'speed', 'mark_connected']), DeviceBulkAddComponentForm ): model = ConsolePort @@ -2350,7 +2596,7 @@ class ConsolePortBulkCreateForm( class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'mark_connected', 'description']), + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm @@ -2359,6 +2605,10 @@ class ConsolePortBulkEditForm( queryset=ConsolePort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: nullable_fields = ['label', 'description'] @@ -2374,6 +2624,13 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm): 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 @@ -2392,6 +2649,11 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelect2Multiple() ) + speed = forms.MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False, + widget=StaticSelect2Multiple() + ) tag = TagFilterField(model) @@ -2404,7 +2666,7 @@ class ConsoleServerPortForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'label', 'type', 'description', 'tags', + 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -2418,19 +2680,24 @@ class ConsoleServerPortCreateForm(ComponentCreateForm): 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 ): model = ConsoleServerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'description', 'tags') + 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, CustomFieldBulkEditForm @@ -2439,6 +2706,10 @@ class ConsoleServerPortBulkEditForm( queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: nullable_fields = ['label', 'description'] @@ -2454,6 +2725,13 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): 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 @@ -2484,7 +2762,8 @@ class PowerPortForm(BootstrapMixin, CustomFieldModelForm): 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(), @@ -2509,12 +2788,13 @@ 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 @@ -2522,7 +2802,7 @@ class PowerPortBulkCreateForm( 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, CustomFieldBulkEditForm @@ -2531,6 +2811,10 @@ class PowerPortBulkEditForm( queryset=PowerPort.objects.all(), widget=forms.MultipleHiddenInput() ) + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) class Meta: nullable_fields = ['label', 'description'] @@ -2580,7 +2864,7 @@ class PowerOutletForm(BootstrapMixin, CustomFieldModelForm): 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(), @@ -2611,7 +2895,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) @@ -2624,7 +2911,7 @@ class PowerOutletCreateForm(ComponentCreateForm): class PowerOutletBulkCreateForm( - form_from_model(PowerOutlet, ['type', 'feed_leg']), + form_from_model(PowerOutlet, ['type', 'feed_leg', 'mark_connected']), DeviceBulkAddComponentForm ): model = PowerOutlet @@ -2632,7 +2919,7 @@ class PowerOutletBulkCreateForm( 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, CustomFieldBulkEditForm @@ -2647,6 +2934,10 @@ 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'] @@ -2744,11 +3035,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm): 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', @@ -2758,7 +3064,6 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): queryset=VLAN.objects.all(), required=False, label='Tagged VLANs', - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2772,13 +3077,12 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', - '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 = { @@ -2791,19 +3095,11 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): 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) @@ -2820,11 +3116,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, @@ -2849,7 +3155,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', @@ -2858,45 +3163,40 @@ 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 ): model = Interface - field_order = ('name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags') + field_order = ( + 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + ) class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'lag', 'mac_address', 'mtu', 'description', 'mode' + 'label', 'type', 'parent', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', ]), BootstrapMixin, AddRemoveTagsForm, @@ -2916,15 +3216,32 @@ class InterfaceBulkEditForm( 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', @@ -2933,7 +3250,6 @@ class InterfaceBulkEditForm( tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -2942,25 +3258,24 @@ class InterfaceBulkEditForm( class Meta: nullable_fields = [ - 'label', 'lag', 'mac_address', 'mtu', 'description', 'mode', 'untagged_vlan', 'tagged_vlans' + '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') @@ -2978,6 +3293,8 @@ 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 @@ -3000,6 +3317,12 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): 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, @@ -3074,7 +3397,8 @@ class FrontPortForm(BootstrapMixin, CustomFieldModelForm): 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(), @@ -3104,7 +3428,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) @@ -3162,7 +3488,7 @@ class FrontPortCreateForm(ComponentCreateForm): class FrontPortBulkEditForm( - form_from_model(FrontPort, ['label', 'type', 'description']), + form_from_model(FrontPort, ['label', 'type', 'mark_connected', 'description']), BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm @@ -3244,7 +3570,7 @@ class RearPortForm(BootstrapMixin, CustomFieldModelForm): 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(), @@ -3264,19 +3590,21 @@ 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 ): model = RearPort - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'description', 'tags') + 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, CustomFieldBulkEditForm @@ -3431,8 +3759,7 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm): class InventoryItemForm(BootstrapMixin, CustomFieldModelForm): device = DynamicModelChoiceField( - queryset=Device.objects.all(), - display_field='display_name' + queryset=Device.objects.all() ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), @@ -3571,19 +3898,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' @@ -3593,7 +3925,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', @@ -3710,18 +4041,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', @@ -3730,7 +4066,6 @@ class ConnectCableToCircuitTerminationForm(BootstrapMixin, CustomFieldModelForm) termination_b_id = DynamicModelChoiceField( queryset=CircuitTermination.objects.all(), label='Side', - display_field='term_side', disabled_indicator='_occupied', query_params={ 'circuit_id': '$termination_b_circuit' @@ -3759,19 +4094,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' } @@ -3782,7 +4122,7 @@ 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( @@ -3801,7 +4141,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', ] @@ -4138,18 +4478,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' } @@ -4157,7 +4504,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', @@ -4176,7 +4522,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): @@ -4280,25 +4626,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', @@ -4344,7 +4696,7 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VirtualChassis - field_order = ['q', 'region_id', 'site_id', 'tenant_group_id', 'tenant_id'] + field_order = ['q', 'region_id', 'site_group_id', 'site_id', 'tenant_group_id', 'tenant_id'] q = forms.CharField( required=False, label=_('Search') @@ -4354,6 +4706,11 @@ class VirtualChassisFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -4377,14 +4734,23 @@ class PowerPanelForm(BootstrapMixin, 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, query_params={ 'site_id': '$site' @@ -4398,10 +4764,10 @@ 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', 'rack_group', 'name', 'tags')), + ('Power Panel', ('region', 'site', 'location', 'name', 'tags')), ) @@ -4411,8 +4777,8 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): 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' ) @@ -4428,7 +4794,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): # 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, CustomFieldBulkEditForm): @@ -4443,15 +4809,23 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE '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' @@ -4459,7 +4833,7 @@ class PowerPanelBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkE ) class Meta: - nullable_fields = ['rack_group'] + nullable_fields = ['location'] class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -4473,6 +4847,11 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -4481,14 +4860,14 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): }, label=_('Site') ) - rack_group_id = DynamicModelMultipleChoiceField( - queryset=RackGroup.objects.all(), + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, null_option='None', query_params={ 'site_id': '$site_id' }, - label=_('Rack group') + label=_('Location') ) tag = TagFilterField(model) @@ -4505,6 +4884,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, @@ -4512,7 +4898,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): 'powerpanel': '$power_panel' }, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) power_panel = DynamicModelChoiceField( @@ -4524,7 +4911,6 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, - display_field='display_name', query_params={ 'site_id': '$site' } @@ -4538,8 +4924,8 @@ class PowerFeedForm(BootstrapMixin, CustomFieldModelForm): class Meta: model = PowerFeed fields = [ - 'region', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', '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')), @@ -4565,11 +4951,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(), @@ -4611,14 +4997,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) @@ -4635,7 +5021,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), @@ -4670,6 +5055,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' @@ -4677,7 +5066,7 @@ class PowerFeedBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd class Meta: nullable_fields = [ - 'rackgroup', 'comments', + 'location', 'comments', ] @@ -4692,6 +5081,11 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=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 06ae150d4..c8166cb44 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -6,13 +6,11 @@ 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 TaggedItem from extras.utils import extras_features from netbox.models import BigIDModel, PrimaryModel from utilities.fields import ColorField @@ -108,7 +106,6 @@ class Cable(PrimaryModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 51956f924..d99d6b7c3 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -4,13 +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 BigIDModel, ChangeLoggingMixin +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, ) @@ -28,7 +26,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel): +class ComponentTemplateModel(ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -75,16 +73,10 @@ class ComponentTemplateModel(ChangeLoggingMixin, BigIDModel): 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('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class ConsolePortTemplate(ComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -108,7 +100,7 @@ class ConsolePortTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class ConsoleServerPortTemplate(ComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -132,7 +124,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class PowerPortTemplate(ComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -179,7 +171,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class PowerOutletTemplate(ComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -231,7 +223,7 @@ class PowerOutletTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class InterfaceTemplate(ComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -266,7 +258,7 @@ class InterfaceTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class FrontPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -327,7 +319,7 @@ class FrontPortTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class RearPortTemplate(ComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -358,7 +350,7 @@ class RearPortTemplate(ComponentTemplateModel): ) -@extras_features('custom_fields', 'export_templates', 'webhooks') +@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 6e6f4ded5..4b162b30f 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -6,12 +6,10 @@ 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 @@ -19,7 +17,6 @@ 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__ = ( @@ -82,17 +79,11 @@ class ComponentModel(PrimaryModel): 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): @@ -159,6 +150,10 @@ class CableTermination(models.Model): 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): """ @@ -213,8 +208,8 @@ class PathEndpoint(models.Model): # Console ports # -@extras_features('custom_fields', '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. """ @@ -224,9 +219,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', 'mark_connected', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] class Meta: ordering = ('device', '_name') @@ -241,6 +241,7 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, self.mark_connected, self.description, ) @@ -250,8 +251,8 @@ class ConsolePort(CableTermination, PathEndpoint, ComponentModel): # Console server ports # -@extras_features('custom_fields', 'export_templates', '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. """ @@ -261,9 +262,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', 'mark_connected', 'description'] + csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description'] class Meta: ordering = ('device', '_name') @@ -278,6 +284,7 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): self.name, self.label, self.type, + self.speed, self.mark_connected, self.description, ) @@ -287,8 +294,8 @@ class ConsoleServerPort(CableTermination, PathEndpoint, ComponentModel): # Power ports # -@extras_features('custom_fields', '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. """ @@ -310,7 +317,6 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): validators=[MinValueValidator(1)], help_text="Allocated power draw (watts)" ) - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description', @@ -399,8 +405,8 @@ class PowerPort(CableTermination, PathEndpoint, ComponentModel): # Power outlets # -@extras_features('custom_fields', 'export_templates', '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. """ @@ -423,7 +429,6 @@ class PowerOutlet(CableTermination, PathEndpoint, ComponentModel): blank=True, help_text="Phase (for three-phase feeds)" ) - tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description'] @@ -500,8 +505,8 @@ class BaseInterface(models.Model): return super().save(*args, **kwargs) -@extras_features('custom_fields', '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. """ @@ -512,6 +517,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, @@ -549,11 +562,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', 'mark_connected', '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: @@ -568,6 +580,7 @@ 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, @@ -591,6 +604,27 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): "Disconnect the interface or choose a suitable type." }) + # 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: @@ -612,16 +646,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 @@ -647,8 +677,8 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface): # Pass-through ports # -@extras_features('custom_fields', 'export_templates', '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. """ @@ -668,7 +698,6 @@ class FrontPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) csv_headers = [ 'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description', @@ -713,8 +742,8 @@ class FrontPort(CableTermination, ComponentModel): }) -@extras_features('custom_fields', 'export_templates', '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. """ @@ -729,7 +758,6 @@ class RearPort(CableTermination, ComponentModel): MaxValueValidator(REARPORT_POSITIONS_MAX) ] ) - tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description'] @@ -767,7 +795,7 @@ class RearPort(CableTermination, ComponentModel): # Device bays # -@extras_features('custom_fields', 'export_templates', '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 @@ -779,7 +807,6 @@ class DeviceBay(ComponentModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) csv_headers = ['device', 'name', 'label', 'installed_device', 'description'] @@ -827,7 +854,7 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('custom_fields', '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. @@ -872,8 +899,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 e73e4e73c..7f22d9325 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -9,11 +9,10 @@ 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 ConfigContextModel, TaggedItem +from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel @@ -136,7 +135,6 @@ class DeviceType(PrimaryModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -517,6 +515,13 @@ class Device(PrimaryModel, ConfigContextModel): 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, @@ -594,16 +599,15 @@ class Device(PrimaryModel, ConfigContextModel): 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: @@ -640,11 +644,17 @@ class Device(PrimaryModel, ConfigContextModel): 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': 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: @@ -799,7 +809,7 @@ class Device(PrimaryModel, ConfigContextModel): 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(), @@ -903,7 +913,6 @@ class VirtualChassis(PrimaryModel): 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 208b99809..06d234149 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -2,11 +2,9 @@ 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 TaggedItem from extras.utils import extras_features from netbox.models import PrimaryModel from utilities.querysets import RestrictedQuerySet @@ -32,8 +30,8 @@ class PowerPanel(PrimaryModel): 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 @@ -41,11 +39,10 @@ class PowerPanel(PrimaryModel): 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'] @@ -60,17 +57,17 @@ class PowerPanel(PrimaryModel): 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 )) @@ -133,12 +130,11 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() csv_headers = [ - 'site', 'power_panel', 'rack_group', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', + 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', ] clone_fields = [ @@ -160,7 +156,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): 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(), @@ -201,7 +197,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CableTermination): 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 c4ec76525..1942e3cb0 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -10,27 +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 TreeForeignKey -from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * from dcim.elevations import RackElevationSVG -from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features -from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel +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', ) @@ -40,66 +35,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'export_templates', 'webhooks') -class RackGroup(NestedGroupModel): - """ - 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 - ) - - csv_headers = ['site', 'parent', 'name', 'slug', 'description'] - - class Meta: - ordering = ['site', 'name'] - unique_together = [ - ['site', 'name'], - ['site', 'slug'], - ] - - 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 clean(self): - super().clean() - - # 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})") - - @extras_features('custom_fields', 'export_templates', 'webhooks') class RackRole(OrganizationalModel): """ @@ -147,7 +82,7 @@ class RackRole(OrganizationalModel): 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 @@ -169,13 +104,12 @@ class Rack(PrimaryModel): 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', @@ -254,25 +188,24 @@ class Rack(PrimaryModel): 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): @@ -284,9 +217,9 @@ class Rack(PrimaryModel): def clean(self): super().clean() - # Validate group/site assignment - if self.site and self.group and self.group.site != self.site: - raise ValidationError(f"Assigned rack group must belong to parent site ({self.site}).") + # 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: @@ -309,17 +242,17 @@ class Rack(PrimaryModel): 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 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,11 +494,10 @@ class RackReservation(PrimaryModel): 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'] @@ -606,7 +538,7 @@ class RackReservation(PrimaryModel): 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 417e0b914..51cb63d08 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -1,24 +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 TreeForeignKey -from taggit.managers import TaggableManager from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * from dcim.fields import ASNField -from extras.models import 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', ) @@ -29,7 +28,9 @@ __all__ = ( @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', @@ -72,6 +73,58 @@ class Region(NestedGroupModel): ).count() +# +# 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 "{}?group={}".format(reverse('dcim:site_list'), self.slug) + + 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 # @@ -107,6 +160,13 @@ class Site(PrimaryModel): 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, @@ -172,16 +232,16 @@ class Site(PrimaryModel): 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', ] @@ -200,6 +260,7 @@ class Site(PrimaryModel): 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, @@ -217,3 +278,66 @@ class Site(PrimaryModel): 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 "{}?location_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 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 277e3f060..1dbdca140 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -7,7 +7,7 @@ from django.db import transaction from django.dispatch import receiver from .choices import CableStatusChoices -from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, RackGroup, VirtualChassis +from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis def create_cablepath(node): @@ -37,36 +37,30 @@ def rebuild_paths(obj): # -# Site/rack/device assignment +# Location/rack/device assignment # -@receiver(post_save, sender=RackGroup) -def handle_rackgroup_site_change(instance, created, **kwargs): +@receiver(post_save, sender=Location) +def handle_location_site_change(instance, created, **kwargs): """ - Update child RackGroups and Racks if Site assignment has changed. We intentionally recurse through each child + 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: - for rackgroup in instance.get_children(): - rackgroup.site = instance.site - rackgroup.save() - for rack in Rack.objects.filter(group=instance).exclude(site=instance.site): - rack.site = instance.site - rack.save() - for powerpanel in PowerPanel.objects.filter(rack_group=instance).exclude(site=instance.site): - powerpanel.site = instance.site - powerpanel.save() + 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 assignment has changed. + Update child Devices if Site or Location assignment has changed. """ if not created: - for device in Device.objects.filter(rack=instance).exclude(site=instance.site): - device.site = instance.site - device.save() + Device.objects.filter(rack=instance).update(site=instance.site, location=instance.location) # diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 1cd3c10a6..c4204dd4a 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, @@ -109,12 +110,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,11 +129,18 @@ class DeviceTable(BaseTable): verbose_name='Type', text=lambda record: record.device_type.display_name ) - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip6', 'primary_ip4'), - 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' @@ -162,11 +171,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', ) @@ -175,9 +184,7 @@ class DeviceImportTable(BaseTable): template_code=DEVICE_LINK ) status = ChoiceFieldColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() site = tables.Column( linkify=True ) @@ -249,10 +256,10 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', + '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): @@ -269,10 +276,10 @@ class DeviceConsolePortTable(ConsolePortTable): class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'mark_connected', '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 '' } @@ -286,10 +293,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'device', 'name', 'label', 'type', 'description', 'mark_connected', 'cable', 'cable_peer', + '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 DeviceConsoleServerPortTable(ConsoleServerPortTable): @@ -307,10 +314,10 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'name', 'label', 'type', 'description', 'mark_connected', '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 '' } @@ -437,6 +444,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' @@ -450,13 +461,13 @@ class DeviceInterfaceTable(InterfaceTable): class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description', - 'mark_connected', '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 '', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ac7590aa2..ce69b0ede 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -33,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') # diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 1ac0d17bb..eb4b28710 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -1,18 +1,18 @@ 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', ) @@ -22,13 +22,9 @@ __all__ = ( # Rack groups # -class RackGroupTable(BaseTable): +class LocationTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} - ) + name = MPTTColumn() site = tables.Column( linkify=True ) @@ -36,12 +32,12 @@ class RackGroupTable(BaseTable): 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') @@ -79,9 +75,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( @@ -104,13 +98,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' ) @@ -143,9 +134,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 50a5e5ec7..e22037255 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,11 +17,7 @@ __all__ = ( class RegionTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} - ) + name = MPTTColumn() site_count = tables.Column( verbose_name='Sites' ) @@ -33,6 +29,24 @@ class RegionTable(BaseTable): default_columns = ('pk', 'name', 'site_count', 'description', 'actions') +# +# Site groups +# + +class SiteGroupTable(BaseTable): + pk = ToggleColumn() + name = MPTTColumn() + 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 # @@ -46,9 +60,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' ) @@ -56,8 +71,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 c8da868b9..e12f6ef87 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,13 +56,6 @@ INTERFACE_TAGGED_VLANS = """ {% endif %} """ -MPTT_LINK = """ -{% for i in record.get_ancestors %} - -{% endfor %} -{{ record.name }} -""" - POWERFEED_CABLE = """ {{ value }} @@ -71,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 # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index d02bc8a5c..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', } @@ -771,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', @@ -806,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', @@ -838,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', } @@ -979,7 +982,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsolePort - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1018,7 +1021,7 @@ class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = ConsoleServerPort - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1057,7 +1060,7 @@ class ConsoleServerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIView class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerPort - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1096,7 +1099,7 @@ class PowerPortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = PowerOutlet - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1135,7 +1138,7 @@ class PowerOutletTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCa class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase): model = Interface - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1193,7 +1196,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase class FrontPortTest(APIViewTestCases.APIViewTestCase): model = FrontPort - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1251,7 +1254,7 @@ class FrontPortTest(APIViewTestCases.APIViewTestCase): class RearPortTest(APIViewTestCases.APIViewTestCase): model = RearPort - brief_fields = ['_occupied', 'cable', 'device', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url'] bulk_update_data = { 'description': 'New description', } @@ -1293,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', } @@ -1356,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', } @@ -1394,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', @@ -1579,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): @@ -1588,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) @@ -1606,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 = ['_occupied', 'cable', 'id', 'name', 'url'] + brief_fields = ['_occupied', 'cable', 'display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'planned', } @@ -1636,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 a76788e65..847618f41 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_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], 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) @@ -388,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]} @@ -395,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): @@ -448,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) @@ -503,11 +592,11 @@ 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): @@ -1161,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) @@ -1207,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) @@ -1324,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]} @@ -1331,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): @@ -1463,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') @@ -1524,6 +1637,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]} @@ -1559,10 +1679,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') @@ -1620,6 +1749,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]} @@ -1655,10 +1791,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') @@ -1724,6 +1869,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]} @@ -1759,10 +1911,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') @@ -1825,6 +1986,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]} @@ -1860,10 +2028,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') @@ -1931,6 +2108,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]} @@ -1938,6 +2143,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]} @@ -1987,10 +2199,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') @@ -2054,6 +2275,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]} @@ -2089,10 +2317,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') @@ -2150,6 +2387,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]} @@ -2185,10 +2429,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') @@ -2228,6 +2481,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]} @@ -2268,10 +2528,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) @@ -2328,6 +2596,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]} @@ -2381,10 +2656,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) @@ -2435,6 +2718,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]} @@ -2582,25 +2872,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) @@ -2619,6 +2917,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]} @@ -2626,9 +2931,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) @@ -2647,10 +2952,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) @@ -2731,6 +3044,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_models.py b/netbox/dcim/tests/test_models.py index 184681e90..815d86758 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -7,39 +7,66 @@ from dcim.models import * from tenancy.models import Tenant -class RackGroupTestCase(TestCase): +class LocationTestCase(TestCase): - def test_change_rackgroup_site(self): + def test_change_location_site(self): """ - Check that all child RackGroups and Racks get updated when a RackGroup is moved to a new Site. Topology: + Check that all child Locations and Racks get updated when a Location is moved to a new Site. Topology: Site A - - RackGroup A1 - - RackGroup A2 + - 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') - rackgroup_a1 = RackGroup(site=site_a, name='RackGroup A1', slug='rackgroup-a1') - rackgroup_a1.save() - rackgroup_a2 = RackGroup(site=site_a, parent=rackgroup_a1, name='RackGroup A2', slug='rackgroup-a2') - rackgroup_a2.save() + 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, group=rackgroup_a1, name='Rack 1') - rack2 = Rack.objects.create(site=site_a, group=rackgroup_a2, name='Rack 2') + 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') - powerpanel1 = PowerPanel.objects.create(site=site_a, rack_group=rackgroup_a1, name='Power Panel 1') + 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 + ) - # Move RackGroup A1 to Site B - rackgroup_a1.site = site_b - rackgroup_a1.save() + powerpanel1 = PowerPanel.objects.create(site=site_a, location=location_a1, name='Power Panel 1') - # Check that all objects within RackGroup A1 now belong to Site B - self.assertEqual(RackGroup.objects.get(pk=rackgroup_a1.pk).site, site_b) - self.assertEqual(RackGroup.objects.get(pk=rackgroup_a2.pk).site, site_b) + # 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) @@ -55,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 @@ -69,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( @@ -134,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): diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 86518af84..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, @@ -336,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 @@ -885,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 @@ -916,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 @@ -929,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) @@ -991,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 = { @@ -1771,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 6cf61df24..e7c29ae9f 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,11 +12,22 @@ 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//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//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'), @@ -31,21 +38,24 @@ urlpatterns = [ 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//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//edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'), path('rack-roles//delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'), @@ -61,6 +71,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,12 +84,14 @@ 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'), @@ -94,6 +107,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,6 +177,7 @@ 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'), @@ -172,6 +187,7 @@ urlpatterns = [ 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'), @@ -198,6 +214,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 +370,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 +387,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 +401,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 +414,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 7df0ea458..4ca9b8498 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 @@ -30,8 +30,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, ) @@ -126,6 +126,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 +151,63 @@ 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 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,17 +231,17 @@ 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, } @@ -207,44 +277,57 @@ 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 LocationEditView(generic.ObjectEditView): + queryset = Location.objects.all() + model_form = forms.LocationForm -class RackGroupDeleteView(generic.ObjectDeleteView): - queryset = RackGroup.objects.all() +class LocationDeleteView(generic.ObjectDeleteView): + queryset = Location.objects.all() -class RackGroupBulkImportView(generic.BulkImportView): - queryset = RackGroup.objects.all() - model_form = forms.RackGroupCSVForm - table = tables.RackGroupTable +class LocationBulkImportView(generic.BulkImportView): + queryset = Location.objects.all() + model_form = forms.LocationCSVForm + table = tables.LocationTable -class RackGroupBulkDeleteView(generic.BulkDeleteView): - queryset = RackGroup.objects.add_related_count( - RackGroup.objects.all(), +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 # @@ -273,6 +356,15 @@ 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=count_related(Rack, 'role') @@ -286,7 +378,7 @@ 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=count_related(Device, 'rack') ) @@ -338,7 +430,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 @@ -349,10 +441,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() @@ -390,14 +482,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 @@ -490,6 +582,15 @@ 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=count_related(DeviceType, 'manufacturer') @@ -931,6 +1032,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 @@ -963,6 +1071,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 @@ -982,7 +1097,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): @@ -1268,6 +1383,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 @@ -2178,13 +2297,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) @@ -2560,7 +2681,7 @@ class VirtualChassisBulkDeleteView(generic.BulkDeleteView): class PowerPanelListView(generic.ObjectListView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) @@ -2570,7 +2691,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') @@ -2601,7 +2722,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 @@ -2609,7 +2730,7 @@ class PowerPanelBulkEditView(generic.BulkEditView): class PowerPanelBulkDeleteView(generic.BulkDeleteView): queryset = PowerPanel.objects.prefetch_related( - 'site', 'rack_group' + 'site', 'location' ).annotate( powerfeed_count=count_related(PowerFeed, 'power_panel') ) 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/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..d1eea15ee 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -5,16 +5,15 @@ from rest_framework import serializers from dcim.api.nested_serializers import ( NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedRackSerializer, - NestedRegionSerializer, NestedSiteSerializer, + NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer, ) -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, 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,6 +239,12 @@ 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, @@ -218,8 +297,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', + 'roles', 'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', 'created', + 'last_updated', ] @@ -227,7 +307,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 +320,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 +398,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 +417,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 +445,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 1067ac0d3..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 @@ -55,6 +53,17 @@ class ConfigContextQuerySetMixin: 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 + + # # Custom fields # @@ -84,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 # @@ -118,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 47c3a1039..33c70f70d 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -87,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 e3c313735..3ac25eec4 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -4,12 +4,12 @@ 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, 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__ = ( @@ -17,12 +17,15 @@ __all__ = ( 'ContentTypeFilterSet', 'CreatedUpdatedFilterSet', 'CustomFieldFilter', + 'CustomLinkFilterSet', 'CustomFieldModelFilterSet', 'ExportTemplateFilterSet', 'ImageAttachmentFilterSet', + 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'ObjectChangeFilterSet', 'TagFilterSet', + 'WebhookFilterSet', ) EXACT_FILTER_TYPES = ( @@ -33,6 +36,20 @@ 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. @@ -79,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: @@ -94,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', @@ -129,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(), diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index ebb6ac5d4..b12ecc11d 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,16 +4,16 @@ 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, Platform, Region, Site, SiteGroup from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, - CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2, + CommentField, 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 # @@ -210,6 +210,10 @@ 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 @@ -249,8 +253,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', 'platforms', + 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'tags', 'data', ) @@ -280,8 +284,8 @@ class ConfigContextBulkEditForm(BootstrapMixin, BulkEditForm): class ConfigContextFilterForm(BootstrapMixin, forms.Form): field_order = [ - 'q', 'region_id', 'site_id', 'role_id', 'platform_id', 'cluster_group_id', 'cluster_id', 'tenant_group_id', - 'tenant_id', + '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, @@ -292,6 +296,11 @@ class ConfigContextFilterForm(BootstrapMixin, forms.Form): required=False, label=_('Regions') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site groups') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -362,6 +371,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 # @@ -390,7 +471,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, - display_field='username', label=_('User'), widget=APISelectMultiple( api_url='/api/users/users/', @@ -399,7 +479,6 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): changed_object_type_id = DynamicModelMultipleChoiceField( queryset=ContentType.objects.all(), required=False, - display_field='display_name', 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/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_sitegroup.py b/netbox/extras/migrations/0056_sitegroup.py new file mode 100644 index 000000000..b81cdb8a1 --- /dev/null +++ b/netbox/extras/migrations/0056_sitegroup.py @@ -0,0 +1,17 @@ +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'), + ), + ] 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..4ed5c7bc7 --- /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_sitegroup'), + ] + + 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 95132e43a..84676453f 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -1,9 +1,7 @@ from .change_logging import ObjectChange +from .configcontexts import ConfigContext, ConfigContextModel from .customfields import CustomField -from .models import ( - ConfigContext, ConfigContextModel, CustomLink, ExportTemplate, ImageAttachment, JobResult, Report, Script, - Webhook, -) +from .models import CustomLink, ExportTemplate, ImageAttachment, JobResult, JournalEntry, Report, Script, Webhook from .tags import Tag, TaggedItem __all__ = ( @@ -14,6 +12,7 @@ __all__ = ( 'ExportTemplate', 'ImageAttachment', 'JobResult', + 'JournalEntry', 'ObjectChange', 'Report', 'Script', diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index a06043e7b..fac86b641 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -67,15 +67,22 @@ class ObjectChange(BigIDModel): 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: @@ -114,7 +121,8 @@ class ObjectChange(BigIDModel): 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..b7da6852c --- /dev/null +++ b/netbox/extras/models/configcontexts.py @@ -0,0 +1,161 @@ +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 + ) + 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/models.py b/netbox/extras/models/models.py index bf0dfc873..61d06d264 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,17 +7,27 @@ 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.querysets import ConfigContextQuerySet from extras.utils import extras_features, FeatureQuery, image_upload -from netbox.models import BigIDModel, ChangeLoggingMixin +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', +) # @@ -109,6 +118,8 @@ class Webhook(BigIDModel): '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',) @@ -172,13 +183,13 @@ class CustomLink(BigIDModel): 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( @@ -196,9 +207,12 @@ class CustomLink(BigIDModel): 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'] @@ -358,142 +372,51 @@ class ImageAttachment(BigIDModel): # -# Config contexts +# Journal entries # -class ConfigContext(ChangeLoggingMixin, BigIDModel): +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 + return f"{self.created} - {self.get_kind_display()}" - 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}'} - ) + def get_kind_class(self): + return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) # diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 3a5b539b1..4c61f7f88 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -2,7 +2,8 @@ from django.db import models from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from netbox.models import BigIDModel, ChangeLoggingMixin +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 +13,8 @@ from utilities.querysets import RestrictedQuerySet # Tags # -class Tag(ChangeLoggingMixin, BigIDModel, TagBase): +@extras_features('webhooks') +class Tag(ChangeLoggedModel, TagBase): color = ColorField( default=ColorChoices.COLOR_GREY ) 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 7aeddb48f..e034b915a 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -2,7 +2,7 @@ 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 +from .models import ConfigContext, JournalEntry, ObjectChange, Tag, TaggedItem TAGGED_ITEM = """ {% if value.get_absolute_url %} @@ -96,3 +96,47 @@ class ObjectChangeTable(BaseTable): class Meta(BaseTable.Meta): model = ObjectChange fields = ('time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') + + +class JournalEntryTable(BaseTable): + pk = ToggleColumn() + created = tables.DateTimeColumn( + format=settings.SHORT_DATETIME_FORMAT + ) + assigned_object_type = tables.Column( + verbose_name='Object type' + ) + assigned_object = tables.Column( + linkify=True, + orderable=False, + verbose_name='Object' + ) + kind = ChoiceFieldColumn() + actions = ButtonsColumn( + model=JournalEntry, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ( + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' + ) + + +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() + actions = ButtonsColumn( + model=JournalEntry, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = JournalEntry + fields = ('created', 'created_by', '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 c0732649b..5e44c83d1 100644 --- a/netbox/extras/tests/test_changelog.py +++ b/netbox/extras/tests/test_changelog.py @@ -40,8 +40,8 @@ class ChangeLogViewTest(ModelViewTestCase): 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', @@ -56,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), @@ -64,20 +64,21 @@ 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].object_data['custom_fields']['my_field_select'], form_data['cf_my_field_select']) + 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', @@ -100,14 +101,16 @@ 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['custom_fields']['my_field_select'], form_data['cf_my_field_select']) - 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_select': 'Bar' @@ -129,15 +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['custom_fields']['my_field_select'], 'Bar') - 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) @@ -169,8 +240,8 @@ 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_select': 'Bar', @@ -195,17 +266,18 @@ 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_select': 'Foo', @@ -229,13 +301,13 @@ 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_select': 'Bar' @@ -255,6 +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['custom_fields']['my_field_select'], 'Bar') - 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_filters.py b/netbox/extras/tests/test_filters.py index 7bcd83e81..5e1b0401d 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, 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'), @@ -195,6 +431,7 @@ class ConfigContextTestCase(TestCase): data='{"foo": 123}' ) c.regions.set([regions[i]]) + c.site_groups.set([site_groups[i]]) c.sites.set([sites[i]]) c.roles.set([device_roles[i]]) c.platforms.set([platforms[i]]) @@ -224,6 +461,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]} @@ -327,7 +571,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 +580,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 +589,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 +598,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 +607,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 +616,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 d2f0a2eb2..ee435307d 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -31,6 +31,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 a6315aa51..976c13760 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -4,6 +4,7 @@ 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 @@ -16,7 +17,7 @@ from utilities.utils import copy_safe_request, count_related, shallow_compare_di 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 @@ -178,16 +179,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, @@ -279,6 +282,120 @@ 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( + data=journalentries, + orderable=False + ) + + # Apply the request context + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(journalentry_table) + + 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" + # 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_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/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 002ad3b89..931e2cc47 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -6,13 +6,12 @@ from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer -from netbox.api.serializers 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 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 @@ -23,7 +22,7 @@ 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 = SerializedPKRelatedField( @@ -44,8 +43,9 @@ class VRFSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): 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', ] @@ -53,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', ] @@ -75,12 +75,12 @@ class RIRSerializer(OrganizationalModelSerializer): class Meta: model = RIR fields = [ - 'id', 'url', 'name', 'slug', 'is_private', 'description', 'custom_fields', 'created', 'last_updated', - 'aggregate_count', + '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() @@ -89,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'] @@ -107,21 +107,28 @@ class RoleSerializer(OrganizationalModelSerializer): class Meta: model = Role fields = [ - 'id', 'url', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'weight', 'description', 'custom_fields', 'created', 'last_updated', 'prefix_count', 'vlan_count', ] 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', 'custom_fields', 'created', 'last_updated', - 'vlan_count', + 'id', 'url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'description', 'custom_fields', + 'created', 'last_updated', 'vlan_count', ] validators = [] @@ -138,8 +145,16 @@ class VLANGroupSerializer(OrganizationalModelSerializer): 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) @@ -151,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 = [] @@ -174,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) @@ -187,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'] @@ -244,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) @@ -263,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', ] @@ -302,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) @@ -317,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 b6f0a7463..1e1177772 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -283,7 +283,7 @@ class IPAddressViewSet(CustomFieldModelViewSet): # class VLANGroupViewSet(CustomFieldModelViewSet): - queryset = VLANGroup.objects.prefetch_related('site').annotate( + queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') ) serializer_class = serializers.VLANGroupSerializer 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 664081010..0262d85dd 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -1,7 +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, ) @@ -13,7 +13,7 @@ from utilities.forms import ( 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 @@ -217,6 +217,24 @@ class RIRCSVForm(CustomFieldModelCSVForm): } +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, @@ -351,6 +369,23 @@ class RoleCSVForm(CustomFieldModelCSVForm): 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 # @@ -359,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(), @@ -369,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( @@ -393,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', @@ -416,7 +457,7 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): ] fieldsets = ( ('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'description', 'tags')), - ('Site/VLAN Assignment', ('region', 'site', 'vlan_group', 'vlan')), + ('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')), ('Tenancy', ('tenant_group', 'tenant')), ) widgets = { @@ -497,18 +538,22 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF queryset=Region.objects.all(), required=False ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$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, @@ -547,8 +592,8 @@ 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_id', 'site_id', - 'role_id', 'tenant_group_id', 'tenant_id', 'is_pool', + '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() @@ -599,6 +644,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm) required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, @@ -632,7 +682,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel device = DynamicModelChoiceField( queryset=Device.objects.all(), required=False, - display_field='display_name', initial_params={ 'interfaces': '$interface' } @@ -662,8 +711,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(), @@ -673,19 +721,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' @@ -695,7 +751,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', @@ -717,14 +772,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', @@ -833,8 +886,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(), @@ -965,8 +1017,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, @@ -1089,11 +1140,54 @@ class VLANGroupForm(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, + 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() @@ -1101,9 +1195,43 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm): 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', {}) + + 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( @@ -1119,21 +1247,50 @@ class VLANGroupCSVForm(CustomFieldModelCSVForm): 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_id = DynamicModelMultipleChoiceField( + region = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, label=_('Region') ) - site_id = DynamicModelMultipleChoiceField( + sitegroup = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) + site = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, label=_('Site') ) + location = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) + rack = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + label=_('Rack') + ) # @@ -1141,28 +1298,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 @@ -1177,11 +1365,6 @@ class VLANForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): fields = [ 'site', 'group', 'vid', 'name', 'status', 'role', 'description', 'tenant_group', 'tenant', 'tags', ] - fieldsets = ( - ('VLAN', ('vid', 'name', 'status', 'role', 'description', 'tags')), - ('Assignment', ('region', 'site', 'group')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'site': "Leave blank if this VLAN spans multiple sites", 'group': "VLAN group (optional)", @@ -1233,15 +1416,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( @@ -1252,11 +1426,16 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor queryset=Region.objects.all(), required=False ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) group = DynamicModelChoiceField( @@ -1292,7 +1471,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN - field_order = ['q', 'region_id', 'site_id', 'group_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id'] + 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' @@ -1302,6 +1483,11 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): required=False, label=_('Region') ) + site_group_id = DynamicModelMultipleChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + label=_('Site group') + ) site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=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/ip.py b/netbox/ipam/models/ip.py index d8d118a99..9867f6069 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -6,10 +6,8 @@ from django.core.exceptions import ValidationError 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 -from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from netbox.models import OrganizationalModel, PrimaryModel from ipam.choices import * @@ -19,7 +17,6 @@ from ipam.managers import IPAddressManager from ipam.querysets import PrefixQuerySet from ipam.validators import DNSValidator from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from virtualization.models import VirtualMachine @@ -108,7 +105,6 @@ class Aggregate(PrimaryModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -293,7 +289,6 @@ class Prefix(PrimaryModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = PrefixQuerySet.as_manager() @@ -565,7 +560,6 @@ class IPAddress(PrimaryModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = IPAddressManager() @@ -649,13 +643,7 @@ class IPAddress(PrimaryModel): 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): diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 57338cce0..336f3a269 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -3,9 +3,7 @@ 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 extras.models import TaggedItem from extras.utils import extras_features from ipam.choices import * from ipam.constants import * @@ -66,7 +64,6 @@ class Service(PrimaryModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 131212564..26cb5299f 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -1,11 +1,11 @@ +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 taggit.managers import TaggableManager from dcim.models import Interface -from extras.models import TaggedItem from extras.utils import extras_features from ipam.choices import * from ipam.constants import * @@ -31,13 +31,23 @@ class VLANGroup(OrganizationalModel): slug = models.SlugField( max_length=100 ) - site = models.ForeignKey( - to='dcim.Site', - on_delete=models.PROTECT, - related_name='vlan_groups', + 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 @@ -45,13 +55,13 @@ class VLANGroup(OrganizationalModel): objects = RestrictedQuerySet.as_manager() - csv_headers = ['name', 'slug', 'site', 'description'] + csv_headers = ['name', 'slug', 'scope_type', 'scope_id', 'description'] class Meta: - ordering = ('site', 'name', 'pk') # (site, name) may be non-unique + ordering = ('name', 'pk') # Name may be non-unique unique_together = [ - ['site', 'name'], - ['site', 'slug'], + ['scope_type', 'scope_id', 'name'], + ['scope_type', 'scope_id', 'slug'], ] verbose_name = 'VLAN group' verbose_name_plural = 'VLAN groups' @@ -62,11 +72,21 @@ class VLANGroup(OrganizationalModel): def get_absolute_url(self): return reverse('ipam:vlangroup_vlans', 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, - self.site.name if self.site else None, + f'{self.scope_type.app_label}.{self.scope_type.model}', + self.scope_id, self.description, ) @@ -135,7 +155,6 @@ class VLAN(PrimaryModel): max_length=200, blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -162,10 +181,11 @@ class VLAN(PrimaryModel): def clean(self): super().clean() - # Validate VLAN group - if self.group and self.group.site != self.site: + # Validate VLAN group (if assigned) + if self.group and self.site and self.group.scope != self.site: raise ValidationError({ - 'group': "VLAN group must belong to the assigned site ({}).".format(self.site) + '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): diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index bcd5a456a..9eb2c6ab6 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,8 +1,6 @@ from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager -from extras.models import TaggedItem from extras.utils import extras_features from ipam.constants import * from netbox.models import PrimaryModel @@ -59,7 +57,6 @@ class VRF(PrimaryModel): related_name='exporting_vrfs', blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -116,7 +113,6 @@ class RouteTarget(PrimaryModel): blank=True, null=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 8101a581d..7e618ad1c 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -3,20 +3,16 @@ 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, + 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 = """ {% load helpers %} {% for i in record.parents|as_range %} @@ -37,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 %} @@ -50,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 %} @@ -94,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 %} @@ -109,16 +105,6 @@ VLAN_MEMBER_TAGGED = """ {% endif %} """ -TENANT_LINK = """ -{% if record.tenant %} - {{ record.tenant }} -{% elif record.vrf.tenant %} - {{ record.vrf.tenant }}* -{% else %} - — -{% endif %} -""" - # # VRFs @@ -130,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' ) @@ -163,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' ) @@ -208,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' @@ -225,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( @@ -279,9 +259,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 ) @@ -308,13 +286,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' ) @@ -347,9 +323,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, @@ -379,9 +353,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' @@ -428,8 +400,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): @@ -444,7 +417,7 @@ class InterfaceIPAddressTable(BaseTable): class VLANGroupTable(BaseTable): pk = ToggleColumn() name = tables.Column(linkify=True) - site = tables.Column( + scope = tables.Column( linkify=True ) vlan_count = LinkedCountColumn( @@ -459,8 +432,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') # @@ -480,9 +453,7 @@ class VLANTable(BaseTable): viewname='ipam:vlangroup_vlans', args=[Accessor('group__pk')] ) - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() status = ChoiceFieldColumn( default=AVAILABLE_LABEL ) @@ -504,9 +475,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' ) @@ -564,9 +533,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_views.py b/netbox/ipam/tests/test_views.py index b105ea7d9..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 @@ -306,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', } @@ -328,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 @@ -342,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 db0eec157..4b576d21f 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,11 +29,13 @@ 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'), @@ -48,11 +51,13 @@ 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'), @@ -68,6 +73,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 +85,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,6 +95,7 @@ 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//edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'), path('vlan-groups//delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'), @@ -106,6 +114,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 +125,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 bada223e3..d5332129c 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -30,6 +30,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 +43,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, } @@ -162,6 +164,15 @@ 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=count_related(Aggregate, 'rir') @@ -296,6 +307,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 @@ -629,7 +647,7 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView): # class VLANGroupListView(generic.ObjectListView): - queryset = VLANGroup.objects.prefetch_related('site').annotate( + queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') ) filterset = filters.VLANGroupFilterSet @@ -640,6 +658,7 @@ class VLANGroupListView(generic.ObjectListView): class VLANGroupEditView(generic.ObjectEditView): queryset = VLANGroup.objects.all() model_form = forms.VLANGroupForm + template_name = 'ipam/vlangroup_edit.html' class VLANGroupDeleteView(generic.ObjectDeleteView): @@ -652,8 +671,17 @@ class VLANGroupBulkImportView(generic.BulkImportView): table = tables.VLANGroupTable +class VLANGroupBulkEditView(generic.BulkEditView): + queryset = VLANGroup.objects.annotate( + vlan_count=count_related(VLAN, 'group') + ) + filterset = filters.VLANGroupFilterSet + table = tables.VLANGroupTable + form = forms.VLANGroupBulkEditForm + + class VLANGroupBulkDeleteView(generic.BulkDeleteView): - queryset = VLANGroup.objects.prefetch_related('site').annotate( + queryset = VLANGroup.objects.annotate( vlan_count=count_related(VLAN, 'group') ) filterset = filters.VLANGroupFilterSet @@ -766,6 +794,7 @@ class VLANVMInterfacesView(generic.ObjectView): class VLANEditView(generic.ObjectEditView): queryset = VLAN.objects.all() model_form = forms.VLANForm + template_name = 'ipam/vlan_edit.html' class VLANDeleteView(generic.ObjectDeleteView): diff --git a/netbox/netbox/api/serializers.py b/netbox/netbox/api/serializers.py index 18fc112c8..d17751e25 100644 --- a/netbox/netbox/api/serializers.py +++ b/netbox/netbox/api/serializers.py @@ -6,11 +6,18 @@ from rest_framework.exceptions import ValidationError from rest_framework.fields import CreateOnlyDefault from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultValues -from extras.models import CustomField +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) @@ -70,19 +77,14 @@ class CustomFieldModelSerializer(ValidatedModelSerializer): instance.custom_fields[field.name] = instance.cf.get(field.name) -class OrganizationalModelSerializer(CustomFieldModelSerializer): - pass +# +# Nested serializers +# - -class NestedGroupModelSerializer(CustomFieldModelSerializer): - _depth = serializers.IntegerField(source='level', read_only=True) - - -class WritableNestedSerializer(serializers.ModelSerializer): +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: @@ -128,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 991a8892d..585b75686 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -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) @@ -127,6 +131,16 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): 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 @@ -221,6 +235,11 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): 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') @@ -234,6 +253,11 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_): 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/constants.py b/netbox/netbox/constants.py index 4c6e3103a..e5b3f763c 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -6,12 +6,12 @@ from circuits.filters import CircuitFilterSet, ProviderFilterSet from circuits.models import Circuit, Provider from circuits.tables import CircuitTable, 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 @@ -55,22 +55,22 @@ 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( diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index da58e0c4f..1f188726b 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 index 63965d4e6..0e66ba90d 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models.py @@ -1,17 +1,20 @@ +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', - 'ChangeLoggingMixin', - 'CustomFieldsMixin', + 'ChangeLoggedModel', 'NestedGroupModel', 'OrganizationalModel', 'PrimaryModel', @@ -40,18 +43,32 @@ class ChangeLoggingMixin(models.Model): class Meta: abstract = True - def to_objectchange(self, action): + 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 - return ObjectChange( + objectchange = ObjectChange( changed_object=self, + related_object=related_object, object_repr=str(self), - action=action, - object_data=serialize_object(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): @@ -121,12 +138,26 @@ class BigIDModel(models.Model): 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. """ - # TODO - # tags = TaggableManager(through=TaggedItem) + 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 @@ -164,16 +195,6 @@ class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel, MPTTMo def __str__(self): return self.name - def to_objectchange(self, action): - # Remove MPTT-internal fields - from extras.models import ObjectChange - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id']) - ) - class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, BigIDModel): """ diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 4c4698e7b..5c727756f 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.6-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." + ) # @@ -429,7 +435,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'}, diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index 216b50759..e27447ad0 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -15,6 +15,7 @@ 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 @@ -137,32 +138,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,6 +179,22 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') + # 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' + ) + # Apply the request context paginate = { 'paginator_class': EnhancedPaginator, @@ -218,11 +238,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 +355,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 +804,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: @@ -898,6 +935,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 +1028,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/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 207836c4c..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 netbox.api.serializers 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,18 +14,19 @@ from .nested_serializers import * # Secrets # -class SecretRoleSerializer(CustomFieldModelSerializer): +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', 'custom_fields', 'created', 'last_updated', 'secret_count', + '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) @@ -38,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/forms.py b/netbox/secrets/forms.py index 9094d977c..b5fc85ce6 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -59,6 +59,20 @@ class SecretRoleCSVForm(CustomFieldModelCSVForm): 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(CustomFieldModelCSVForm): 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(), diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py index 091b67cc6..932242060 100644 --- a/netbox/secrets/models.py +++ b/netbox/secrets/models.py @@ -12,9 +12,7 @@ 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 TaggedItem from extras.utils import extras_features from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel from utilities.querysets import RestrictedQuerySet @@ -312,7 +310,6 @@ class Secret(PrimaryModel): max_length=128, editable=False ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() 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 62a285875..7c72b848c 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,6 +11,7 @@ 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'), @@ -26,5 +27,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 3fb8d1740..0ae2f6231 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -48,6 +48,15 @@ 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=count_related(Secret, 'role') diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html index 244ff27e1..e0fc564d3 100644 --- a/netbox/templates/circuits/circuittermination_edit.html +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -27,6 +27,7 @@ {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} {% render_field form.mark_connected %} diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index a39ada1ce..1a8667787 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -102,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 fcefc9fab..271c7c42d 100644 --- a/netbox/templates/dcim/consoleport.html +++ b/netbox/templates/dcim/consoleport.html @@ -29,6 +29,10 @@ Type {{ object.get_type_display }} + + Speed + {{ object.get_speed_display }} + Description {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index fb4c0982a..8b9ffde8a 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -29,6 +29,10 @@ Type {{ object.get_type_display }} + + Speed + {{ object.get_speed_display|placeholder }} + Description {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index bd3675213..e4947c1a4 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -19,21 +19,41 @@
- + + + + + + + + + {% endwith %} {% else %} diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index 2bfaad8a4..26d05afb7 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 %}
@@ -253,7 +254,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 d2440bd42..8a0c85a12 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -19,6 +19,7 @@ {% 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 %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index fa460eb76..e906aec90 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -5,8 +5,8 @@ {% block breadcrumbs %}
  • Power Panels
  • {{ object.site }}
  • - {% if object.rack_group %} -
  • {{ object.rack_group }}
  • + {% if object.location %} +
  • {{ object.location }}
  • {% endif %}
  • {{ object }}
  • {% endblock %} @@ -26,10 +26,10 @@
    - + - + + + + + - + - + - + - + + + + +
    SiteRegion {% if object.site.region %} - {{ object.site.region }} / + {% for region in object.site.region.get_ancestors %} + {{ region }} / + {% endfor %} + {{ object.site.region }} + {% else %} + None {% endif %} {{ object.site }}
    Site + {{ object.site }} +
    Location + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None + {% endif %} +
    Rack {% if object.rack %} - {% if object.rack.group %} - {{ object.rack.group }} / - {% endif %} {{ object.rack }} {% else %} None diff --git a/netbox/templates/dcim/device_component.html b/netbox/templates/dcim/device_component.html index 998551838..2cc4abf35 100644 --- a/netbox/templates/dcim/device_component.html +++ b/netbox/templates/dcim/device_component.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load perms %} +{% load custom_links %} {% load plugins %} {% block title %}{{ object.device }} / {{ object }}{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 3260ee924..d93d5ba98 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -20,8 +20,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 %}
    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 @@
    - {% 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/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index 3169d2ffc..d5b9f6112 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,6 +1,6 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - {{ endpoint.parent }}{{ endpoint.parent_object }} {{ endpoint }}
    Rack GroupLocation - {% if object.rack_group %} - {{ object.rack_group }} + {% if object.location %} + {{ object.location }} {% else %} None {% endif %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 1d8702d7b..f0081cce1 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -65,7 +65,20 @@ {% endif %} {{ object.site }}
    Location + {% if object.location %} + {% for location in object.location.get_ancestors %} + {{ location }} / + {% endfor %} + {{ object.location }} + {% else %} + None + {% endif %} +
    Group diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 5b1a72b63..d0c1a47a6 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -5,8 +5,9 @@

    Rack

    {% render_field form.region %} + {% render_field form.site_group %} {% render_field form.site %} - {% render_field form.group %} + {% render_field form.location %} {% render_field form.name %} {% render_field form.status %} {% render_field form.role %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index b2382567e..1fd0e71f4 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -200,30 +200,24 @@
    - Rack Groups + Locations
    - {% for rg in rack_groups %} + {% for location in locations %} - - - + + {% endfor %} - - - - -
    {{ rg }}{{ rg.rack_count }} - + + + {{ location }} + {{ location.rack_count }} +
    All racks{{ stats.rack_count }} - - - -
    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_edit.html b/netbox/templates/extras/configcontext_edit.html index 64bbfde07..9673f9f44 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -15,6 +15,7 @@
    Assignment
    {% render_field form.regions %} + {% render_field form.site_groups %} {% render_field form.sites %} {% render_field form.roles %} {% render_field form.platforms %} diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html new file mode 100644 index 000000000..6c50ca547 --- /dev/null +++ b/netbox/templates/extras/object_journal.html @@ -0,0 +1,32 @@ +{% 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 606f22ecb..12467e5a5 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -86,8 +86,8 @@
    -
    - Difference +
    +
    {% if diff_added == diff_removed %} @@ -115,17 +115,41 @@
    -
    +
    - 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 %} +
    +
    +
    {% include 'panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 99b3b8868..2736c207c 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -41,13 +41,25 @@ - {% if perms.extras.view_objectchange %} - - {% endif %} {% endblock %} + {% if perms.extras.view_journalentry %} + {% with journal_viewname=object|viewname:'journal' %} + {% url journal_viewname pk=object.pk as journal_url %} + {% if journal_url %} + + {% endif %} + {% endwith %} + {% endif %} + {% if perms.extras.view_objectchange %} + {% with changelog_viewname=object|viewname:'changelog' %} + {% url changelog_viewname pk=object.pk as changelog_url %} + + {% endwith %} + {% endif %} {% endblock %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 089481181..3e8d1adc4 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -45,9 +45,20 @@
    {% endif %} - +
  • - -
    Route DistinguisherRoute Distinguisher {{ object.rd }}
    TenantTenant {% if object.tenant %} {{ object.tenant }} @@ -44,15 +44,21 @@
    DescriptionDescription {{ object.description|placeholder }}
    PrefixesPrefixes {{ prefix_count }}
    IP Addresses + {{ ipaddress_count }} +
    diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 6a1d77cae..e574e926e 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -1,6 +1,7 @@ {% extends 'generic/object.html' %} {% load helpers %} {% load plugins %} +{% load render_table from django_tables2 %} {% block title %}{{ object.virtual_machine }} / {{ object.name }}{% endblock %} @@ -56,16 +57,33 @@ - {% plugin_left_page object %} + {% plugin_left_page object %}
    + {% include 'inc/custom_fields_panel.html' %} {% include 'extras/inc/tags_panel.html' with tags=object.tags.all %} {% plugin_right_page object %}
    - {% 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/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index d4869aaf9..c0ad6e98c 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -31,6 +31,14 @@ {% render_field form.tagged_vlans %}
    + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} {% endblock %} {% block buttons %} diff --git a/netbox/tenancy/api/nested_serializers.py b/netbox/tenancy/api/nested_serializers.py index 7b227c123..11225fa7a 100644 --- a/netbox/tenancy/api/nested_serializers.py +++ b/netbox/tenancy/api/nested_serializers.py @@ -20,7 +20,7 @@ class NestedTenantGroupSerializer(WritableNestedSerializer): class Meta: model = TenantGroup - fields = ['id', 'url', 'name', 'slug', 'tenant_count', '_depth'] + fields = ['id', 'url', 'display', 'name', 'slug', 'tenant_count', '_depth'] class NestedTenantSerializer(WritableNestedSerializer): @@ -28,4 +28,4 @@ class NestedTenantSerializer(WritableNestedSerializer): class Meta: model = Tenant - fields = ['id', 'url', 'name', 'slug'] + fields = ['id', 'url', 'display', 'name', 'slug'] diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index c701e6b3b..3136c811c 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -1,7 +1,6 @@ from rest_framework import serializers -from netbox.api.serializers import CustomFieldModelSerializer, NestedGroupModelSerializer -from extras.api.serializers import TaggedObjectSerializer +from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer from tenancy.models import Tenant, TenantGroup from .nested_serializers import * @@ -18,12 +17,12 @@ class TenantGroupSerializer(NestedGroupModelSerializer): class Meta: model = TenantGroup fields = [ - 'id', 'url', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'parent', 'description', 'custom_fields', 'created', 'last_updated', 'tenant_count', '_depth', ] -class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class TenantSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False, allow_null=True) circuit_count = serializers.IntegerField(read_only=True) @@ -40,7 +39,7 @@ class TenantSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Tenant fields = [ - 'id', 'url', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', + 'id', 'url', 'display', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'circuit_count', 'device_count', 'ipaddress_count', 'prefix_count', 'rack_count', 'site_count', 'virtualmachine_count', 'vlan_count', 'vrf_count', 'cluster_count', ] diff --git a/netbox/tenancy/forms.py b/netbox/tenancy/forms.py index 43401baab..d53748055 100644 --- a/netbox/tenancy/forms.py +++ b/netbox/tenancy/forms.py @@ -44,6 +44,24 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm): fields = TenantGroup.csv_headers +class TenantGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=TenantGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + parent = DynamicModelChoiceField( + queryset=TenantGroup.objects.all(), + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['parent', 'description'] + + # # Tenants # diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py index 757afb09e..e5ecec639 100644 --- a/netbox/tenancy/models.py +++ b/netbox/tenancy/models.py @@ -1,12 +1,9 @@ from django.db import models from django.urls import reverse from mptt.models import MPTTModel, TreeForeignKey -from taggit.managers import TaggableManager -from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from netbox.models import NestedGroupModel, PrimaryModel -from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet @@ -42,8 +39,6 @@ class TenantGroup(NestedGroupModel): blank=True ) - objects = TreeManager() - csv_headers = ['name', 'slug', 'parent', 'description'] class Meta: @@ -89,7 +84,6 @@ class Tenant(PrimaryModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 8872a7012..5b5bc6d73 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -1,22 +1,32 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, TagColumn, ToggleColumn +from utilities.tables import BaseTable, ButtonsColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn from .models import Tenant, TenantGroup -MPTT_LINK = """ -{% for i in record.get_ancestors %} - -{% endfor %} -{{ record.name }} -""" -COL_TENANT = """ -{% if record.tenant %} - {{ record.tenant }} -{% else %} - — -{% endif %} -""" +# +# Table columns +# + +class TenantColumn(tables.TemplateColumn): + """ + Render a colored label (e.g. for DeviceRoles). + """ + template_code = """ + {% if record.tenant %} + {{ record.tenant }} + {% elif record.vrf.tenant %} + {{ record.vrf.tenant }}* + {% else %} + — + {% endif %} + """ + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return str(value) # @@ -25,11 +35,7 @@ COL_TENANT = """ class TenantGroupTable(BaseTable): pk = ToggleColumn() - name = tables.TemplateColumn( - template_code=MPTT_LINK, - orderable=False, - attrs={'td': {'class': 'text-nowrap'}} - ) + name = MPTTColumn() tenant_count = LinkedCountColumn( viewname='tenancy:tenant_list', url_params={'group': 'slug'}, diff --git a/netbox/tenancy/tests/test_api.py b/netbox/tenancy/tests/test_api.py index c512ff688..5a3c2c1b0 100644 --- a/netbox/tenancy/tests/test_api.py +++ b/netbox/tenancy/tests/test_api.py @@ -16,7 +16,7 @@ class AppTest(APITestCase): class TenantGroupTest(APIViewTestCases.APIViewTestCase): model = TenantGroup - brief_fields = ['_depth', 'id', 'name', 'slug', 'tenant_count', 'url'] + brief_fields = ['_depth', 'display', 'id', 'name', 'slug', 'tenant_count', 'url'] bulk_update_data = { 'description': 'New description', } @@ -54,7 +54,7 @@ class TenantGroupTest(APIViewTestCases.APIViewTestCase): class TenantTest(APIViewTestCases.APIViewTestCase): model = Tenant - brief_fields = ['id', 'name', 'slug', 'url'] + brief_fields = ['display', 'id', 'name', 'slug', 'url'] bulk_update_data = { 'group': None, 'description': 'New description', diff --git a/netbox/tenancy/tests/test_views.py b/netbox/tenancy/tests/test_views.py index 5b88b84cf..8ef7efb12 100644 --- a/netbox/tenancy/tests/test_views.py +++ b/netbox/tenancy/tests/test_views.py @@ -29,6 +29,10 @@ class TenantGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Tenant Group 6,tenant-group-6,Sixth tenant group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class TenantTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Tenant diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index c07df97a6..1b9dcbe26 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/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 Tenant, TenantGroup @@ -11,6 +11,7 @@ urlpatterns = [ path('tenant-groups/', views.TenantGroupListView.as_view(), name='tenantgroup_list'), path('tenant-groups/add/', views.TenantGroupEditView.as_view(), name='tenantgroup_add'), path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'), + path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'), path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'), path('tenant-groups//edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'), path('tenant-groups//delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'), @@ -26,5 +27,6 @@ urlpatterns = [ path('tenants//edit/', views.TenantEditView.as_view(), name='tenant_edit'), path('tenants//delete/', views.TenantDeleteView.as_view(), name='tenant_delete'), path('tenants//changelog/', ObjectChangeLogView.as_view(), name='tenant_changelog', kwargs={'model': Tenant}), + path('tenants//journal/', ObjectJournalView.as_view(), name='tenant_journal', kwargs={'model': Tenant}), ] diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4c72cea42..11f5ead00 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -39,6 +39,19 @@ class TenantGroupBulkImportView(generic.BulkImportView): table = tables.TenantGroupTable +class TenantGroupBulkEditView(generic.BulkEditView): + queryset = TenantGroup.objects.add_related_count( + TenantGroup.objects.all(), + Tenant, + 'group', + 'tenant_count', + cumulative=True + ) + filterset = filters.TenantGroupFilterSet + table = tables.TenantGroupTable + form = forms.TenantGroupBulkEditForm + + class TenantGroupBulkDeleteView(generic.BulkDeleteView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), diff --git a/netbox/users/admin.py b/netbox/users/admin.py index f2fe4a0b4..e1625e1ed 100644 --- a/netbox/users/admin.py +++ b/netbox/users/admin.py @@ -223,7 +223,7 @@ class ObjectTypeListFilter(admin.SimpleListFilter): parameter_name = 'object_type' def lookups(self, request, model_admin): - object_types = ObjectPermission.objects.values_list('id', flat=True).distinct() + object_types = ObjectPermission.objects.values_list('object_types__pk', flat=True).distinct() content_types = ContentType.objects.filter(pk__in=object_types).order_by('app_label', 'model') return [ (ct.pk, ct) for ct in content_types diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3b43ca7c9..27ef3fc82 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -17,7 +17,7 @@ class NestedGroupSerializer(WritableNestedSerializer): class Meta: model = Group - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedUserSerializer(WritableNestedSerializer): @@ -25,7 +25,7 @@ class NestedUserSerializer(WritableNestedSerializer): class Meta: model = User - fields = ['id', 'url', 'username'] + fields = ['id', 'url', 'display', 'username'] class NestedObjectPermissionSerializer(WritableNestedSerializer): @@ -39,7 +39,7 @@ class NestedObjectPermissionSerializer(WritableNestedSerializer): class Meta: model = ObjectPermission - fields = ['id', 'url', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] + fields = ['id', 'url', 'display', 'name', 'enabled', 'object_types', 'groups', 'users', 'actions'] def get_groups(self, obj): return [g.name for g in obj.groups.all()] diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index eed0bd80e..054f9ba48 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -19,7 +19,7 @@ class UserSerializer(ValidatedModelSerializer): class Meta: model = User fields = ( - 'id', 'url', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', + 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', ) extra_kwargs = { @@ -44,7 +44,7 @@ class GroupSerializer(ValidatedModelSerializer): class Meta: model = Group - fields = ('id', 'url', 'name', 'user_count') + fields = ('id', 'url', 'display', 'name', 'user_count') class ObjectPermissionSerializer(ValidatedModelSerializer): @@ -69,5 +69,6 @@ class ObjectPermissionSerializer(ValidatedModelSerializer): class Meta: model = ObjectPermission fields = ( - 'id', 'url', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', 'constraints', + 'id', 'url', 'display', 'name', 'description', 'enabled', 'object_types', 'groups', 'users', 'actions', + 'constraints', ) diff --git a/netbox/users/tests/test_api.py b/netbox/users/tests/test_api.py index 11d4e58cd..c2488f2f6 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -20,7 +20,7 @@ class AppTest(APITestCase): class UserTest(APIViewTestCases.APIViewTestCase): model = User view_namespace = 'users' - brief_fields = ['id', 'url', 'username'] + brief_fields = ['display', 'id', 'url', 'username'] validation_excluded_fields = ['password'] create_data = [ { @@ -51,7 +51,7 @@ class UserTest(APIViewTestCases.APIViewTestCase): class GroupTest(APIViewTestCases.APIViewTestCase): model = Group view_namespace = 'users' - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] create_data = [ { 'name': 'Group 4', @@ -77,7 +77,7 @@ class GroupTest(APIViewTestCases.APIViewTestCase): class ObjectPermissionTest(APIViewTestCases.APIViewTestCase): model = ObjectPermission - brief_fields = ['actions', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] + brief_fields = ['actions', 'display', 'enabled', 'groups', 'id', 'name', 'object_types', 'url', 'users'] @classmethod def setUpTestData(cls): diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 6cf773170..943abf733 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -24,6 +24,7 @@ __all__ = ( 'CSVContentTypeField', 'CSVDataField', 'CSVModelChoiceField', + 'CSVTypedChoiceField', 'DynamicModelChoiceField', 'DynamicModelMultipleChoiceField', 'ExpandableIPAddressField', @@ -125,6 +126,10 @@ class CSVChoiceField(forms.ChoiceField): self.choices = unpack_grouped_choices(choices) +class CSVTypedChoiceField(forms.TypedChoiceField): + STATIC_CHOICES = True + + class CSVModelChoiceField(forms.ModelChoiceField): """ Provides additional validation for model choices entered as CSV data. @@ -271,7 +276,8 @@ class DynamicModelChoiceMixin: filter = django_filters.ModelChoiceFilter widget = widgets.APISelect - def __init__(self, display_field='name', query_params=None, initial_params=None, null_option=None, + # TODO: Remove display_field in v2.12 + def __init__(self, display_field='display', query_params=None, initial_params=None, null_option=None, disabled_indicator=None, brief_mode=True, *args, **kwargs): self.display_field = display_field self.query_params = query_params or {} diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py index fdbc6b3dc..e776b40eb 100644 --- a/netbox/utilities/tables.py +++ b/netbox/utilities/tables.py @@ -133,6 +133,9 @@ class BooleanColumn(tables.Column): rendered = '' return mark_safe(rendered) + def value(self, value): + return str(value) + class ButtonsColumn(tables.TemplateColumn): """ @@ -177,6 +180,10 @@ class ButtonsColumn(tables.TemplateColumn): super().__init__(template_code=template_code, *args, **kwargs) + # Exclude from export by default + if 'exclude_from_export' not in kwargs: + self.exclude_from_export = True + self.extra_context.update({ 'buttons': buttons or self.buttons, 'return_url_extra': return_url_extra, @@ -201,6 +208,9 @@ class ChoiceFieldColumn(tables.Column): ) return self.default + def value(self, value): + return value + class ColorColumn(tables.Column): """ @@ -211,6 +221,9 @@ class ColorColumn(tables.Column): f' ' ) + def value(self, value): + return f'#{value}' + class ColoredLabelColumn(tables.TemplateColumn): """ @@ -230,6 +243,9 @@ class ColoredLabelColumn(tables.TemplateColumn): def __init__(self, *args, **kwargs): super().__init__(template_code=self.template_code, *args, **kwargs) + def value(self, value): + return str(value) + class LinkedCountColumn(tables.Column): """ @@ -253,6 +269,9 @@ class LinkedCountColumn(tables.Column): return mark_safe(f'{value}') return value + def value(self, value): + return value + class TagColumn(tables.TemplateColumn): """ @@ -271,3 +290,39 @@ class TagColumn(tables.TemplateColumn): template_code=self.template_code, extra_context={'url_name': url_name} ) + + def value(self, value): + return ",".join([tag.name for tag in value.all()]) + + +class MPTTColumn(tables.TemplateColumn): + """ + Display a nested hierarchy for MPTT-enabled models. + """ + template_code = """{% for i in record.get_ancestors %}{% endfor %}""" \ + """{{ record.name }}""" + + def __init__(self, *args, **kwargs): + super().__init__( + template_code=self.template_code, + orderable=False, + attrs={'td': {'class': 'text-nowrap'}}, + *args, + **kwargs + ) + + def value(self, value): + return value + + +class UtilizationColumn(tables.TemplateColumn): + """ + Display a colored utilization bar graph. + """ + template_code = """{% load helpers %}{% if record.pk %}{% utilization_graph value %}{% endif %}""" + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return f'{value}%' diff --git a/netbox/utilities/templates/buttons/export.html b/netbox/utilities/templates/buttons/export.html index ef05c2285..4256b1a73 100644 --- a/netbox/utilities/templates/buttons/export.html +++ b/netbox/utilities/templates/buttons/export.html @@ -1,18 +1,21 @@ -{% if export_templates %} -
    - - -
    -{% else %} - +
    + + +
    diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 89bac51cf..aa8db346d 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -559,12 +559,6 @@ class ViewTestCases: # Try GET with model-level permission self.assertHttpStatus(self.client.get(self._get_url('list')), 200) - # Built-in CSV export - if hasattr(self.model, 'csv_headers'): - response = self.client.get('{}?export'.format(self._get_url('list'))) - self.assertHttpStatus(response, 200) - self.assertEqual(response.get('Content-Type'), 'text/csv') - @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects_with_constrained_permission(self): instance1, instance2 = self._get_queryset().all()[:2] @@ -586,10 +580,27 @@ class ViewTestCases: if hasattr(self.model, 'name'): self.assertIn(instance1.name, content) self.assertNotIn(instance2.name, content) - else: + elif hasattr(self.model, 'get_absolute_url'): self.assertIn(instance1.get_absolute_url(), content) self.assertNotIn(instance2.get_absolute_url(), content) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_export_objects(self): + url = self._get_url('list') + + # Test default CSV export + response = self.client.get(f'{url}?export') + self.assertHttpStatus(response, 200) + if hasattr(self.model, 'csv_headers'): + self.assertEqual(response.get('Content-Type'), 'text/csv') + content = response.content.decode('utf-8') + self.assertEqual(content.splitlines()[0], ','.join(self.model.csv_headers)) + + # Test table-based export + response = self.client.get(f'{url}?export=table') + self.assertHttpStatus(response, 200) + self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') + class CreateMultipleObjectsViewTestCase(ModelViewTestCase): """ Create multiple instances using a single form. Expects the creation of three new instances by default. @@ -1013,6 +1024,7 @@ class ViewTestCases: DeleteObjectViewTestCase, ListObjectsViewTestCase, BulkImportObjectsViewTestCase, + BulkEditObjectsViewTestCase, BulkDeleteObjectsViewTestCase, ): """ diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index d76b469b2..f0340f094 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -7,6 +7,7 @@ from django.core.serializers import serialize from django.db.models import Count, OuterRef, Subquery from django.db.models.functions import Coalesce from jinja2 import Environment +from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices from extras.utils import is_taggable @@ -83,7 +84,7 @@ def count_related(model, field): return Coalesce(subquery, 0) -def serialize_object(obj, extra=None, exclude=None): +def serialize_object(obj, extra=None): """ Return a generic JSON representation of an object using Django's built-in serializer. (This is used for things like change logging, not the REST API.) Optionally include a dictionary to supplement the object data. A list of keys @@ -93,6 +94,11 @@ def serialize_object(obj, extra=None, exclude=None): json_str = serialize('json', [obj]) data = json.loads(json_str)[0]['fields'] + # Exclude any MPTTModel fields + if issubclass(obj.__class__, MPTTModel): + for field in ['level', 'lft', 'rght', 'tree_id']: + data.pop(field) + # Include custom_field_data as "custom_fields" if hasattr(obj, 'custom_field_data'): data['custom_fields'] = data.pop('custom_field_data') @@ -112,10 +118,6 @@ def serialize_object(obj, extra=None, exclude=None): if isinstance(key, str) and key.startswith('_'): data.pop(key) - # Explicitly excluded keys - if isinstance(exclude, (list, tuple)) and key in exclude: - data.pop(key) - return data diff --git a/netbox/virtualization/api/nested_serializers.py b/netbox/virtualization/api/nested_serializers.py index 7763f0ef4..aa0a9437e 100644 --- a/netbox/virtualization/api/nested_serializers.py +++ b/netbox/virtualization/api/nested_serializers.py @@ -23,7 +23,7 @@ class NestedClusterTypeSerializer(WritableNestedSerializer): class Meta: model = ClusterType - fields = ['id', 'url', 'name', 'slug', 'cluster_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] class NestedClusterGroupSerializer(WritableNestedSerializer): @@ -32,7 +32,7 @@ class NestedClusterGroupSerializer(WritableNestedSerializer): class Meta: model = ClusterGroup - fields = ['id', 'url', 'name', 'slug', 'cluster_count'] + fields = ['id', 'url', 'display', 'name', 'slug', 'cluster_count'] class NestedClusterSerializer(WritableNestedSerializer): @@ -41,7 +41,7 @@ class NestedClusterSerializer(WritableNestedSerializer): class Meta: model = Cluster - fields = ['id', 'url', 'name', 'virtualmachine_count'] + fields = ['id', 'url', 'display', 'name', 'virtualmachine_count'] # @@ -53,7 +53,7 @@ class NestedVirtualMachineSerializer(WritableNestedSerializer): class Meta: model = VirtualMachine - fields = ['id', 'url', 'name'] + fields = ['id', 'url', 'display', 'name'] class NestedVMInterfaceSerializer(WritableNestedSerializer): @@ -62,4 +62,4 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer): class Meta: model = Interface - fields = ['id', 'url', 'virtual_machine', 'name'] + fields = ['id', 'url', 'display', 'virtual_machine', 'name'] diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index e3b82e40a..0afa8f796 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,12 +3,10 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from netbox.api.serializers import CustomFieldModelSerializer -from extras.api.serializers import TaggedObjectSerializer from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import OrganizationalModelSerializer, ValidatedModelSerializer +from netbox.api.serializers import OrganizationalModelSerializer, PrimaryModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -26,7 +24,8 @@ class ClusterTypeSerializer(OrganizationalModelSerializer): class Meta: model = ClusterType fields = [ - 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'cluster_count', + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'cluster_count', ] @@ -37,11 +36,12 @@ class ClusterGroupSerializer(OrganizationalModelSerializer): class Meta: model = ClusterGroup fields = [ - 'id', 'url', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', 'cluster_count', + 'id', 'url', 'display', 'name', 'slug', 'description', 'custom_fields', 'created', 'last_updated', + 'cluster_count', ] -class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class ClusterSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True) @@ -53,8 +53,8 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): class Meta: model = Cluster fields = [ - 'id', 'url', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', 'device_count', 'virtualmachine_count', + 'id', 'url', 'display', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', 'device_count', 'virtualmachine_count', ] @@ -62,7 +62,7 @@ class ClusterSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): # Virtual machines # -class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerializer): +class VirtualMachineSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) @@ -77,9 +77,9 @@ class VirtualMachineSerializer(TaggedObjectSerializer, CustomFieldModelSerialize class Meta: model = VirtualMachine fields = [ - 'id', 'url', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', - 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'created', 'last_updated', ] validators = [] @@ -89,9 +89,9 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class Meta(VirtualMachineSerializer.Meta): fields = [ - 'id', 'url', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', - 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', 'custom_fields', - 'config_context', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', + 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'local_context_data', 'tags', + 'custom_fields', 'config_context', 'created', 'last_updated', ] @swagger_serializer_method(serializer_or_field=serializers.DictField) @@ -103,7 +103,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): +class VMInterfaceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) @@ -118,8 +118,8 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer): class Meta: model = VMInterface fields = [ - 'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags', + 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode', + 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', ] def validate(self, data): diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index af53afad5..be9b70749 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,7 +1,7 @@ import django_filters from django.db.models import Q -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from extras.filters import CustomFieldModelFilterSet, CreatedUpdatedFilterSet, LocalConfigContextFilterSet from tenancy.filters import TenancyFilterSet from utilities.filters import ( @@ -52,6 +52,19 @@ class ClusterFilterSet(BaseFilterSet, TenancyFilterSet, CustomFieldModelFilterSe 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)', @@ -151,6 +164,19 @@ class VirtualMachineFilterSet( to_field_name='slug', label='Region (slug)', ) + site_group_id = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + label='Site group (ID)', + ) + site_group = TreeNodeMultipleChoiceFilter( + queryset=SiteGroup.objects.all(), + field_name='cluster__site__group', + lookup_expr='in', + to_field_name='slug', + label='Site group (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='cluster__site', queryset=Site.objects.all(), @@ -211,7 +237,7 @@ class VirtualMachineFilterSet( return queryset.exclude(params) -class VMInterfaceFilterSet(BaseFilterSet): +class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpdatedFilterSet): q = django_filters.CharFilter( method='search', label='Search', diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 65c2e45d4..95e1d2dc1 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -6,7 +6,7 @@ from django.utils.translation import gettext as _ from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT -from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site +from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup from extras.forms import ( AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm, ) @@ -46,6 +46,20 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm): fields = ClusterType.csv_headers +class ClusterTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterType.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Cluster groups # @@ -68,6 +82,20 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm): fields = ClusterGroup.csv_headers +class ClusterGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ClusterGroup.objects.all(), + widget=forms.MultipleHiddenInput + ) + description = forms.CharField( + max_length=200, + required=False + ) + + class Meta: + nullable_fields = ['description'] + + # # Clusters # @@ -87,11 +115,19 @@ class ClusterForm(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', } ) comments = CommentField() @@ -103,10 +139,10 @@ class ClusterForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): class Meta: model = Cluster fields = ( - 'name', 'type', 'group', 'tenant', 'region', 'site', 'comments', 'tags', + 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site', 'tags')), + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), ('Tenancy', ('tenant_group', 'tenant')), ) @@ -162,11 +198,16 @@ class ClusterBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit queryset=Region.objects.all(), required=False, ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + ) site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, query_params={ - 'region_id': '$region' + 'region_id': '$region', + 'group_id': '$site_group', } ) comments = CommentField( @@ -223,25 +264,29 @@ class ClusterAddDevicesForm(BootstrapMixin, forms.Form): required=False, null_option='None' ) + site_group = DynamicModelChoiceField( + queryset=SiteGroup.objects.all(), + required=False, + null_option='None' + ) 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' } ) devices = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), - display_field='display_name', query_params={ 'site_id': '$site', 'rack_id': '$rack', @@ -557,12 +602,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil # VM interfaces # -class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, forms.ModelForm): +class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, label='Untagged VLAN', - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -572,7 +616,6 @@ class VMInterfaceForm(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', @@ -647,7 +690,6 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -656,7 +698,6 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -737,7 +778,6 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', @@ -746,7 +786,6 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): tagged_vlans = DynamicModelMultipleChoiceField( queryset=VLAN.objects.all(), required=False, - display_field='display_name', brief_mode=False, query_params={ 'site_id': 'null', diff --git a/netbox/virtualization/migrations/0020_standardize_models.py b/netbox/virtualization/migrations/0020_standardize_models.py index 8ccb3df0d..bea551a72 100644 --- a/netbox/virtualization/migrations/0020_standardize_models.py +++ b/netbox/virtualization/migrations/0020_standardize_models.py @@ -1,4 +1,5 @@ import django.core.serializers.json +import taggit.managers from django.db import migrations, models @@ -44,4 +45,24 @@ class Migration(migrations.Migration): name='id', field=models.BigAutoField(primary_key=True, serialize=False), ), + migrations.AddField( + model_name='vminterface', + name='created', + field=models.DateField(auto_now_add=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='last_updated', + field=models.DateTimeField(auto_now=True, null=True), + ), + migrations.AddField( + model_name='vminterface', + name='custom_field_data', + field=models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder), + ), + migrations.AlterField( + model_name='vminterface', + name='tags', + field=taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag'), + ), ] diff --git a/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py b/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py new file mode 100644 index 000000000..0d82fcb43 --- /dev/null +++ b/netbox/virtualization/migrations/0021_virtualmachine_vcpus_decimal.py @@ -0,0 +1,17 @@ +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0020_standardize_models'), + ] + + operations = [ + migrations.AlterField( + model_name='virtualmachine', + name='vcpus', + field=models.DecimalField(blank=True, decimal_places=2, max_digits=6, null=True, validators=[django.core.validators.MinValueValidator(0.01)]), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 364dde30b..f904b6756 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -1,20 +1,19 @@ from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.core.exceptions import ValidationError +from django.core.validators import MinValueValidator from django.db import models from django.urls import reverse -from taggit.managers import TaggableManager from dcim.models import BaseInterface, Device -from extras.models import ConfigContextModel, ObjectChange, TaggedItem +from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet from extras.utils import extras_features -from netbox.models import BigIDModel, OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, PrimaryModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from utilities.querysets import RestrictedQuerySet -from utilities.utils import serialize_object from .choices import * @@ -155,7 +154,6 @@ class Cluster(PrimaryModel): comments = models.TextField( blank=True ) - tags = TaggableManager(through=TaggedItem) objects = RestrictedQuerySet.as_manager() @@ -258,10 +256,15 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): null=True, verbose_name='Primary IPv6' ) - vcpus = models.PositiveSmallIntegerField( + vcpus = models.DecimalField( + max_digits=6, + decimal_places=2, blank=True, null=True, - verbose_name='vCPUs' + verbose_name='vCPUs', + validators=( + MinValueValidator(0.01), + ) ) memory = models.PositiveIntegerField( blank=True, @@ -282,7 +285,6 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): object_id_field='assigned_object_id', related_query_name='virtual_machine' ) - tags = TaggableManager(through=TaggedItem) objects = ConfigContextModelQuerySet.as_manager() @@ -373,8 +375,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -@extras_features('export_templates', 'webhooks') -class VMInterface(BigIDModel, BaseInterface): +@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') +class VMInterface(PrimaryModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, @@ -413,10 +415,6 @@ class VMInterface(BigIDModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) - tags = TaggableManager( - through=TaggedItem, - related_name='vminterface' - ) objects = RestrictedQuerySet.as_manager() @@ -458,13 +456,7 @@ class VMInterface(BigIDModel, BaseInterface): def to_objectchange(self, action): # Annotate the parent VirtualMachine - return ObjectChange( - changed_object=self, - object_repr=str(self), - action=action, - related_object=self.virtual_machine, - object_data=serialize_object(self) - ) + return super().to_objectchange(action, related_object=self.virtual_machine) @property def parent(self): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index 808832224..80e957505 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables - +from django.conf import settings from dcim.tables.devices import BaseInterfaceTable -from tenancy.tables import COL_TENANT +from tenancy.tables import TenantColumn from utilities.tables import ( BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, TagColumn, ToggleColumn, ) @@ -107,9 +107,7 @@ class VirtualMachineTable(BaseTable): linkify=True ) role = ColoredLabelColumn() - tenant = tables.TemplateColumn( - template_code=COL_TENANT - ) + tenant = TenantColumn() class Meta(BaseTable.Meta): model = VirtualMachine @@ -125,10 +123,18 @@ class VirtualMachineDetailTable(VirtualMachineTable): linkify=True, verbose_name='IPv6 Address' ) - 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' + ) tags = TagColumn( url_name='virtualization:virtualmachine_list' ) diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 716b6af4d..bcf3195f2 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -19,7 +19,7 @@ class AppTest(APITestCase): class ClusterTypeTest(APIViewTestCases.APIViewTestCase): model = ClusterType - brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Cluster Type 4', @@ -51,7 +51,7 @@ class ClusterTypeTest(APIViewTestCases.APIViewTestCase): class ClusterGroupTest(APIViewTestCases.APIViewTestCase): model = ClusterGroup - brief_fields = ['cluster_count', 'id', 'name', 'slug', 'url'] + brief_fields = ['cluster_count', 'display', 'id', 'name', 'slug', 'url'] create_data = [ { 'name': 'Cluster Group 4', @@ -83,7 +83,7 @@ class ClusterGroupTest(APIViewTestCases.APIViewTestCase): class ClusterTest(APIViewTestCases.APIViewTestCase): model = Cluster - brief_fields = ['id', 'name', 'url', 'virtualmachine_count'] + brief_fields = ['display', 'id', 'name', 'url', 'virtualmachine_count'] bulk_update_data = { 'comments': 'New comment', } @@ -131,7 +131,7 @@ class ClusterTest(APIViewTestCases.APIViewTestCase): class VirtualMachineTest(APIViewTestCases.APIViewTestCase): model = VirtualMachine - brief_fields = ['id', 'name', 'url'] + brief_fields = ['display', 'id', 'name', 'url'] bulk_update_data = { 'status': 'staged', } @@ -207,7 +207,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase): class VMInterfaceTest(APIViewTestCases.APIViewTestCase): model = VMInterface - brief_fields = ['id', 'name', 'url', 'virtual_machine'] + brief_fields = ['display', 'id', 'name', 'url', 'virtual_machine'] bulk_update_data = { 'description': 'New description', } diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py index e27c063e1..e822d8763 100644 --- a/netbox/virtualization/tests/test_filters.py +++ b/netbox/virtualization/tests/test_filters.py @@ -1,6 +1,6 @@ from django.test import TestCase -from dcim.models import DeviceRole, Platform, Region, Site +from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup from ipam.models import IPAddress from tenancy.models import Tenant, TenantGroup from virtualization.choices import * @@ -96,14 +96,21 @@ class ClusterTestCase(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) @@ -144,6 +151,13 @@ class ClusterTestCase(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]} @@ -206,14 +220,21 @@ class VirtualMachineTestCase(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) @@ -329,6 +350,13 @@ class VirtualMachineTestCase(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/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index e35f977fd..cb0055789 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -33,6 +33,10 @@ class ClusterGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Group 6,cluster-group-6,Sixth cluster group", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ClusterType @@ -59,6 +63,10 @@ class ClusterTypeTestCase(ViewTestCases.OrganizationalObjectViewTestCase): "Cluster Type 6,cluster-type-6,Sixth cluster type", ) + cls.bulk_edit_data = { + 'description': 'New description', + } + class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Cluster diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index 703f99b65..a036ab0bd 100644 --- a/netbox/virtualization/urls.py +++ b/netbox/virtualization/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView +from extras.views import ObjectChangeLogView, ObjectJournalView from ipam.views import ServiceEditView from . import views from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -12,6 +12,7 @@ urlpatterns = [ path('cluster-types/', views.ClusterTypeListView.as_view(), name='clustertype_list'), path('cluster-types/add/', views.ClusterTypeEditView.as_view(), name='clustertype_add'), path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'), + path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'), path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'), path('cluster-types//edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'), path('cluster-types//delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'), @@ -21,6 +22,7 @@ urlpatterns = [ path('cluster-groups/', views.ClusterGroupListView.as_view(), name='clustergroup_list'), path('cluster-groups/add/', views.ClusterGroupEditView.as_view(), name='clustergroup_add'), path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'), + path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'), path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'), path('cluster-groups//edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'), path('cluster-groups//delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'), @@ -36,6 +38,7 @@ urlpatterns = [ path('clusters//edit/', views.ClusterEditView.as_view(), name='cluster_edit'), path('clusters//delete/', views.ClusterDeleteView.as_view(), name='cluster_delete'), path('clusters//changelog/', ObjectChangeLogView.as_view(), name='cluster_changelog', kwargs={'model': Cluster}), + path('clusters//journal/', ObjectJournalView.as_view(), name='cluster_journal', kwargs={'model': Cluster}), path('clusters//devices/add/', views.ClusterAddDevicesView.as_view(), name='cluster_add_devices'), path('clusters//devices/remove/', views.ClusterRemoveDevicesView.as_view(), name='cluster_remove_devices'), @@ -50,6 +53,7 @@ urlpatterns = [ path('virtual-machines//delete/', views.VirtualMachineDeleteView.as_view(), name='virtualmachine_delete'), path('virtual-machines//config-context/', views.VirtualMachineConfigContextView.as_view(), name='virtualmachine_configcontext'), path('virtual-machines//changelog/', ObjectChangeLogView.as_view(), name='virtualmachine_changelog', kwargs={'model': VirtualMachine}), + path('virtual-machines//journal/', ObjectJournalView.as_view(), name='virtualmachine_journal', kwargs={'model': VirtualMachine}), path('virtual-machines//services/assign/', ServiceEditView.as_view(), name='virtualmachine_service_assign'), # VM interfaces diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index e5fe05858..d0bb0cf76 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -42,6 +42,15 @@ class ClusterTypeBulkImportView(generic.BulkImportView): table = tables.ClusterTypeTable +class ClusterTypeBulkEditView(generic.BulkEditView): + queryset = ClusterType.objects.annotate( + cluster_count=count_related(Cluster, 'type') + ) + filterset = filters.ClusterTypeFilterSet + table = tables.ClusterTypeTable + form = forms.ClusterTypeBulkEditForm + + class ClusterTypeBulkDeleteView(generic.BulkDeleteView): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') @@ -70,11 +79,22 @@ class ClusterGroupDeleteView(generic.ObjectDeleteView): class ClusterGroupBulkImportView(generic.BulkImportView): - queryset = ClusterGroup.objects.all() + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) model_form = forms.ClusterGroupCSVForm table = tables.ClusterGroupTable +class ClusterGroupBulkEditView(generic.BulkEditView): + queryset = ClusterGroup.objects.annotate( + cluster_count=count_related(Cluster, 'group') + ) + filterset = filters.ClusterGroupFilterSet + table = tables.ClusterGroupTable + form = forms.ClusterGroupBulkEditForm + + class ClusterGroupBulkDeleteView(generic.BulkDeleteView): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') diff --git a/requirements.txt b/requirements.txt index f9e616c5e..d31dc91a2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,24 @@ Django==3.2b1 django-cacheops==5.1 -django-cors-headers==3.5.0 -django-debug-toolbar==3.1.1 +django-cors-headers==3.7.0 +django-debug-toolbar==3.2 django-filter==2.4.0 -django-mptt==0.11.0 +django-mptt==0.12.0 django-pglocks==1.0.4 django-prometheus==2.1.0 django-rq==2.4.0 -django-tables2==2.3.3 +django-tables2==2.3.4 django-taggit==1.3.0 -django-timezone-field==4.0 +django-timezone-field==4.1.1 djangorestframework==3.12.2 drf-yasg[validation]==1.20.0 gunicorn==20.0.4 -Jinja2==2.11.2 -Markdown==3.3.3 +Jinja2==2.11.3 +Markdown==3.3.4 netaddr==0.8.0 -Pillow==8.0.1 +Pillow==8.1.2 psycopg2-binary==2.8.6 -pycryptodome==3.9.9 -PyYAML==5.3.1 -svgwrite==1.4 +pycryptodome==3.10.1 +PyYAML==5.4.1 +svgwrite==1.4.1 +tablib==3.0.0