diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b12c80eac..67f5028cd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,10 +3,12 @@ on: [push, pull_request] jobs: build: runs-on: ubuntu-latest + env: + NETBOX_CONFIGURATION: netbox.configuration_testing strategy: matrix: - python-version: [3.7, 3.8, 3.9] - node-version: [14.x] + python-version: ['3.8', '3.9', '3.10'] + node-version: ['14.x'] services: redis: image: redis @@ -57,7 +59,6 @@ jobs: python -m pip install --upgrade pip pip install -r requirements.txt pip install pycodestyle coverage tblib - ln -s configuration.testing.py netbox/netbox/configuration.py - name: Build documentation run: mkdocs build diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 000000000..bb42ca19f --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,10 @@ +version: 2 +build: + os: ubuntu-20.04 + tools: + python: "3.9" +mkdocs: + configuration: mkdocs.yml +python: + install: + - requirements: requirements.txt diff --git a/base_requirements.txt b/base_requirements.txt index 247a37a41..77a5bb8aa 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # The Python web framework on which NetBox is built # https://github.com/django/django -Django<4.0 +Django # Django middleware which permits cross-domain API requests # https://github.com/OttoYiu/django-cors-headers @@ -82,6 +82,10 @@ markdown-include # https://github.com/squidfunk/mkdocs-material mkdocs-material +# Introspection for embedded code +# https://github.com/mkdocstrings/mkdocstrings +mkdocstrings + # Library for manipulating IP prefixes and addresses # https://github.com/netaddr/netaddr netaddr @@ -113,3 +117,7 @@ svgwrite # Tabular dataset library (for table-based exports) # https://github.com/jazzband/tablib tablib + +# Timezone data (required by django-timezone-field on Python 3.9+) +# https://github.com/python/tzdata +tzdata diff --git a/docs/configuration/dynamic-settings.md b/docs/configuration/dynamic-settings.md index a222272c2..5649eb9be 100644 --- a/docs/configuration/dynamic-settings.md +++ b/docs/configuration/dynamic-settings.md @@ -66,6 +66,22 @@ CUSTOM_VALIDATORS = { --- +## DEFAULT_USER_PREFERENCES + +This is a dictionary defining the default preferences to be set for newly-created user accounts. For example, to set the default page size for all users to 100, define the following: + +```python +DEFAULT_USER_PREFERENCES = { + "pagination": { + "per_page": 100 + } +} +``` + +For a complete list of available preferences, log into NetBox and navigate to `/user/preferences/`. A period in a preference name indicates a level of nesting in the JSON data. The example above maps to `pagination.per_page`. + +--- + ## ENFORCE_GLOBAL_UNIQUE Default: False diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 95ed3fc37..a863ef3dc 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -1,6 +1,11 @@ # NetBox Configuration -NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py`. An example configuration is provided as `configuration.example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. +NetBox's local configuration is stored in `$INSTALL_ROOT/netbox/netbox/configuration.py` by default. An example configuration is provided as `configuration_example.py`. You may copy or rename the example configuration and make changes as appropriate. NetBox will not run without a configuration file. While NetBox has many configuration settings, only a few of them must be defined at the time of installation: these are defined under "required settings" below. + +!!! info "Customizing the Configuration Module" + A custom configuration module may be specified by setting the `NETBOX_CONFIGURATION` environment variable. This must be a dotted path to the desired Python module. For example, a file named `my_config.py` in the same directory as `settings.py` would be referenced as `netbox.my_config`. + + For the sake of brevity, the NetBox documentation refers to the configuration file simply as `configuration.py`. Some configuration parameters may alternatively be defined either in `configuration.py` or within the administrative section of the user interface. Settings which are "hard-coded" in the configuration file take precedence over those defined via the UI. diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d8d79b6ec..76fd0a12c 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -13,6 +13,23 @@ ADMINS = [ --- +## AUTH_PASSWORD_VALIDATORS + +This parameter acts as a pass-through for configuring Django's built-in password validators for local user accounts. If configured, these will be applied whenever a user's password is updated to ensure that it meets minimum criteria such as length or complexity. An example is provided below. For more detail on the available options, please see [the Django documentation](https://docs.djangoproject.com/en/stable/topics/auth/passwords/#password-validation). + +```python +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + 'OPTIONS': { + 'min_length': 10, + } + }, +] +``` + +--- + ## BASE_PATH Default: None @@ -49,6 +66,21 @@ CORS_ORIGIN_WHITELIST = [ --- +## CSRF_TRUSTED_ORIGINS + +Default: `[]` + +Defines a list of trusted origins for unsafe (e.g. `POST`) requests. This is a pass-through to Django's [`CSRF_TRUSTED_ORIGINS`](https://docs.djangoproject.com/en/4.0/ref/settings/#std:setting-CSRF_TRUSTED_ORIGINS) setting. Note that each host listed must specify a scheme (e.g. `http://` or `https://). + +```python +CSRF_TRUSTED_ORIGINS = ( + 'http://netbox.local', + 'https://netbox.local', +) +``` + +--- + ## DEBUG Default: False @@ -140,6 +172,66 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- +## FIELD_CHOICES + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. (A list of available colors is provided below.) + +The choices provided can either replace the stock choices provided by NetBox, or append to them. To _replace_ the available choices, specify the app, model, and field name separated by dots. For example, the site model would be referenced as `dcim.Site.status`. To _extend_ the available choices, append a plus sign to the end of this string (e.g. `dcim.Site.status+`). + +For example, the following configuration would replace the default site status choices with the options Foo, Bar, and Baz: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +Appending a plus sign to the field identifier would instead _add_ these choices to the ones already offered: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ... + ) +} +``` + +The following model fields support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `extras.JournalEntry.kind` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.VirtualMachine.status` + +The following colors are supported: + +* `blue` +* `indigo` +* `purple` +* `pink` +* `red` +* `orange` +* `yellow` +* `green` +* `teal` +* `cyan` +* `gray` +* `black` +* `white` + +--- + ## HTTP_PROXIES Default: None diff --git a/docs/core-functionality/device-types.md b/docs/core-functionality/device-types.md index 037d3cfd0..ec5cbacdb 100644 --- a/docs/core-functionality/device-types.md +++ b/docs/core-functionality/device-types.md @@ -37,4 +37,5 @@ Once component templates have been created, every new device that you create as {!models/dcim/interfacetemplate.md!} {!models/dcim/frontporttemplate.md!} {!models/dcim/rearporttemplate.md!} +{!models/dcim/modulebaytemplate.md!} {!models/dcim/devicebaytemplate.md!} diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 982ee3071..35c978210 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -17,6 +17,7 @@ Device components represent discrete objects within a device which are used to t {!models/dcim/interface.md!} {!models/dcim/frontport.md!} {!models/dcim/rearport.md!} +{!models/dcim/modulebay.md!} {!models/dcim/devicebay.md!} {!models/dcim/inventoryitem.md!} diff --git a/docs/core-functionality/modules.md b/docs/core-functionality/modules.md new file mode 100644 index 000000000..4d32fe18c --- /dev/null +++ b/docs/core-functionality/modules.md @@ -0,0 +1,4 @@ +# Modules + +{!models/dcim/moduletype.md!} +{!models/dcim/module.md!} diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 2e7aaf65a..316c7fe00 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,3 +1,4 @@ # Service Mapping +{!models/ipam/servicetemplate.md!} {!models/ipam/service.md!} diff --git a/docs/customization/reports.md b/docs/customization/reports.md index ed4faf371..3bf6bd8d9 100644 --- a/docs/customization/reports.md +++ b/docs/customization/reports.md @@ -95,7 +95,7 @@ The following methods are available to log results within a report: The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page. -To perform additional tasks, such as sending an email or calling a webhook, after a report has been run, extend the `post_run()` method. The status of the report is available as `self.failed` and the results object is `self.result`. +To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively. The status of a completed report is available as `self.failed` and the results object is `self.result`. By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last. diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index d55afb2f2..f4d171f48 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -2,7 +2,7 @@ ## 1. Define the model class -Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. +Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be NetBoxModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module. Each model should define, at a minimum: diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 3c02c01bf..dbbe8378d 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -11,17 +11,25 @@ Getting started with NetBox development is pretty straightforward, and should fe ### Fork the Repo -Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) You can then clone your GitHub fork locally for development: +Assuming you'll be working on your own fork, your first step will be to fork the [official git repository](https://github.com/netbox-community/netbox). (If you're a maintainer who's going to be working directly with the official repo, skip this step.) Click the "fork" button at top right (be sure that you've logged into GitHub first). + +![GitHub fork button](../media/development/github_fork_button.png) + +Copy the URL provided in the dialog box. + +![GitHub fork dialog](../media/development/github_fork_dialog.png) + +You can then clone your GitHub fork locally for development: ```no-highlight -$ git clone https://github.com/youruseraccount/netbox.git +$ git clone https://github.com/$username/netbox.git Cloning into 'netbox'... -remote: Enumerating objects: 231, done. -remote: Counting objects: 100% (231/231), done. -remote: Compressing objects: 100% (147/147), done. -remote: Total 56705 (delta 134), reused 145 (delta 84), pack-reused 56474 -Receiving objects: 100% (56705/56705), 27.96 MiB | 34.92 MiB/s, done. -Resolving deltas: 100% (44177/44177), done. +remote: Enumerating objects: 85949, done. +remote: Counting objects: 100% (4672/4672), done. +remote: Compressing objects: 100% (1224/1224), done. +remote: Total 85949 (delta 3538), reused 4332 (delta 3438), pack-reused 81277 +Receiving objects: 100% (85949/85949), 55.16 MiB | 44.90 MiB/s, done. +Resolving deltas: 100% (68008/68008), done. $ ls netbox/ base_requirements.txt contrib docs mkdocs.yml NOTICE requirements.txt upgrade.sh CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts @@ -33,7 +41,7 @@ The NetBox project utilizes three persistent git branches to track work: * `develop` - All development on the upcoming stable release occurs here * `feature` - Tracks work on an upcoming major release -Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch. +Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release. For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0). @@ -60,7 +68,7 @@ $ python3 -m venv ~/.venv/netbox This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`. !!! info "Where to Create Your Virtual Environments" - Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. + Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please. Also consider using [`virtualenvwrapper`](https://virtualenvwrapper.readthedocs.io/en/stable/) to simplify the management of multiple venvs. Once created, activate the virtual environment: @@ -85,7 +93,7 @@ Collecting Django==3.1 (from -r requirements.txt (line 1)) ### Configure NetBox -Within the `netbox/netbox/` directory, copy `configuration.example.py` to `configuration.py` and update the following parameters: +Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: * `ALLOWED_HOSTS`: This can be set to `['*']` for development purposes * `DATABASE`: PostgreSQL database connection parameters @@ -99,12 +107,13 @@ Within the `netbox/netbox/` directory, copy `configuration.example.py` to `confi Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command: ```no-highlight -$ python netbox/manage.py runserver +$ ./manage.py runserver +Watching for file changes with StatReloader Performing system checks... System check identified no issues (0 silenced). -November 18, 2020 - 15:52:31 -Django version 3.1, using settings 'netbox.settings' +February 18, 2022 - 20:29:57 +Django version 4.0.2, using settings 'netbox.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. ``` @@ -122,26 +131,36 @@ The demo data is provided in JSON format and loaded into an empty database using ## Running Tests -Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `/netbox/` directory, not the root directory of the repository. +Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command, which employs Python's [`unittest`](https://docs.python.org/3/library/unittest.html#module-unittest) library. Remember to ensure the Python virtual environment is active before running this command. Also keep in mind that these commands are executed in the `netbox/` directory, not the root directory of the repository. -When running tests, it's advised to use the special testing configuration file that ships with NetBox. This ensures that tests are run with the same configuration parameters consistently. To override your local configuration when running tests, set the `NETBOX_CONFIGURATION` environment variable to `netbox.configuration_testing`. +To avoid potential issues with your local configuration file, set the `NETBOX_CONFIGURATION` to point to the packaged test configuration at `netbox/configuration_testing.py`. This will handle things like ensuring that the dummy plugin is enabled for comprehensive testing. ```no-highlight -$ NETBOX_CONFIGURATION=netbox.configuration_testing python manage.py test +$ export NETBOX_CONFIGURATION=netbox.configuration_testing +$ cd netbox/ +$ python manage.py test ``` -In cases where you haven't made any changes to the database (which is most of the time), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.) +In cases where you haven't made any changes to the database schema (which is typical), you can append the `--keepdb` argument to this command to reuse the test database between runs. This cuts down on the time it takes to run the test suite since the database doesn't have to be rebuilt each time. (Note that this argument will cause errors if you've modified any model fields since the previous test run.) ```no-highlight $ python manage.py test --keepdb ``` -You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests: +You can also reduce testing time by enabling parallel test execution with the `--parallel` flag. (By default, this will run as many parallel tests as you have processors. To avoid sluggishness, it's a good idea to specify a lower number of parallel tests.) This flag can be combined with `--keepdb`, although if you encounter any strange errors, try running the test suite again with parallelization disabled. + +```no-highlight +$ python manage.py test --parallel +``` + +Finally, it's possible to limit the run to a specific set of tests, specified by their Python path. For example, to run only IPAM and DCIM view tests: ```no-highlight $ python manage.py test dcim.tests.test_views ipam.tests.test_views ``` +This is handy for instances where just a few tests are failing and you want to re-run them individually. + ## Submitting Pull Requests Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 8fbec84f9..17c27948d 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -8,7 +8,7 @@ Check `base_requirements.txt` for any dependencies pinned to a specific version, ### Link to the Release Notes Page -Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and point `index.md` to the new file. +Add the release notes (`/docs/release-notes/X.Y.md`) to the table of contents within `mkdocs.yml`, and add a summary of the major changes to `index.md`. ### Manually Perform a New Install diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index 0595bc358..ceb5321a9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,8 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| Name | Description | -| ---- | ----------- | -| extras.configcontext.format | Preferred format when rendering config context data (JSON or YAML) | -| pagination.per_page | The number of items to display per page of a paginated table | -| tables.TABLE_NAME.columns | The ordered list of columns to display when viewing the table | +| Name | Description | +|--------------------------|---------------------------------------------------------------| +| data_format | Preferred format when rendering raw data (JSON or YAML) | +| pagination.per_page | The number of items to display per page of a paginated table | +| pagination.placement | Where to display the paginator controls relative to the table | +| tables.${table}.columns | The ordered list of columns to display when viewing the table | +| tables.${table}.ordering | A list of column names by which the table should be ordered | +| ui.colormode | Light or dark mode in the user interface | diff --git a/docs/index.md b/docs/index.md index 943f1d7ab..81c899387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and ## Supported Python Versions -NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Python 3.6 was removed in NetBox v3.0.) +NetBox supports Python 3.8, 3.9, and 3.10 environments. ## Getting Started diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index b1e1e832e..50b350d3a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -6,8 +6,8 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. -!!! warning "Python 3.7 or later required" - NetBox v3.0 and v3.1 require Python 3.7, 3.8, or 3.9. It is recommended to install at least Python v3.8, as this will become the minimum supported Python version in NetBox v3.2. +!!! warning "Python 3.8 or later required" + NetBox v3.2 requires Python 3.8, 3.9, or 3.10. === "Ubuntu" @@ -17,16 +17,11 @@ Begin by installing all system packages required by NetBox and its dependencies. === "CentOS" - !!! warning - CentOS 8 does not provide Python 3.7 or later via its native package manager. You will need to install it via some other means. [Here is an example](https://tecadmin.net/install-python-3-7-on-centos-8/) of installing Python 3.7 from source. - - Once you have Python 3.7 or later installed, install the remaining system packages: - ```no-highlight sudo yum install -y gcc libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config ``` -Before continuing, check that your installed Python version is at least 3.7: +Before continuing, check that your installed Python version is at least 3.8: ```no-highlight python3 -V @@ -117,11 +112,11 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s ## Configuration -Move into the NetBox configuration directory and make a copy of `configuration.example.py` named `configuration.py`. This file will hold all of your local configuration parameters. +Move into the NetBox configuration directory and make a copy of `configuration_example.py` named `configuration.py`. This file will hold all of your local configuration parameters. ```no-highlight cd /opt/netbox/netbox/netbox/ -sudo cp configuration.example.py configuration.py +sudo cp configuration_example.py configuration.py ``` Open `configuration.py` with your preferred editor to begin configuring NetBox. NetBox offers [many configuration parameters](../configuration/index.md), but only the following four are required for new installations: @@ -234,10 +229,10 @@ Once NetBox has been configured, we're ready to proceed with the actual installa sudo /opt/netbox/upgrade.sh ``` -Note that **Python 3.7 or later is required** for NetBox v3.0 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) +Note that **Python 3.8 or later is required** for NetBox v3.2 and later releases. If the default Python installation on your server is set to a lesser version, pass the path to the supported installation as an environment variable named `PYTHON`. (Note that the environment variable must be passed _after_ the `sudo` command.) ```no-highlight -sudo PYTHON=/usr/bin/python3.7 /opt/netbox/upgrade.sh +sudo PYTHON=/usr/bin/python3.8 /opt/netbox/upgrade.sh ``` !!! note diff --git a/docs/installation/index.md b/docs/installation/index.md index a06a60bf6..905add7ab 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -15,7 +15,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index c619cf241..014dffaf8 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -10,7 +10,7 @@ NetBox v3.0 and later require the following: | Dependency | Minimum Version | |------------|-----------------| -| Python | 3.7 | +| Python | 3.8 | | PostgreSQL | 10 | | Redis | 4.0 | @@ -81,10 +81,10 @@ sudo ./upgrade.sh ``` !!! warning - If the default version of Python is not at least 3.7, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: + If the default version of Python is not at least 3.8, you'll need to pass the path to a supported Python version as an environment variable when calling the upgrade script. For example: ```no-highlight - sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh + sudo PYTHON=/usr/bin/python3.8 ./upgrade.sh ``` This script performs the following actions: diff --git a/docs/media/development/github_fork_button.png b/docs/media/development/github_fork_button.png new file mode 100644 index 000000000..b326d5de1 Binary files /dev/null and b/docs/media/development/github_fork_button.png differ diff --git a/docs/media/development/github_fork_dialog.png b/docs/media/development/github_fork_dialog.png new file mode 100644 index 000000000..32fed0523 Binary files /dev/null and b/docs/media/development/github_fork_dialog.png differ diff --git a/docs/media/plugins/plugin_admin_ui.png b/docs/media/plugins/plugin_admin_ui.png deleted file mode 100644 index 44802c5fc..000000000 Binary files a/docs/media/plugins/plugin_admin_ui.png and /dev/null differ diff --git a/docs/media/plugins/plugin_rest_api_endpoint.png b/docs/media/plugins/plugin_rest_api_endpoint.png deleted file mode 100644 index 7cdf34cc8..000000000 Binary files a/docs/media/plugins/plugin_rest_api_endpoint.png and /dev/null differ diff --git a/docs/models/circuits/providernetwork.md b/docs/models/circuits/providernetwork.md index f5a428e96..42c46e13c 100644 --- a/docs/models/circuits/providernetwork.md +++ b/docs/models/circuits/providernetwork.md @@ -2,4 +2,4 @@ This model can be used to represent the boundary of a provider network, the details of which are unknown or unimportant to the NetBox user. For example, it might represent a provider's regional MPLS network to which multiple circuits provide connectivity. -Each provider network must be assigned to a provider. A circuit may terminate to either a provider network or to a site. +Each provider network must be assigned to a provider, and may optionally be assigned an arbitrary service ID. A circuit may terminate to either a provider network or to a site. diff --git a/docs/models/dcim/devicebay.md b/docs/models/dcim/devicebay.md index 2aea14a7a..e79c426dc 100644 --- a/docs/models/dcim/devicebay.md +++ b/docs/models/dcim/devicebay.md @@ -5,4 +5,4 @@ Device bays represent a space or slot within a parent device in which a child de Child devices are first-class Devices in their own right: That is, they are fully independent managed entities which don't share any control plane with the parent. Just like normal devices, child devices have their own platform (OS), role, tags, and components. LAG interfaces may not group interfaces belonging to different child devices. !!! note - Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + Device bays are **not** suitable for modeling line cards (such as those commonly found in chassis-based routers and switches), as these components depend on the control plane of the parent device to operate. Instead, these should be modeled as modules installed within module bays. diff --git a/docs/models/dcim/devicebaytemplate.md b/docs/models/dcim/devicebaytemplate.md index ebf7bd63c..a4c50067a 100644 --- a/docs/models/dcim/devicebaytemplate.md +++ b/docs/models/dcim/devicebaytemplate.md @@ -1,3 +1,3 @@ ## Device Bay Templates -A template for a device bay that will be created on all instantiations of the parent device type. +A template for a device bay that will be created on all instantiations of the parent device type. Device bays hold child devices, such as blade servers. diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index b919465c8..cf42185f4 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -4,13 +4,13 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively. -Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: +Some devices house child devices which share physical resources, like space and power, but which function independently. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) * A child device (which must be installed within a device bay) * Neither !!! note - This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index 585674de1..7fa52fa9f 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -1,6 +1,6 @@ ## Interfaces -Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). +Interfaces in NetBox represent network interfaces used to exchange data with connected devices. On modern networks, these are most commonly Ethernet, but other types are supported as well. Each interface must be assigned a type, and may optionally be assigned a MAC address, MTU, and IEEE 802.1Q mode (tagged or access). Each interface can also be enabled or disabled, and optionally designated as management-only (for out-of-band management). Additionally, each interface may optionally be assigned to a VRF. !!! note Although devices and virtual machines both can have interfaces, a separate model is used for each. Thus, device interfaces have some properties that are not present on virtual machine interfaces and vice versa. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 237bad92c..fbd3172bb 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,7 +1,7 @@ # Inventory Items -Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. -Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox). +Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside NetBox). -Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. +Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md new file mode 100644 index 000000000..8ed31481a --- /dev/null +++ b/docs/models/dcim/inventoryitemrole.md @@ -0,0 +1,3 @@ +# Inventory Item Roles + +Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc. diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md new file mode 100644 index 000000000..3167ed4ab --- /dev/null +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -0,0 +1,3 @@ +# Inventory Item Templates + +A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. diff --git a/docs/models/dcim/module.md b/docs/models/dcim/module.md new file mode 100644 index 000000000..bc9753ecc --- /dev/null +++ b/docs/models/dcim/module.md @@ -0,0 +1,5 @@ +# Modules + +A module is a field-replaceable hardware component installed within a device which houses its own child components. The most common example is a chassis-based router or switch. + +Similar to devices, modules are instantiated from module types, and any components associated with the module type are automatically instantiated on the new model. Each module must be installed within a module bay on a device, and each module bay may have only one module installed in it. A module may optionally be assigned a serial number and asset tag. diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md new file mode 100644 index 000000000..6c6f94598 --- /dev/null +++ b/docs/models/dcim/modulebay.md @@ -0,0 +1,3 @@ +## Module Bays + +Module bays represent a space or slot within a device in which a field-replaceable module may be installed. A common example is that of a chassis-based switch such as the Cisco Nexus 9000 or Juniper EX9200. Modules in turn hold additional components that become available to the parent device. diff --git a/docs/models/dcim/modulebaytemplate.md b/docs/models/dcim/modulebaytemplate.md new file mode 100644 index 000000000..463789305 --- /dev/null +++ b/docs/models/dcim/modulebaytemplate.md @@ -0,0 +1,3 @@ +## Module Bay Templates + +A template for a module bay that will be created on all instantiations of the parent device type. Module bays hold installed modules that do not have an independent management plane, such as line cards. diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md new file mode 100644 index 000000000..c1c8c5079 --- /dev/null +++ b/docs/models/dcim/moduletype.md @@ -0,0 +1,23 @@ +# Module Types + +A module type represent a specific make and model of hardware component which is installable within a device and has its own child components. For example, consider a chassis-based switch or router with a number of field-replaceable line cards. Each line card has its own model number and includes a certain set of components such as interfaces. Each module type may have a manufacturer, model number, and part number assigned to it. + +Similar to device types, each module type can have any of the following component templates associated with it: + +* Interfaces +* Console ports +* Console server ports +* Power ports +* Power Outlets +* Front pass-through ports +* Rear pass-through ports + +Note that device bays and module bays may _not_ be added to modules. + +## Automatic Component Renaming + +When adding component templates to a module type, the string `{module}` can be used to reference the `position` field of the module bay into which an instance of the module type is being installed. + +For example, you can create a module type with interface templates named `Gi{module}/0/[1-48]`. When a new module of this type is "installed" to a module bay with a position of "3", NetBox will automatically name these interfaces `Gi3/0/[1-48]`. + +Automatic renaming is supported for all modular component types (those listed above). diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index e3462a6a7..da73816b6 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -19,6 +19,8 @@ Custom fields may be created by navigating to Customization > Custom Fields. Net * JSON: Arbitrary data stored in JSON format * Selection: A selection of one of several pre-defined custom choices * Multiple selection: A selection field which supports the assignment of multiple values +* Object: A single NetBox object of the type defined by `object_type` +* Multiple object: One or more NetBox objects of the type defined by `object_type` Each custom field must have a name. This should be a simple database-friendly string (e.g. `tps_report`) and may contain only alphanumeric characters and underscores. You may also assign a corresponding human-friendly label (e.g. "TPS report"); the label will be displayed on web forms. A weight is also required: Higher-weight fields will be ordered lower within a form. (The default weight is 100.) If a description is provided, it will appear beneath the field in a form. @@ -41,3 +43,7 @@ NetBox supports limited custom validation for custom field values. Following are Each custom selection field must have at least two choices. These are specified as a comma-separated list. Choices appear in forms in the order they are listed. Note that choice values are saved exactly as they appear, so it's best to avoid superfluous punctuation or symbols where possible. If a default value is specified for a selection field, it must exactly match one of the provided choices. The value of a multiple selection field will always return a list, even if only one value is selected. + +### Custom Object Fields + +An object or multi-object custom field can be used to refer to a particular NetBox object or objects as the "value" for a custom field. These custom fields must define an `object_type`, which determines the type of object to which custom field instances point. diff --git a/docs/models/extras/customlink.md b/docs/models/extras/customlink.md index b9b731751..16ba9d2af 100644 --- a/docs/models/extras/customlink.md +++ b/docs/models/extras/customlink.md @@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as: View NMS ``` -Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links. +Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually. !!! warning Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users. @@ -24,13 +24,14 @@ Custom links appear as buttons in the top right corner of the page. Numeric weig The following context data is available within the template when rendering a custom link's text or URL. -| Variable | Description | -|----------|-------------| -| `obj` | The NetBox object being displayed | -| `debug` | A boolean indicating whether debugging is enabled | -| `request` | The current WSGI request | -| `user` | The current user (if authenticated) | -| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | +| Variable | Description | +|-----------|-------------------------------------------------------------------------------------------------------------------| +| `object` | The NetBox object being displayed | +| `obj` | Same as `object`; maintained for backward compatability until NetBox v3.5 | +| `debug` | A boolean indicating whether debugging is enabled | +| `request` | The current WSGI request | +| `user` | The current user (if authenticated) | +| `perms` | The [permissions](https://docs.djangoproject.com/en/stable/topics/auth/default/#permissions) assigned to the user | While most of the context variables listed above will have consistent attributes, the object will be an instance of the specific object being viewed when the link is rendered. Different models have different fields and properties, so you may need to some research to determine the attributes available for use within your template for a specific object type. diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index eb47dd332..d0137938d 100644 --- a/docs/models/extras/webhook.md +++ b/docs/models/extras/webhook.md @@ -3,7 +3,7 @@ A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks. !!! warning - Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. + Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users. ## Configuration @@ -12,7 +12,7 @@ A webhook is a mechanism for conveying to some external system a change that too * **Enabled** - If unchecked, the webhook will be inactive. * **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected. * **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`. -* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed. +* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field. * **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`) * **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below). * **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.) @@ -23,7 +23,7 @@ A webhook is a mechanism for conveying to some external system a change that too ## Jinja2 Template Support -[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. +[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands. For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration: diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md new file mode 100644 index 000000000..7fed40211 --- /dev/null +++ b/docs/models/ipam/servicetemplate.md @@ -0,0 +1,3 @@ +# Service Templates + +Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses. diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 819d45982..2840fafed 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -2,4 +2,6 @@ VLAN groups can be used to organize VLANs within NetBox. Each VLAN group can be scoped to a particular region, site group, site, location, rack, cluster group, or cluster. Member VLANs will be available for assignment to devices and/or virtual machines within the specified scope. +A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive). + Groups can also be used to enforce uniqueness: Each VLAN within a group must have a unique ID and name. VLANs which are not assigned to a group may have overlapping names and IDs (including VLANs which belong to a common site). For example, you can create two VLANs with ID 123, but they cannot both be assigned to the same group. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index 6fac7ce36..7f1a5082d 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -1,3 +1,3 @@ ## Interfaces -Virtual machine interfaces behave similarly to device interfaces, and can be assigned IP addresses, VLANs, and services. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. +Virtual machine interfaces behave similarly to device interfaces, and can be assigned to VRFs, and may have IP addresses, VLANs, and services attached to them. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. diff --git a/docs/plugins/development.md b/docs/plugins/development.md deleted file mode 100644 index 9d1ad1444..000000000 --- a/docs/plugins/development.md +++ /dev/null @@ -1,432 +0,0 @@ -# Plugin Development - -!!! info "Help Improve the NetBox Plugins Framework!" - We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338). - -This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. - -Plugins can do a lot, including: - -* Create Django models to store data in the database -* Provide their own "pages" (views) in the web user interface -* Inject template content and navigation links -* Establish their own REST API endpoints -* Add custom request/response middleware - -However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. - -!!! warning - While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework so as to avoid breaking changes in future releases. - -## Initial Setup - -### Plugin Structure - -Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin looks something like this: - -```no-highlight -project-name/ - - plugin_name/ - - templates/ - - plugin_name/ - - *.html - - __init__.py - - middleware.py - - navigation.py - - signals.py - - template_content.py - - urls.py - - views.py - - README - - setup.py -``` - -The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: - -* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. -* `README` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write README files using a markup language such as Markdown. -* The plugin source directory, with the same name as your plugin. This must be a valid Python package name (e.g. no spaces or hyphens). - -The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class. - -### Create setup.py - -`setup.py` is the [setup script](https://docs.python.org/3.7/distutils/setupscript.html) we'll use to install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to inform the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: - -```python -from setuptools import find_packages, setup - -setup( - name='netbox-animal-sounds', - version='0.1', - description='An example NetBox plugin', - url='https://github.com/netbox-community/netbox-animal-sounds', - author='Jeremy Stretch', - license='Apache 2.0', - install_requires=[], - packages=find_packages(), - include_package_data=True, - zip_safe=False, -) -``` - -Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). - -!!! note - `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) - -### Define a PluginConfig - -The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: - -```python -from extras.plugins import PluginConfig - -class AnimalSoundsConfig(PluginConfig): - name = 'netbox_animal_sounds' - verbose_name = 'Animal Sounds' - description = 'An example plugin for development purposes' - version = '0.1' - author = 'Jeremy Stretch' - author_email = 'author@example.com' - base_url = 'animal-sounds' - required_settings = [] - default_settings = { - 'loud': False - } - -config = AnimalSoundsConfig -``` - -NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. - -#### PluginConfig Attributes - -| Name | Description | -| ---- | ----------- | -| `name` | Raw plugin name; same as the plugin's source directory | -| `verbose_name` | Human-friendly name for the plugin | -| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | -| `description` | Brief description of the plugin's purpose | -| `author` | Name of plugin's author | -| `author_email` | Author's public email address | -| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | -| `required_settings` | A list of any configuration parameters that **must** be defined by the user | -| `default_settings` | A dictionary of configuration parameters and their default values | -| `min_version` | Minimum version of NetBox with which the plugin is compatible | -| `max_version` | Maximum version of NetBox with which the plugin is compatible | -| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | -| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | -| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | - -All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. - -### Create a Virtual Environment - -It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) specific to your plugin. This will afford you complete control over the installed versions of all dependencies and avoid conflicting with any system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) - -```shell -python3 -m venv /path/to/my/venv -``` - -You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) - -```shell -cd $VENV/lib/python3.7/site-packages/ -echo /opt/netbox/netbox > netbox.pth -``` - -### Install the Plugin for Development - -To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): - -```no-highlight -$ python setup.py develop -``` - -## Database Models - -If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Model instances can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. - -Below is an example `models.py` file containing a model with two character fields: - -```python -from django.db import models - -class Animal(models.Model): - name = models.CharField(max_length=50) - sound = models.CharField(max_length=50) - - def __str__(self): - return self.name -``` - -Once you have defined the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. - -!!! note - A plugin must be installed before it can be used with Django management commands. If you skipped this step above, run `python setup.py develop` from the plugin's root directory. - -```no-highlight -$ ./manage.py makemigrations netbox_animal_sounds -Migrations for 'netbox_animal_sounds': - /home/jstretch/animal_sounds/netbox_animal_sounds/migrations/0001_initial.py - - Create model Animal -``` - -Next, we can apply the migration to the database with the `migrate` command: - -```no-highlight -$ ./manage.py migrate netbox_animal_sounds -Operations to perform: - Apply all migrations: netbox_animal_sounds -Running migrations: - Applying netbox_animal_sounds.0001_initial... OK -``` - -For more background on schema migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). - -### Using the Django Admin Interface - -Plugins can optionally expose their models via Django's built-in [administrative interface](https://docs.djangoproject.com/en/stable/ref/contrib/admin/). This can greatly improve troubleshooting ability, particularly during development. To expose a model, simply register it using Django's `admin.register()` function. An example `admin.py` file for the above model is shown below: - -```python -from django.contrib import admin -from .models import Animal - -@admin.register(Animal) -class AnimalAdmin(admin.ModelAdmin): - list_display = ('name', 'sound') -``` - -This will display the plugin and its model in the admin UI. Staff users can create, change, and delete model instances via the admin UI without needing to create a custom view. - -![NetBox plugin in the admin UI](../media/plugins/plugin_admin_ui.png) - -## Views - -If your plugin needs its own page or pages in the NetBox web UI, you'll need to define views. A view is a particular page tied to a URL within NetBox, which renders content using a template. Views are typically defined in `views.py`, and URL patterns in `urls.py`. As an example, let's write a view which displays a random animal and the sound it makes. First, we'll create the view in `views.py`: - -```python -from django.shortcuts import render -from django.views.generic import View -from .models import Animal - -class RandomAnimalView(View): - """ - Display a randomly-selected animal. - """ - def get(self, request): - animal = Animal.objects.order_by('?').first() - return render(request, 'netbox_animal_sounds/animal.html', { - 'animal': animal, - }) -``` - -This view retrieves a random animal from the database and and passes it as a context variable when rendering a template named `animal.html`, which doesn't exist yet. To create this template, first create a directory named `templates/netbox_animal_sounds/` within the plugin source directory. (We use the plugin's name as a subdirectory to guard against naming collisions with other plugins.) Then, create a template named `animal.html` as described below. - -### Extending the Base Template - -NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This template includes four content blocks: - -* `title` - The page title -* `header` - The upper portion of the page -* `content` - The main page body -* `javascript` - A section at the end of the page for including Javascript code - -For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). - -```jinja2 -{% extends 'base/layout.html' %} - -{% block content %} - {% with config=settings.PLUGINS_CONFIG.netbox_animal_sounds %} -

- {% if animal %} - The {{ animal.name|lower }} says - {% if config.loud %} - {{ animal.sound|upper }}! - {% else %} - {{ animal.sound }} - {% endif %} - {% else %} - No animals have been created yet! - {% endif %} -

- {% endwith %} -{% endblock %} - -``` - -The first line of the template instructs Django to extend the NetBox base template and inject our custom content within its `content` block. - -!!! note - Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important differences to be aware of. - -Finally, to make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. - -```python -from django.urls import path -from . import views - -urlpatterns = [ - path('random/', views.RandomAnimalView.as_view(), name='random_animal'), -] -``` - -A URL pattern has three components: - -* `route` - The unique portion of the URL dedicated to this view -* `view` - The view itself -* `name` - A short name used to identify the URL path internally - -This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. - -## REST API Endpoints - -Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. NetBox uses the [Django REST Framework](https://www.django-rest-framework.org/), which makes writing API serializers and views very simple. - -First, we'll create a serializer for our `Animal` model, in `api/serializers.py`: - -```python -from rest_framework.serializers import ModelSerializer -from netbox_animal_sounds.models import Animal - -class AnimalSerializer(ModelSerializer): - - class Meta: - model = Animal - fields = ('id', 'name', 'sound') -``` - -Next, we'll create a generic API view set that allows basic CRUD (create, read, update, and delete) operations for Animal instances. This is defined in `api/views.py`: - -```python -from rest_framework.viewsets import ModelViewSet -from netbox_animal_sounds.models import Animal -from .serializers import AnimalSerializer - -class AnimalViewSet(ModelViewSet): - queryset = Animal.objects.all() - serializer_class = AnimalSerializer -``` - -Finally, we'll register a URL for our endpoint in `api/urls.py`. This file **must** define a variable named `urlpatterns`. - -```python -from rest_framework import routers -from .views import AnimalViewSet - -router = routers.DefaultRouter() -router.register('animals', AnimalViewSet) -urlpatterns = router.urls -``` - -With these three components in place, we can request `/api/plugins/animal-sounds/animals/` to retrieve a list of all Animal objects defined. - -![NetBox REST API plugin endpoint](../media/plugins/plugin_rest_api_endpoint.png) - -!!! warning - This example is provided as a minimal reference implementation only. It does not address authentication, performance, or myriad other concerns that plugin authors should have. - -## Navigation Menu Items - -To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. - -```python -from extras.plugins import PluginMenuButton, PluginMenuItem -from utilities.choices import ButtonColorChoices - -menu_items = ( - PluginMenuItem( - link='plugins:netbox_animal_sounds:random_animal', - link_text='Random sound', - buttons=( - PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), - PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), - ) - ), -) -``` - -A `PluginMenuItem` has the following attributes: - -* `link` - The name of the URL path to which this menu item links -* `link_text` - The text presented to the user -* `permissions` - A list of permissions required to display this link (optional) -* `buttons` - An iterable of PluginMenuButton instances to display (optional) - -A `PluginMenuButton` has the following attributes: - -* `link` - The name of the URL path to which this button links -* `title` - The tooltip text (displayed when the mouse hovers over the button) -* `icon_class` - Button icon CSS class (NetBox currently supports [Font Awesome 4.7](https://fontawesome.com/v4.7.0/icons/)) -* `color` - One of the choices provided by `ButtonColorChoices` (optional) -* `permissions` - A list of permissions required to display this button (optional) - -!!! note - Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. - -## Extending Core Templates - -Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: - -* `left_page()` - Inject content on the left side of the page -* `right_page()` - Inject content on the right side of the page -* `full_width_page()` - Inject content across the entire bottom of the page -* `buttons()` - Add buttons to the top of the page - -Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. - -When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: - -* `object` - The object being viewed -* `request` - The current request -* `settings` - Global NetBox settings -* `config` - Plugin-specific configuration parameters - -For example, accessing `{{ request.user }}` within a template will return the current user. - -Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. - -```python -from extras.plugins import PluginTemplateExtension -from .models import Animal - -class SiteAnimalCount(PluginTemplateExtension): - model = 'dcim.site' - - def right_page(self): - return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ - 'animal_count': Animal.objects.count(), - }) - -template_extensions = [SiteAnimalCount] -``` - -## Background Tasks - -By default, Netbox provides 3 differents [RQ](https://python-rq.org/) queues to run background jobs : *high*, *default* and *low*. -These 3 core queues can be used out-of-the-box by plugins to define background tasks. - -Plugins can also define dedicated queues. These queues can be configured under the PluginConfig class `queues` attribute. An example configuration -is below: - -```python -class MyPluginConfig(PluginConfig): - name = 'myplugin' - ... - queues = [ - 'queue1', - 'queue2', - 'queue-whatever-the-name' - ] -``` - -The PluginConfig above creates 3 queues with the following names: *myplugin.queue1*, *myplugin.queue2*, *myplugin.queue-whatever-the-name*. -As you can see, the queue's name is always preprended with the plugin's name, to avoid any name clashes between different plugins. - -In case you create dedicated queues for your plugin, it is strongly advised to also create a dedicated RQ worker instance. This instance should only listen to the queues defined in your plugin - to avoid impact between your background tasks and netbox internal tasks. - -``` -python manage.py rqworker myplugin.queue1 myplugin.queue2 myplugin.queue-whatever-the-name -``` diff --git a/docs/plugins/development/background-tasks.md b/docs/plugins/development/background-tasks.md new file mode 100644 index 000000000..5ed05752a --- /dev/null +++ b/docs/plugins/development/background-tasks.md @@ -0,0 +1,30 @@ +# Background Tasks + +NetBox supports the queuing of tasks that need to be performed in the background, decoupled from the request-response cycle, using the [Python RQ](https://python-rq.org/) library. Three task queues of differing priority are defined by default: + +* High +* Default +* Low + +Any tasks in the "high" queue are completed before the default queue is checked, and any tasks in the default queue are completed before those in the "low" queue. + +Plugins can also add custom queues for their own needs by setting the `queues` attribute under the PluginConfig class. An example is included below: + +```python +class MyPluginConfig(PluginConfig): + name = 'myplugin' + ... + queues = [ + 'foo', + 'bar', + ] +``` + +The PluginConfig above creates two custom queues with the following names `my_plugin.foo` and `my_plugin.bar`. (The plugin's name is prepended to each queue to avoid conflicts between plugins.) + +!!! warning "Configuring the RQ worker process" + By default, NetBox's RQ worker process only services the high, default, and low queues. Plugins which introduce custom queues should advise users to either reconfigure the default worker, or run a dedicated worker specifying the necessary queues. For example: + + ``` + python manage.py rqworker my_plugin.foo my_plugin.bar + ``` diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md new file mode 100644 index 000000000..318ad5b4e --- /dev/null +++ b/docs/plugins/development/filtersets.md @@ -0,0 +1,70 @@ +# Filters & Filter Sets + +Filter sets define the mechanisms available for filtering or searching through a set of objects in NetBox. For instance, sites can be filtered by their parent region or group, status, facility ID, and so on. The same filter set is used consistently for a model whether the request is made via the UI, REST API, or GraphQL API. NetBox employs the [django-filters2](https://django-tables2.readthedocs.io/en/latest/) library to define filter sets. + +## FilterSet Classes + +To support additional functionality standard to NetBox models, such as tag assignment and custom field support, the `NetBoxModelFilterSet` class is available for use by plugins. This should be used as the base filter set class for plugin models which inherit from `NetBoxModel`. Within this class, individual filters can be declared as directed by the `django-filters` documentation. An example is provided below. + +```python +# filtersets.py +import django_filters +from netbox.filtersets import NetBoxModelFilterSet +from .models import MyModel + +class MyFilterSet(NetBoxModelFilterSet): + status = django_filters.MultipleChoiceFilter( + choices=( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ), + null_value=None + ) + + class Meta: + model = MyModel + fields = ('some', 'other', 'fields') +``` + +### Declaring Filter Sets + +To utilize a filter set in a subclass of one of NetBox's generic views (such as `ObjectListView` or `BulkEditView`), define the `filterset` attribute on the view class: + +```python +# views.py +from netbox.views.generic import ObjectListView +from .filtersets import MyModelFitlerSet +from .models import MyModel + +class MyModelListView(ObjectListView): + queryset = MyModel.objects.all() + filterset = MyModelFitlerSet +``` + +To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view: + +```python +# api/views.py +from myplugin import models, filtersets +from . import serializers + +class MyModelViewSet(...): + queryset = models.MyModel.objects.all() + serializer_class = serializers.MyModelSerializer + filterset_class = filtersets.MyModelFilterSet +``` + +## Filter Classes + +### TagFilter + +The `TagFilter` class is available for all models which support tag assignment (those which inherit from `NetBoxModel` or `TagsMixin`). This filter subclasses django-filter's `ModelMultipleChoiceFilter` to work with NetBox's `TaggedItem` class. + +```python +from django_filters import FilterSet +from extras.filters import TagFilter + +class MyModelFilterSet(FilterSet): + tag = TagFilter() +``` diff --git a/docs/plugins/development/forms.md b/docs/plugins/development/forms.md new file mode 100644 index 000000000..e1eefa7a5 --- /dev/null +++ b/docs/plugins/development/forms.md @@ -0,0 +1,216 @@ +# Forms + +## Form Classes + +NetBox provides several base form classes for use by plugins. + +| Form Class | Purpose | +|---------------------------|--------------------------------------| +| `NetBoxModelForm` | Create/edit individual objects | +| `NetBoxModelCSVForm` | Bulk import objects from CSV data | +| `NetBoxModelBulkEditForm` | Edit multiple objects simultaneously | +| `NetBoxModelFilterSetForm` | Filter objects within a list view | + +### `NetBoxModelForm` + +This is the base form for creating and editing NetBox models. It extends Django's ModelForm to add support for tags and custom fields. + +| Attribute | Description | +|-------------|-------------------------------------------------------------| +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelForm +from utilities.forms.fields import CommentField, DynamicModelChoiceField +from .models import MyModel + +class MyModelForm(NetBoxModelForm): + site = DynamicModelChoiceField( + queryset=Site.objects.all() + ) + comments = CommentField() + fieldsets = ( + ('Model Stuff', ('name', 'status', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), + ) + + class Meta: + model = MyModel + fields = ('name', 'status', 'site', 'comments', 'tags') +``` + +!!! tip "Comment fields" + If your form has a `comments` field, there's no need to list it; this will always appear last on the page. + +### `NetBoxModelCSVForm` + +This form facilitates the bulk import of new objects from CSV data. As with model forms, you'll need to declare a `Meta` subclass specifying the associated `model` and `fields`. NetBox also provides several form fields suitable for import various types of CSV data, listed below. + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelCSVForm +from utilities.forms import CSVModelChoiceField +from .models import MyModel + +class MyModelCSVForm(NetBoxModelCSVForm): + site = CSVModelChoiceField( + queryset=Site.objects.all(), + to_field_name='name', + help_text='Assigned site' + ) + + class Meta: + model = MyModel + fields = ('name', 'status', 'site', 'comments') +``` + +### `NetBoxModelBulkEditForm` + +This form facilitates editing multiple objects in bulk. Unlike a model form, this form does not have a child `Meta` class, and must explicitly define each field. All fields in a bulk edit form are generally declared with `required=False`. + +| Attribute | Description | +|-------------------|---------------------------------------------------------------------------------------------| +| `model` | The model of object being edited | +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | +| `nullable_fields` | A tuple of fields which can be nullified (set to empty) using the bulk edit form (optional) | + +**Example** + +```python +from django import forms +from dcim.models import Site +from netbox.forms import NetBoxModelCSVForm +from utilities.forms import CommentField, DynamicModelChoiceField +from .models import MyModel, MyModelStatusChoices + +class MyModelEditForm(NetBoxModelCSVForm): + name = forms.CharField( + required=False + ) + status = forms.ChoiceField( + choices=MyModelStatusChoices, + required=False + ) + site = DynamicModelChoiceField( + queryset=Site.objects.all(), + required=False + ) + comments = CommentField() + + model = MyModel + fieldsets = ( + ('Model Stuff', ('name', 'status', 'site')), + ) + nullable_fields = ('site', 'comments') +``` + +### `NetBoxModelFilterSetForm` + +This form class is used to render a form expressly for filtering a list of objects. Its fields should correspond to filters defined on the model's filter set. + +| Attribute | Description | +|-------------------|-------------------------------------------------------------| +| `model` | The model of object being edited | +| `fieldsets` | A tuple of two-tuples defining the form's layout (optional) | + +**Example** + +```python +from dcim.models import Site +from netbox.forms import NetBoxModelFilterSetForm +from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField +from .models import MyModel, MyModelStatusChoices + +class MyModelFilterForm(NetBoxModelFilterSetForm): + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), + required=False + ) + status = MultipleChoiceField( + choices=MyModelStatusChoices, + required=False + ) + + model = MyModel +``` + +## General Purpose Fields + +In addition to the [form fields provided by Django](https://docs.djangoproject.com/en/stable/ref/forms/fields/), NetBox provides several field classes for use within forms to handle specific types of data. These can be imported from `utilities.forms.fields` and are documented below. + +::: utilities.forms.ColorField + selection: + members: false + +::: utilities.forms.CommentField + selection: + members: false + +::: utilities.forms.JSONField + selection: + members: false + +::: utilities.forms.MACAddressField + selection: + members: false + +::: utilities.forms.SlugField + selection: + members: false + +## Choice Fields + +::: utilities.forms.ChoiceField + selection: + members: false + +::: utilities.forms.MultipleChoiceField + selection: + members: false + +## Dynamic Object Fields + +::: utilities.forms.DynamicModelChoiceField + selection: + members: false + +::: utilities.forms.DynamicModelMultipleChoiceField + selection: + members: false + +## Content Type Fields + +::: utilities.forms.ContentTypeChoiceField + selection: + members: false + +::: utilities.forms.ContentTypeMultipleChoiceField + selection: + members: false + +## CSV Import Fields + +::: utilities.forms.CSVChoiceField + selection: + members: false + +::: utilities.forms.CSVMultipleChoiceField + selection: + members: false + +::: utilities.forms.CSVModelChoiceField + selection: + members: false + +::: utilities.forms.CSVContentTypeField + selection: + members: false + +::: utilities.forms.CSVMultipleContentTypeField + selection: + members: false diff --git a/docs/plugins/development/graphql-api.md b/docs/plugins/development/graphql-api.md new file mode 100644 index 000000000..0dadf021f --- /dev/null +++ b/docs/plugins/development/graphql-api.md @@ -0,0 +1,52 @@ +# GraphQL API + +## Defining the Schema Class + +A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`. + +### Example + +```python +# graphql.py +import graphene +from netbox.graphql.types import NetBoxObjectType +from netbox.graphql.fields import ObjectField, ObjectListField +from . import filtersets, models + +class MyModelType(NetBoxObjectType): + + class Meta: + model = models.MyModel + fields = '__all__' + filterset_class = filtersets.MyModelFilterSet + +class MyQuery(graphene.ObjectType): + mymodel = ObjectField(MyModelType) + mymodel_list = ObjectListField(MyModelType) + +schema = MyQuery +``` + +## GraphQL Objects + +NetBox provides two object type classes for use by plugins. + +::: netbox.graphql.types.BaseObjectType + selection: + members: false + +::: netbox.graphql.types.NetBoxObjectType + selection: + members: false + +## GraphQL Fields + +NetBox provides two field classes for use by plugins. + +::: netbox.graphql.fields.ObjectField + selection: + members: false + +::: netbox.graphql.fields.ObjectListField + selection: + members: false diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..98db9e0bb --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,171 @@ +# Plugins Development + +!!! tip "Plugins Development Tutorial" + Just getting started with plugins? Check out our [**NetBox Plugin Tutorial**](https://github.com/netbox-community/netbox-plugin-tutorial) on GitHub! This in-depth guide will walk you through the process of creating an entire plugin from scratch. It even includes a companion [demo plugin repo](https://github.com/netbox-community/netbox-plugin-demo) to ensure you can jump in at any step along the way. This will get you up and running with plugins in no time! + +NetBox can be extended to support additional data models and functionality through the use of plugins. A plugin is essentially a self-contained [Django app](https://docs.djangoproject.com/en/stable/) which gets installed alongside NetBox to provide custom functionality. Multiple plugins can be installed in a single NetBox instance, and each plugin can be enabled and configured independently. + +!!! info "Django Development" + Django is the Python framework on which NetBox is built. As Django itself is very well-documented, this documentation covers only the aspects of plugin development which are unique to NetBox. + +Plugins can do a lot, including: + +* Create Django models to store data in the database +* Provide their own "pages" (views) in the web user interface +* Inject template content and navigation links +* Extend NetBox's REST and GraphQL APIs +* Add custom request/response middleware + +However, keep in mind that each piece of functionality is entirely optional. For example, if your plugin merely adds a piece of middleware or an API endpoint for existing data, there's no need to define any new models. + +!!! warning + While very powerful, the NetBox plugins API is necessarily limited in its scope. The plugins API is discussed here in its entirety: Any part of the NetBox code base not documented here is _not_ part of the supported plugins API, and should not be employed by a plugin. Internal elements of NetBox are subject to change at any time and without warning. Plugin authors are **strongly** encouraged to develop plugins using only the officially supported components discussed here and those provided by the underlying Django framework to avoid breaking changes in future releases. + +## Plugin Structure + +Although the specific structure of a plugin is largely left to the discretion of its authors, a typical NetBox plugin might look something like this: + +```no-highlight +project-name/ + - plugin_name/ + - api/ + - __init__.py + - serializers.py + - urls.py + - views.py + - migrations/ + - __init__.py + - 0001_initial.py + - ... + - templates/ + - plugin_name/ + - *.html + - __init__.py + - filtersets.py + - graphql.py + - models.py + - middleware.py + - navigation.py + - signals.py + - tables.py + - template_content.py + - urls.py + - views.py + - README.md + - setup.py +``` + +The top level is the project root, which can have any name that you like. Immediately within the root should exist several items: + +* `setup.py` - This is a standard installation script used to install the plugin package within the Python environment. +* `README.md` - A brief introduction to your plugin, how to install and configure it, where to find help, and any other pertinent information. It is recommended to write `README` files using a markup language such as Markdown to enable human-friendly display. +* The plugin source directory. This must be a valid Python package name, typically comprising only lowercase letters, numbers, and underscores. + +The plugin source directory contains all the actual Python code and other resources used by your plugin. Its structure is left to the author's discretion, however it is recommended to follow best practices as outlined in the [Django documentation](https://docs.djangoproject.com/en/stable/intro/reusable-apps/). At a minimum, this directory **must** contain an `__init__.py` file containing an instance of NetBox's `PluginConfig` class, discussed below. + +## PluginConfig + +The `PluginConfig` class is a NetBox-specific wrapper around Django's built-in [`AppConfig`](https://docs.djangoproject.com/en/stable/ref/applications/) class. It is used to declare NetBox plugin functionality within a Python package. Each plugin should provide its own subclass, defining its name, metadata, and default and required configuration parameters. An example is below: + +```python +from extras.plugins import PluginConfig + +class FooBarConfig(PluginConfig): + name = 'foo_bar' + verbose_name = 'Foo Bar' + description = 'An example NetBox plugin' + version = '0.1' + author = 'Jeremy Stretch' + author_email = 'author@example.com' + base_url = 'foo-bar' + required_settings = [] + default_settings = { + 'baz': True + } + +config = FooBarConfig +``` + +NetBox looks for the `config` variable within a plugin's `__init__.py` to load its configuration. Typically, this will be set to the PluginConfig subclass, but you may wish to dynamically generate a PluginConfig class based on environment variables or other factors. + +### PluginConfig Attributes + +| Name | Description | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------| +| `name` | Raw plugin name; same as the plugin's source directory | +| `verbose_name` | Human-friendly name for the plugin | +| `version` | Current release ([semantic versioning](https://semver.org/) is encouraged) | +| `description` | Brief description of the plugin's purpose | +| `author` | Name of plugin's author | +| `author_email` | Author's public email address | +| `base_url` | Base path to use for plugin URLs (optional). If not specified, the project's `name` will be used. | +| `required_settings` | A list of any configuration parameters that **must** be defined by the user | +| `default_settings` | A dictionary of configuration parameters and their default values | +| `min_version` | Minimum version of NetBox with which the plugin is compatible | +| `max_version` | Maximum version of NetBox with which the plugin is compatible | +| `middleware` | A list of middleware classes to append after NetBox's build-in middleware | +| `queues` | A list of custom background task queues to create | +| `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | +| `menu_items` | The dotted path to the list of menu items provided by the plugin (default: `navigation.menu_items`) | +| `graphql_schema` | The dotted path to the plugin's GraphQL schema class, if any (default: `graphql.schema`) | +| `user_preferences` | The dotted path to the dictionary mapping of user preferences defined by the plugin (default: `preferences.preferences`) | + +All required settings must be configured by the user. If a configuration parameter is listed in both `required_settings` and `default_settings`, the default setting will be ignored. + +## Create setup.py + +`setup.py` is the [setup script](https://docs.python.org/3.8/distutils/setupscript.html) used to package and install our plugin once it's finished. The primary function of this script is to call the setuptools library's `setup()` function to create a Python distribution package. We can pass a number of keyword arguments to control the package creation as well as to provide metadata about the plugin. An example `setup.py` is below: + +```python +from setuptools import find_packages, setup + +setup( + name='my-example-plugin', + version='0.1', + description='An example NetBox plugin', + url='https://github.com/jeremystretch/my-example-plugin', + author='Jeremy Stretch', + license='Apache 2.0', + install_requires=[], + packages=find_packages(), + include_package_data=True, + zip_safe=False, +) +``` + +Many of these are self-explanatory, but for more information, see the [setuptools documentation](https://setuptools.readthedocs.io/en/latest/setuptools.html). + +!!! info + `zip_safe=False` is **required** as the current plugin iteration is not zip safe due to upstream python issue [issue19699](https://bugs.python.org/issue19699) + +## Create a Virtual Environment + +It is strongly recommended to create a Python [virtual environment](https://docs.python.org/3/tutorial/venv.html) for the development of your plugin, as opposed to using system-wide packages. This will afford you complete control over the installed versions of all dependencies and avoid conflict with system packages. This environment can live wherever you'd like, however it should be excluded from revision control. (A popular convention is to keep all virtual environments in the user's home directory, e.g. `~/.virtualenvs/`.) + +```shell +python3 -m venv ~/.virtualenvs/my_plugin +``` + +You can make NetBox available within this environment by creating a path file pointing to its location. This will add NetBox to the Python path upon activation. (Be sure to adjust the command below to specify your actual virtual environment path, Python version, and NetBox installation.) + +```shell +echo /opt/netbox/netbox > $VENV/lib/python3.8/site-packages/netbox.pth +``` + +## Development Installation + +To ease development, it is recommended to go ahead and install the plugin at this point using setuptools' `develop` mode. This will create symbolic links within your Python environment to the plugin development directory. Call `setup.py` from the plugin's root directory with the `develop` argument (instead of `install`): + +```no-highlight +$ python setup.py develop +``` + +## Configure NetBox + +To enable the plugin in NetBox, add it to the `PLUGINS` parameter in `configuration.py`: + +```python +PLUGINS = [ + 'my_plugin', +] +``` diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md new file mode 100644 index 000000000..0e1fec6e5 --- /dev/null +++ b/docs/plugins/development/models.md @@ -0,0 +1,185 @@ +# Database Models + +## Creating Models + +If your plugin introduces a new type of object in NetBox, you'll probably want to create a [Django model](https://docs.djangoproject.com/en/stable/topics/db/models/) for it. A model is essentially a Python representation of a database table, with attributes that represent individual columns. Instances of a model (objects) can be created, manipulated, and deleted using [queries](https://docs.djangoproject.com/en/stable/topics/db/queries/). Models must be defined within a file named `models.py`. + +Below is an example `models.py` file containing a model with two character (text) fields: + +```python +from django.db import models + +class MyModel(models.Model): + foo = models.CharField(max_length=50) + bar = models.CharField(max_length=50) + + def __str__(self): + return f'{self.foo} {self.bar}' +``` + +Every model includes by default a numeric primary key. This value is generated automatically by the database, and can be referenced as `pk` or `id`. + +## Enabling NetBox Features + +Plugin models can leverage certain NetBox features by inheriting from NetBox's `NetBoxModel` class. This class extends the plugin model to enable features unique to NetBox, including: + +* Change logging +* Custom fields +* Custom links +* Custom validation +* Export templates +* Journaling +* Tags +* Webhooks + +This class performs two crucial functions: + +1. Apply any fields, methods, and/or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these features + +Simply subclass BaseModel when defining a model in your plugin: + +```python +# models.py +from django.db import models +from netbox.models import NetBoxModel + +class MyModel(NetBoxModel): + foo = models.CharField() + ... +``` + +### Enabling Features Individually + +If you prefer instead to enable only a subset of these features for a plugin model, NetBox provides a discrete "mix-in" class for each feature. You can subclass each of these individually when defining your model. (Your model will also need to inherit from Django's built-in `Model` class.) + +For example, if we wanted to support only tags and export templates, we would inherit from NetBox's `ExportTemplatesMixin` and `TagsMixin` classes, and from Django's `Model` class. (Inheriting _all_ the available mixins is essentially the same as subclassing `NetBoxModel`.) + +```python +# models.py +from django.db import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +## Database Migrations + +Once you have completed defining the model(s) for your plugin, you'll need to create the database schema migrations. A migration file is essentially a set of instructions for manipulating the PostgreSQL database to support your new model, or to alter existing models. Creating migrations can usually be done automatically using Django's `makemigrations` management command. (Ensure that your plugin has been installed and enabled first, otherwise it won't be found.) + +!!! note Enable Developer Mode + NetBox enforces a safeguard around the `makemigrations` command to protect regular users from inadvertently creating erroneous schema migrations. To enable this command for plugin development, set `DEVELOPER=True` in `configuration.py`. + +```no-highlight +$ ./manage.py makemigrations my_plugin +Migrations for 'my_plugin': + /home/jstretch/animal_sounds/my_plugin/migrations/0001_initial.py + - Create model MyModel +``` + +Next, we can apply the migration to the database with the `migrate` command: + +```no-highlight +$ ./manage.py migrate my_plugin +Operations to perform: + Apply all migrations: my_plugin +Running migrations: + Applying my_plugin.0001_initial... OK +``` + +For more information about database migrations, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/migrations/). + +## Feature Mixins Reference + +!!! warning + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `features` module, they are not yet supported for use by plugins. + +::: netbox.models.features.ChangeLoggingMixin + +::: netbox.models.features.CustomLinksMixin + +::: netbox.models.features.CustomFieldsMixin + +::: netbox.models.features.CustomValidationMixin + +::: netbox.models.features.ExportTemplatesMixin + +::: netbox.models.features.JournalingMixin + +::: netbox.models.features.TagsMixin + +::: netbox.models.features.WebhooksMixin + +## Choice Sets + +For model fields which support the selection of one or more values from a predefined list of choices, NetBox provides the `ChoiceSet` utility class. This can be used in place of a regular choices tuple to provide enhanced functionality, namely dynamic configuration and colorization. (See [Django's documentation](https://docs.djangoproject.com/en/stable/ref/models/fields/#choices) on the `choices` parameter for supported model fields.) + +To define choices for a model field, subclass `ChoiceSet` and define a tuple named `CHOICES`, of which each member is a two- or three-element tuple. These elements are: + +* The database value +* The corresponding human-friendly label +* The assigned color (optional) + +A complete example is provided below. + +!!! note + Authors may find it useful to declare each of the database values as constants on the class, and reference them within `CHOICES` members. This convention allows the values to be referenced from outside the class, however it is not strictly required. + +### Dynamic Configuration + +Some model field choices in NetBox can be configured by an administrator. For example, the default values for the Site model's `status` field can be replaced or supplemented with custom choices. To enable dynamic configuration for a ChoiceSet subclass, define its `key` as a string specifying the model and field name to which it applies. For example: + +```python +from utilities.choices import ChoiceSet + +class StatusChoices(ChoiceSet): + key = 'MyModel.status' +``` + +To extend or replace the default values for this choice set, a NetBox administrator can then reference it under the [`FIELD_CHOICES`](../../configuration/optional-settings.md#field_choices) configuration parameter. For example, the `status` field on `MyModel` in `my_plugin` would be referenced as: + +```python +FIELD_CHOICES = { + 'my_plugin.MyModel.status': ( + # Custom choices + ) +} +``` + +### Example + +```python +# choices.py +from utilities.choices import ChoiceSet + +class StatusChoices(ChoiceSet): + key = 'MyModel.status' + + STATUS_FOO = 'foo' + STATUS_BAR = 'bar' + STATUS_BAZ = 'baz' + + CHOICES = [ + (STATUS_FOO, 'Foo', 'red'), + (STATUS_BAR, 'Bar', 'green'), + (STATUS_BAZ, 'Baz', 'blue'), + ] +``` + +!!! warning + For dynamic configuration to work properly, `CHOICES` must be a mutable list, rather than a tuple. + +```python +# models.py +from django.db import models +from .choices import StatusChoices + +class MyModel(models.Model): + status = models.CharField( + max_length=50, + choices=StatusChoices, + default=StatusChoices.STATUS_FOO + ) +``` diff --git a/docs/plugins/development/navigation.md b/docs/plugins/development/navigation.md new file mode 100644 index 000000000..52ae953a7 --- /dev/null +++ b/docs/plugins/development/navigation.md @@ -0,0 +1,50 @@ +# Navigation + +## Menu Items + +To make its views easily accessible to users, a plugin can inject items in NetBox's navigation menu under the "Plugins" header. Menu items are added by defining a list of PluginMenuItem instances. By default, this should be a variable named `menu_items` in the file `navigation.py`. An example is shown below. + +!!! tip + The path to declared menu items can be modified by setting `menu_items` in the PluginConfig instance. + +```python +from extras.plugins import PluginMenuButton, PluginMenuItem +from utilities.choices import ButtonColorChoices + +menu_items = ( + PluginMenuItem( + link='plugins:netbox_animal_sounds:random_animal', + link_text='Random sound', + buttons=( + PluginMenuButton('home', 'Button A', 'fa fa-info', ButtonColorChoices.BLUE), + PluginMenuButton('home', 'Button B', 'fa fa-warning', ButtonColorChoices.GREEN), + ) + ), +) +``` + +A `PluginMenuItem` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this menu item links | +| `link_text` | Yes | The text presented to the user | +| `permissions` | - | A list of permissions required to display this link | +| `buttons` | - | An iterable of PluginMenuButton instances to include | + +## Menu Buttons + +A `PluginMenuButton` has the following attributes: + +| Attribute | Required | Description | +|---------------|----------|--------------------------------------------------------------------| +| `link` | Yes | Name of the URL path to which this button links | +| `title` | Yes | The tooltip text (displayed when the mouse hovers over the button) | +| `icon_class` | Yes | Button icon CSS class* | +| `color` | - | One of the choices provided by `ButtonColorChoices` | +| `permissions` | - | A list of permissions required to display this button | + +*NetBox supports [Material Design Icons](https://materialdesignicons.com/). + +!!! note + Any buttons associated within a menu item will be shown only if the user has permission to view the link, regardless of what permissions are set on the buttons. diff --git a/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md new file mode 100644 index 000000000..ea195dafc --- /dev/null +++ b/docs/plugins/development/rest-api.md @@ -0,0 +1,117 @@ +# REST API + +Plugins can declare custom endpoints on NetBox's REST API to retrieve or manipulate models or other data. These behave very similarly to views, except that instead of rendering arbitrary content using a template, data is returned in JSON format using a serializer. + +Generally speaking, there aren't many NetBox-specific components to implementing REST API functionality in a plugin. NetBox employs the [Django REST Framework](https://www.django-rest-framework.org/) (DRF) for its REST API, and plugin authors will find that they can largely replicate the same patterns found in NetBox's implementation. Some brief examples are included here for reference. + +## Code Layout + +The recommended approach is to separate API serializers, views, and URLs into separate modules under the `api/` directory to keep things tidy, particularly for larger projects. The file at `api/__init__.py` can import the relevant components from each submodule to allow import all API components directly from elsewhere. However, this is merely a convention and not strictly required. + +```no-highlight +project-name/ + - plugin_name/ + - api/ + - __init__.py + - serializers.py + - urls.py + - views.py + ... +``` + +## Serializers + +### Model Serializers + +Serializers are responsible for converting Python objects to JSON data suitable for conveying to consumers, and vice versa. NetBox provides the `NetBoxModelSerializer` class for use by plugins to handle the assignment of tags and custom field data. (These features can also be included ad hoc via the `CustomFieldModelSerializer` and `TaggableModelSerializer` classes.) + +#### Example + +To create a serializer for a plugin model, subclass `NetBoxModelSerializer` in `api/serializers.py`. Specify the model class and the fields to include within the serializer's `Meta` class. It is generally advisable to include a `url` attribute on each serializer. This will render the direct link to access the object being rendered. + +```python +# api/serializers.py +from rest_framework import serializers +from netbox.api.serializers import NetBoxModelSerializer +from my_plugin.models import MyModel + +class MyModelSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:myplugin-api:mymodel-detail' + ) + + class Meta: + model = MyModel + fields = ('id', 'foo', 'bar', 'baz') +``` + +### Nested Serializers + +There are two cases where it is generally desirable to show only a minimal representation of an object: + +1. When displaying an object related to the one being viewed (for example, the region to which a site is assigned) +2. Listing many objects using "brief" mode + +To accommodate these, it is recommended to create nested serializers accompanying the "full" serializer for each model. NetBox provides the `WritableNestedSerializer` class for just this purpose. This class accepts a primary key value on write, but displays an object representation for read requests. It also includes a read-only `display` attribute which conveys the string representation of the object. + +#### Example + +```python +# api/serializers.py +from rest_framework import serializers +from netbox.api.serializers import WritableNestedSerializer +from my_plugin.models import MyModel + +class NestedMyModelSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField( + view_name='plugins-api:myplugin-api:mymodel-detail' + ) + + class Meta: + model = MyModel + fields = ('id', 'display', 'foo') +``` + +## Viewsets + +Just as in the user interface, a REST API view handles the business logic of displaying and interacting with NetBox objects. NetBox provides the `NetBoxModelViewSet` class, which extends DRF's built-in `ModelViewSet` to handle bulk operations and object validation. + +Unlike the user interface, typically only a single view set is required per model: This view set handles all request types (`GET`, `POST`, `DELETE`, etc.). + +### Example + +To create a viewset for a plugin model, subclass `NetBoxModelViewSet` in `api/views.py`, and define the `queryset` and `serializer_class` attributes. + +```python +# api/views.py +from netbox.api.viewsets import ModelViewSet +from my_plugin.models import MyModel +from .serializers import MyModelSerializer + +class MyModelViewSet(ModelViewSet): + queryset = MyModel.objects.all() + serializer_class = MyModelSerializer +``` + +## Routers + +Routers map URLs to REST API views (endpoints). NetBox does not provide any custom components for this; the [`DefaultRouter`](https://www.django-rest-framework.org/api-guide/routers/#defaultrouter) class provided by DRF should suffice for most use cases. + +Routers should be exposed in `api/urls.py`. This file **must** define a variable named `urlpatterns`. + +### Example + +```python +# api/urls.py +from netbox.api.routers import NetBoxRouter +from .views import MyModelViewSet + +router = NetBoxRouter() +router.register('my-model', MyModelViewSet) +urlpatterns = router.urls +``` + +This will make the plugin's view accessible at `/api/plugins/my-plugin/my-model/`. + +!!! warning + The examples provided here are intended to serve as a minimal reference implementation only. This documentation does not address authentication, performance, or myriad other concerns that plugin authors may need to address. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md new file mode 100644 index 000000000..77e258def --- /dev/null +++ b/docs/plugins/development/tables.md @@ -0,0 +1,88 @@ +# Tables + +NetBox employs the [`django-tables2`](https://django-tables2.readthedocs.io/) library for rendering dynamic object tables. These tables display lists of objects, and can be sorted and filtered by various parameters. + +## NetBoxTable + +To provide additional functionality beyond what is supported by the stock `Table` class in `django-tables2`, NetBox provides the `NetBoxTable` class. This custom table class includes support for: + +* User-configurable column display and ordering +* Custom field & custom link columns +* Automatic prefetching of related objects + +It also includes several default columns: + +* `pk` - A checkbox for selecting the object associated with each table row (where applicable) +* `id` - The object's numeric database ID, as a hyperlink to the object's view (hidden by default) +* `actions` - A dropdown menu presenting object-specific actions available to the user + +### Example + +```python +# tables.py +import django_tables2 as tables +from netbox.tables import NetBoxTable +from .models import MyModel + +class MyModelTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + ... + + class Meta(NetBoxTable.Meta): + model = MyModel + fields = ('pk', 'id', 'name', ...) + default_columns = ('pk', 'name', ...) +``` + +### Table Configuration + +The NetBoxTable class features dynamic configuration to allow users to change their column display and ordering preferences. To configure a table for a specific request, simply call its `configure()` method and pass the current HTTPRequest object. For example: + +```python +table = MyModelTable(data=MyModel.objects.all()) +table.configure(request) +``` + +This will automatically apply any user-specific preferences for the table. (If using a generic view provided by NetBox, table configuration is handled automatically.) + +## Columns + +The table column classes listed below are supported for use in plugins. These classes can be imported from `netbox.tables.columns`. + +::: netbox.tables.BooleanColumn + selection: + members: false + +::: netbox.tables.ChoiceFieldColumn + selection: + members: false + +::: netbox.tables.ColorColumn + selection: + members: false + +::: netbox.tables.ColoredLabelColumn + selection: + members: false + +::: netbox.tables.ContentTypeColumn + selection: + members: false + +::: netbox.tables.ContentTypesColumn + selection: + members: false + +::: netbox.tables.MarkdownColumn + selection: + members: false + +::: netbox.tables.TagColumn + selection: + members: false + +::: netbox.tables.TemplateColumn + selection: + members: false diff --git a/docs/plugins/development/templates.md b/docs/plugins/development/templates.md new file mode 100644 index 000000000..64616c442 --- /dev/null +++ b/docs/plugins/development/templates.md @@ -0,0 +1,247 @@ +# Templates + +Templates are used to render HTML content generated from a set of context data. NetBox provides a set of built-in templates suitable for use in plugin views. Plugin authors can extend these templates to minimize the work needed to create custom templates while ensuring that the content they produce matches NetBox's layout and style. These templates are all written in the [Django Template Language (DTL)](https://docs.djangoproject.com/en/stable/ref/templates/language/). + +## Template File Structure + +Plugin templates should live in the `templates//` path within the plugin root. For example if your plugin's name is `my_plugin` and you create a template named `foo.html`, it should be saved to `templates/my_plugin/foo.html`. (You can of course use subdirectories below this point as well.) This ensures that Django's template engine can locate the template for rendering. + +## Standard Blocks + +The following template blocks are available on all templates. + +| Name | Required | Description | +|----------------|----------|---------------------------------------------------------------------| +| `title` | Yes | Page title | +| `content` | Yes | Page content | +| `head` | - | Content to include in the HTML `` element | +| `footer` | - | Page footer content | +| `footer_links` | - | Links section of the page footer | +| `javascript` | - | Javascript content included at the end of the HTML `` element | + +!!! note + For more information on how template blocks work, consult the [Django documentation](https://docs.djangoproject.com/en/stable/ref/templates/builtins/#block). + +## Base Templates + +### layout.html + +Path: `base/layout.html` + +NetBox provides a base template to ensure a consistent user experience, which plugins can extend with their own content. This is a general-purpose template that can be used when none of the function-specific templates below are suitable. + +#### Blocks + +| Name | Required | Description | +|-----------|----------|----------------------------| +| `header` | - | Page header | +| `tabs` | - | Horizontal navigation tabs | +| `modals` | - | Bootstrap 5 modal elements | + +#### Example + +An example of a plugin template which extends `layout.html` is included below. + +```jinja2 +{% extends 'base/layout.html' %} + +{% block header %} +

My Custom Header

+{% endblock header %} + +{% block content %} +

{{ some_plugin_context_var }}

+{% endblock content %} +``` + +The first line of the template instructs Django to extend the NetBox base template, and the `block` sections inject our custom content within its `header` and `content` blocks. + +!!! note + Django renders templates with its own custom [template language](https://docs.djangoproject.com/en/stable/topics/templates/#the-django-template-language). This is very similar to Jinja2, however there are some important distinctions of which authors should be aware. Be sure to familiarize yourself with Django's template language before attempting to create new templates. + +## Generic View Templates + +### object.html + +Path: `generic/object.html` + +This template is used by the `ObjectView` generic view to display a single object. + +#### Blocks + +| Name | Required | Description | +|---------------------|----------|----------------------------------------------| +| `breadcrumbs` | - | Breadcrumb list items (HTML `
  • ` elements) | +| `object_identifier` | - | A unique identifier (string) for the object | +| `extra_controls` | - | Additional action buttons to display | +| `extra_tabs` | - | Additional tabs to include | + +#### Context + +| Name | Required | Description | +|----------|----------|----------------------------------| +| `object` | Yes | The object instance being viewed | + +### object_edit.html + +Path: `generic/object_edit.html` + +This template is used by the `ObjectEditView` generic view to create or modify a single object. + +#### Blocks + +| Name | Required | Description | +|------------------|----------|-------------------------------------------------------| +| `form` | - | Custom form content (within the HTML `
    ` element | +| `buttons` | - | Form submission buttons | + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `object` | Yes | The object instance being modified (or none, if creating) | +| `form` | Yes | The form class for creating/modifying the object | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### object_delete.html + +Path: `generic/object_delete.html` + +This template is used by the `ObjectDeleteView` generic view to delete a single object. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `object` | Yes | The object instance being deleted | +| `form` | Yes | The form class for confirming the object's deletion | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### object_list.html + +Path: `generic/object_list.html` + +This template is used by the `ObjectListView` generic view to display a filterable list of multiple objects. + +#### Blocks + +| Name | Required | Description | +|------------------|----------|--------------------------------------------------------------------| +| `extra_controls` | - | Additional action buttons | +| `bulk_buttons` | - | Additional bulk action buttons to display beneath the objects list | + +#### Context + +| Name | Required | Description | +|---------------|----------|---------------------------------------------------------------------------------------------| +| `model` | Yes | The object class | +| `table` | Yes | The table class used for rendering the list of objects | +| `permissions` | Yes | A mapping of add, change, and delete permissions for the current user | +| `actions` | Yes | A list of buttons to display (`add`, `import`, `export`, `bulk_edit`, and/or `bulk_delete`) | +| `filter_form` | - | The bound filterset form for filtering the objects list | +| `return_url` | - | The return URL to pass when submitting a bulk operation form | + +### bulk_import.html + +Path: `generic/bulk_import.html` + +This template is used by the `BulkImportView` generic view to import multiple objects at once from CSV data. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|--------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The CSV import form class | +| `return_url` | - | The return URL to pass when submitting a bulk operation form | +| `fields` | - | A dictionary of form fields, to display import options | + +### bulk_edit.html + +Path: `generic/bulk_edit.html` + +This template is used by the `BulkEditView` generic view to modify multiple objects simultaneously. + +#### Blocks + +None + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The bulk edit form class | +| `table` | Yes | The table class used for rendering the list of objects | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +### bulk_delete.html + +Path: `generic/bulk_delete.html` + +This template is used by the `BulkDeleteView` generic view to delete multiple objects simultaneously. + +#### Blocks + +| Name | Required | Description | +|-----------------|----------|---------------------------------------| +| `message_extra` | - | Supplementary warning message content | + +#### Context + +| Name | Required | Description | +|--------------|----------|-----------------------------------------------------------------| +| `model` | Yes | The object class | +| `form` | Yes | The bulk delete form class | +| `table` | Yes | The table class used for rendering the list of objects | +| `return_url` | Yes | The URL to which the user is redirect after submitting the form | + +## Tags + +The following custom template tags are available in NetBox. + +!!! info + These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them. + +::: utilities.templatetags.builtins.tags.badge + +::: utilities.templatetags.builtins.tags.checkmark + +::: utilities.templatetags.builtins.tags.tag + +## Filters + +The following custom template filters are available in NetBox. + +!!! info + These are loaded automatically by the template backend: You do _not_ need to include a `{% load %}` tag in your template to activate them. + +::: utilities.templatetags.builtins.filters.bettertitle + +::: utilities.templatetags.builtins.filters.content_type + +::: utilities.templatetags.builtins.filters.content_type_id + +::: utilities.templatetags.builtins.filters.linkify + +::: utilities.templatetags.builtins.filters.meta + +::: utilities.templatetags.builtins.filters.placeholder + +::: utilities.templatetags.builtins.filters.render_json + +::: utilities.templatetags.builtins.filters.render_markdown + +::: utilities.templatetags.builtins.filters.render_yaml + +::: utilities.templatetags.builtins.filters.split + +::: utilities.templatetags.builtins.filters.tzoffset diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md new file mode 100644 index 000000000..92626f8d3 --- /dev/null +++ b/docs/plugins/development/views.md @@ -0,0 +1,177 @@ +# Views + +## Writing Views + +If your plugin will provide its own page or pages within the NetBox web UI, you'll need to define views. A view is a piece of business logic which performs an action and/or renders a page when a request is made to a particular URL. HTML content is rendered using a [template](./templates.md). Views are typically defined in `views.py`, and URL patterns in `urls.py`. + +As an example, let's write a view which displays a random animal and the sound it makes. We'll use Django's generic `View` class to minimize the amount of boilerplate code needed. + +```python +from django.shortcuts import render +from django.views.generic import View +from .models import Animal + +class RandomAnimalView(View): + """ + Display a randomly-selected animal. + """ + def get(self, request): + animal = Animal.objects.order_by('?').first() + return render(request, 'netbox_animal_sounds/animal.html', { + 'animal': animal, + }) +``` + +This view retrieves a random Animal instance from the database and passes it as a context variable when rendering a template named `animal.html`. HTTP `GET` requests are handled by the view's `get()` method, and `POST` requests are handled by its `post()` method. + +Our example above is extremely simple, but views can do just about anything. They are generally where the core of your plugin's functionality will reside. Views also are not limited to returning HTML content: A view could return a CSV file or image, for instance. For more information on views, see the [Django documentation](https://docs.djangoproject.com/en/stable/topics/class-based-views/). + +### URL Registration + +To make the view accessible to users, we need to register a URL for it. We do this in `urls.py` by defining a `urlpatterns` variable containing a list of paths. + +```python +from django.urls import path +from . import views + +urlpatterns = [ + path('random/', views.RandomAnimalView.as_view(), name='random_animal'), +] +``` + +A URL pattern has three components: + +* `route` - The unique portion of the URL dedicated to this view +* `view` - The view itself +* `name` - A short name used to identify the URL path internally + +This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it. + +### View Classes + +NetBox provides several generic view classes (documented below) to facilitate common operations, such as creating, viewing, modifying, and deleting objects. Plugins can subclass these views for their own use. + +| View Class | Description | +|--------------------|--------------------------------| +| `ObjectView` | View a single object | +| `ObjectEditView` | Create or edit a single object | +| `ObjectDeleteView` | Delete a single object | +| `ObjectListView` | View a list of objects | +| `BulkImportView` | Import a set of new objects | +| `BulkEditView` | Edit multiple objects | +| `BulkDeleteView` | Delete multiple objects | + +!!! warning + Please note that only the classes which appear in this documentation are currently supported. Although other classes may be present within the `views.generic` module, they are not yet supported for use by plugins. + +#### Example Usage + +```python +# views.py +from netbox.views.generic import ObjectEditView +from .models import Thing + +class ThingEditView(ObjectEditView): + queryset = Thing.objects.all() + template_name = 'myplugin/thing.html' + ... +``` +## Object Views + +Below are the class definitions for NetBox's object views. These views handle CRUD actions for individual objects. The view, add/edit, and delete views each inherit from `BaseObjectView`, which is not intended to be used directly. + +::: netbox.views.generic.base.BaseObjectView + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + +## Multi-Object Views + +Below are the class definitions for NetBox's multi-object views. These views handle simultaneous actions for sets objects. The list, import, edit, and delete views each inherit from `BaseMultiObjectView`, which is not intended to be used directly. + +::: netbox.views.generic.base.BaseMultiObjectView + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - export_table + - export_template + +::: netbox.views.generic.BulkImportView + selection: + members: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + +## Feature Views + +These views are provided to enable or enhance certain NetBox model features, such as change logging or journaling. These typically do not need to be subclassed: They can be used directly e.g. in a URL path. + +::: netbox.views.generic.ObjectChangeLogView + selection: + members: + - get_form + +::: netbox.views.generic.ObjectJournalView + selection: + members: + - get_form + +## Extending Core Views + +Plugins can inject custom content into certain areas of the detail views of applicable models. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired methods to render custom content. Four methods are available: + +* `left_page()` - Inject content on the left side of the page +* `right_page()` - Inject content on the right side of the page +* `full_width_page()` - Inject content across the entire bottom of the page +* `buttons()` - Add buttons to the top of the page + +Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. + +When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include: + +* `object` - The object being viewed +* `request` - The current request +* `settings` - Global NetBox settings +* `config` - Plugin-specific configuration parameters + +For example, accessing `{{ request.user }}` within a template will return the current user. + +Declared subclasses should be gathered into a list or tuple for integration with NetBox. By default, NetBox looks for an iterable named `template_extensions` within a `template_content.py` file. (This can be overridden by setting `template_extensions` to a custom value on the plugin's PluginConfig.) An example is below. + +```python +from extras.plugins import PluginTemplateExtension +from .models import Animal + +class SiteAnimalCount(PluginTemplateExtension): + model = 'dcim.site' + + def right_page(self): + return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={ + 'animal_count': Animal.objects.count(), + }) + +template_extensions = [SiteAnimalCount] +``` diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 13c129398..af15ba7b6 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,18 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 3.2](./version-3.2.md) (Pending Release) + +* Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) +* Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) +* Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) +* Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) +* Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) +* Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) +* Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) +* Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) +* Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + #### [Version 3.1](./version-3.1.md) (December 2021) * Contact Objects ([#1344](https://github.com/netbox-community/netbox/issues/1344)) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md new file mode 100644 index 000000000..f0b230edb --- /dev/null +++ b/docs/release-notes/version-3.2.md @@ -0,0 +1,229 @@ +# NetBox v3.2 + +## v3.2.0 (FUTURE) + +!!! warning "Python 3.8 or Later Required" + NetBox v3.2 requires Python 3.8 or later. + +!!! warning "Deletion of Legacy Data" + This release includes a database migration that will remove the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields from the site model. (These fields have been superseded by the ASN and contact models introduced in NetBox v3.1.) To protect against the accidental destruction of data, the upgrade process **will fail** if any sites still have data in any of these fields. To bypass this safeguard, set the `NETBOX_DELETE_LEGACY_DATA` environment variable when running the upgrade script, which will permit the destruction of legacy data. + +!!! tip "Migration Scripts" + A set of [migration scripts](https://github.com/netbox-community/migration-scripts) is available to assist with the migration of legacy site data. + +### Breaking Changes + +* Automatic redirection of legacy slug-based URL paths has been removed. URL-based slugs were changed to use numeric IDs in v2.11.0. +* The `asn` field has been removed from the site model. Please replicate any site ASN assignments to the ASN model introduced in NetBox v3.1 prior to upgrading. +* The `asn` query filter for sites now matches against the AS number of assigned ASN objects. +* The `contact_name`, `contact_phone`, and `contact_email` fields have been removed from the site model. Please replicate any data remaining in these fields to the contact model introduced in NetBox v3.1 prior to upgrading. +* The `created` field of all change-logged models now conveys a full datetime object, rather than only a date. (Previous date-only values will receive a timestamp of 00:00.) While this change is largely unconcerning, strictly-typed API consumers may need to be updated. +* A `pre_run()` method has been added to the base Report class. Although unlikely to affect most installations, you may need to alter any reports which already use this name for a method. +* Webhook URLs now support Jinja2 templating. Although this is unlikely to introduce any issues, it's possible that an unusual URL might trigger a Jinja2 rendering error, in which case the URL would need to be properly escaped. + +### New Features + +#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) + +NetBox's plugins framework has been extended considerably in this release. Additions include: + +* Officially-supported generic view classes for common CRUD operations: + * `ObjectView` + * `ObjectEditView` + * `ObjectDeleteView` + * `ObjectListView` + * `BulkImportView` + * `BulkEditView` + * `BulkDeleteView` +* The `NetBoxModel` base class, which enables various NetBox features, including: + * Change logging + * Custom fields + * Custom links + * Custom validation + * Export templates + * Journaling + * Tags + * Webhooks +* Four base form classes for manipulating objects via the UI: + * `NetBoxModelForm` + * `NetBoxModelCSVForm` + * `NetBoxModelBulkEditForm` + * `NetBoxModelFilterSetForm` +* The `NetBoxModelFilterSet` base class for plugin filter sets +* The `NetBoxTable` base class for rendering object tables with `django-tables2`, as well as various custom column classes +* Function-specific templates (for generic views) +* Various custom template tags and filters +* `NetBoxModelViewSet` and several base serializer classes now provide enhanced REST API functionality +* Plugins can now extend NetBox's GraphQL API with their own schema + +No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their existing code for misuse of unsupported components, as much of NetBox's internal code base has been reorganized. + +#### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844)) + +Several new models have been added to represent field-replaceable device modules, such as line cards installed within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components (interfaces, console ports, etc.) associated with it. These components become available to the parent device once the module has been installed within a module bay. This provides a convenient mechanism to effect the addition and deletion of device components as modules are installed and removed. + +Automatic renaming of module components is also supported. When a new module is created, any occurrence of the string `{module}` in a component name will be replaced with the position of the module bay into which the module is being installed. + +As with device types, the NetBox community offers a selection of curated real-world module type definitions in our [device type library](https://github.com/netbox-community/devicetype-library). These YAML files can be imported directly to NetBox for your convenience. + +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been introduced: object and multi-object. These can be used to associate an object in NetBox with some other arbitrary object(s) regardless of its type. For example, you might create a custom field named `primary_site` on the tenant model so that each tenant can have particular site designated as its primary. The multi-object custom field type allows for the assignment of multiple objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to built-in foreign key relations. Nested representations are provided automatically for each custom field object. + +#### Custom Status Choices ([#8054](https://github.com/netbox-community/netbox/issues/8054)) + +Custom choices can be now added to most object status fields in NetBox. This is done by defining the [`FIELD_CHOICES`](../configuration/optional-settings.md#field_choices) configuration parameter to map field identifiers to an iterable of custom choices an (optionally) colors. These choices are populated automatically when NetBox initializes. For example, the following configuration will add three custom choices for the site status field, each with a designated color: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo', 'red'), + ('bar', 'Bar', 'green'), + ('baz', 'Baz', 'blue'), + ) +} +``` + +This will replace all default choices for this field with those listed. If instead the intent is to _extend_ the set of default choices, this can be done by appending a plus sign (`+`) to the end of the field identifier. For example, the following will add a single extra choice while retaining the defaults provided by NetBox: + +```python +FIELD_CHOICES = { + 'dcim.Site.status+': ( + ('fubar', 'FUBAR', 'red'), + ) +} +``` + +#### Improved User Preferences ([#7759](https://github.com/netbox-community/netbox/issues/7759)) + +A robust new mechanism for managing user preferences is included in this release. The user preferences form has been improved for better usability, and administrators can now define default preferences for all users with the [`DEFAULT_USER_PREFERENCES`](../configuration/dynamic-settings.md##default_user_preferences) configuration parameter. For example, this can be used to define the columns which appear by default in a table: + +```python +DEFAULT_USER_PREFERENCES = { + 'tables': { + 'IPAddressTable': { + 'columns': ['address', 'status', 'created', 'description'] + } + } +} +``` + +Users can adjust their own preferences under their user profile. A complete list of supported preferences is available in NetBox's [developer documentation](../development/user-preferences.md). + +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent functional roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. + +#### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) + +Inventory items can now be templatized on a device type similar to other components (such as interfaces or console ports). This enables users to better pre-model fixed hardware components such as power supplies or hard disks. + +Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other templated components. These relationships will be mirrored when instantiating inventory items on a newly-created device (see [#7846](https://github.com/netbox-community/netbox/issues/7846)). For example, if defining an optic assigned to an interface template on a device type, the instantiated device will mimic this relationship between the optic and interface. + +#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591)) + +A new service template model has been introduced to assist in standardizing the definition and association of applications with devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated. + +#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) + +A new REST API endpoint has been added at `/api/ipam/vlan-groups//available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically from the available pool. + +Where it is desired to limit the range of available VLANs within a group, users can define a minimum and/or maximum VLAN ID per group (see [#8168](https://github.com/netbox-community/netbox/issues/8168)). + +### Enhancements + +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table pagination controls +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences +* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Expose `AUTH_PASSWORD_VALIDATORS` setting to enforce password validation for local accounts +* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables +* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks +* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts +* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components +* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable the assignment of interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to device interface model +* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group +* [#8295](https://github.com/netbox-community/netbox/issues/8295) - Jinja2 rendering is now supported for webhook URLs +* [#8296](https://github.com/netbox-community/netbox/issues/8296) - Allow disabling custom links +* [#8307](https://github.com/netbox-community/netbox/issues/8307) - Add `data_type` indicator to REST API serializer for custom fields +* [#8463](https://github.com/netbox-community/netbox/issues/8463) - Change the `created` field on all change-logged models from date to datetime +* [#8496](https://github.com/netbox-community/netbox/issues/8496) - Enable assigning multiple ASNs to a provider +* [#8572](https://github.com/netbox-community/netbox/issues/8572) - Add a `pre_run()` method for reports +* [#8593](https://github.com/netbox-community/netbox/issues/8593) - Add a `link` field for contacts +* [#8649](https://github.com/netbox-community/netbox/issues/8649) - Enable customization of configuration module using `NETBOX_CONFIGURATION` environment variable +* [#9006](https://github.com/netbox-community/netbox/issues/9006) - Enable custom fields, custom links, and tags for journal entries + +### Bug Fixes (From Beta2) + +* [#8658](https://github.com/netbox-community/netbox/issues/8658) - Fix display of assigned components under inventory item lists +* [#8838](https://github.com/netbox-community/netbox/issues/8838) - Fix FieldError exception during global search +* [#8845](https://github.com/netbox-community/netbox/issues/8845) - Correct default ASN formatting in table +* [#8869](https://github.com/netbox-community/netbox/issues/8869) - Fix NoReverseMatch exception when displaying tag w/assignments +* [#8872](https://github.com/netbox-community/netbox/issues/8872) - Enable filtering by custom object fields +* [#8970](https://github.com/netbox-community/netbox/issues/8970) - Permit nested inventory item templates on device types +* [#8976](https://github.com/netbox-community/netbox/issues/8976) - Add missing `object_type` field on CustomField REST API serializer +* [#8978](https://github.com/netbox-community/netbox/issues/8978) - Fix instantiation of front ports when provisioning a module +* [#9007](https://github.com/netbox-community/netbox/issues/9007) - Fix FieldError exception when instantiating a device type with nested inventory items + +### Other Changes + +* [#7731](https://github.com/netbox-community/netbox/issues/7731) - Require Python 3.8 or later +* [#7743](https://github.com/netbox-community/netbox/issues/7743) - Remove legacy ASN field from site model +* [#7748](https://github.com/netbox-community/netbox/issues/7748) - Remove legacy contact fields from site model +* [#8031](https://github.com/netbox-community/netbox/issues/8031) - Remove automatic redirection of legacy slug-based URLs +* [#8195](https://github.com/netbox-community/netbox/issues/8195), [#8454](https://github.com/netbox-community/netbox/issues/8454) - Use 64-bit integers for all primary keys +* [#8509](https://github.com/netbox-community/netbox/issues/8509) - `CSRF_TRUSTED_ORIGINS` is now a discrete configuration parameter (rather than being populated from `ALLOWED_HOSTS`) +* [#8684](https://github.com/netbox-community/netbox/issues/8684) - Change custom link template context variable `obj` to `object` (backward-compatible) + +### REST API Changes + +* Added the following endpoints: + * `/api/dcim/inventory-item-roles/` + * `/api/dcim/inventory-item-templates/` + * `/api/dcim/modules/` + * `/api/dcim/module-bays/` + * `/api/dcim/module-bay-templates/` + * `/api/dcim/module-types/` + * `/api/ipam/service-templates/` + * `/api/ipam/vlan-groups//available-vlans/` +* circuits.Provider + * Added `asns` field +* circuits.ProviderNetwork + * Added `service_id` field +* dcim.ConsolePort + * Added `module` field +* dcim.ConsoleServerPort + * Added `module` field +* dcim.FrontPort + * Added `module` field +* dcim.Interface + * Added `module`, `speed`, `duplex`, and `vrf` fields +* dcim.InventoryItem + * Added `component_type`, `component_id`, and `role` fields + * Added read-only `component` field (GFK) +* dcim.PowerPort + * Added `module` field +* dcim.PowerOutlet + * Added `module` field +* dcim.RearPort + * Added `module` field +* dcim.Site + * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields +* extras.ConfigContext + * Add `cluster_types` field +* extras.CustomField + * Added `data_type` and `object_type` fields +* extras.CustomLink + * Added `enabled` field +* extras.JournalEntry + * Added `custom_fields` and `tags` fields +* ipam.ASN + * Added `provider_count` field +* ipam.VLANGroup + * Added the `/availables-vlans/` endpoint + * Added `min_vid` and `max_vid` fields +* tenancy.Contact + * Added `link` field +* virtualization.VMInterface + * Added `vrf` field diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index c8726f8e6..000000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -# File inclusion plugin for Python-Markdown -# https://github.com/cmacmackin/markdown-include -markdown-include - -# MkDocs Material theme (for documentation build) -# https://github.com/squidfunk/mkdocs-material -mkdocs-material diff --git a/mkdocs.yml b/mkdocs.yml index 6245f3bb2..3af855d3b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -18,6 +18,23 @@ theme: toggle: icon: material/lightbulb name: Switch to Light Mode +plugins: + - mkdocstrings: + handlers: + python: + setup_commands: + - import os + - import django + - os.chdir('netbox/') + - os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + - django.setup() + rendering: + heading_level: 3 + members_order: source + show_root_heading: true + show_root_full_path: false + show_root_toc_entry: false + show_source: false extra: social: - icon: fontawesome/brands/github @@ -62,6 +79,7 @@ nav: - Sites and Racks: 'core-functionality/sites-and-racks.md' - Devices and Cabling: 'core-functionality/devices.md' - Device Types: 'core-functionality/device-types.md' + - Modules: 'core-functionality/modules.md' - Virtualization: 'core-functionality/virtualization.md' - Service Mapping: 'core-functionality/services.md' - Circuits: 'core-functionality/circuits.md' @@ -86,7 +104,18 @@ nav: - Webhooks: 'additional-features/webhooks.md' - Plugins: - Using Plugins: 'plugins/index.md' - - Developing Plugins: 'plugins/development.md' + - Developing Plugins: + - Getting Started: 'plugins/development/index.md' + - Models: 'plugins/development/models.md' + - Views: 'plugins/development/views.md' + - Navigation: 'plugins/development/navigation.md' + - Templates: 'plugins/development/templates.md' + - Tables: 'plugins/development/tables.md' + - Forms: 'plugins/development/forms.md' + - Filters & Filter Sets: 'plugins/development/filtersets.md' + - REST API: 'plugins/development/rest-api.md' + - GraphQL API: 'plugins/development/graphql-api.md' + - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' @@ -115,6 +144,7 @@ nav: - Release Checklist: 'development/release-checklist.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 3.2: 'release-notes/version-3.2.md' - Version 3.1: 'release-notes/version-3.1.md' - Version 3.0: 'release-notes/version-3.0.md' - Version 2.11: 'release-notes/version-2.11.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 42f9d9322..19570f067 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,8 +4,10 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer from dcim.api.serializers import LinkTerminationSerializer -from netbox.api import ChoiceField -from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer +from ipam.models import ASN +from ipam.api.nested_serializers import NestedASNSerializer +from netbox.api import ChoiceField, SerializedPKRelatedField +from netbox.api.serializers import NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from .nested_serializers import * @@ -14,15 +16,23 @@ from .nested_serializers import * # Providers # -class ProviderSerializer(PrimaryModelSerializer): +class ProviderSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provider-detail') + asns = SerializedPKRelatedField( + queryset=ASN.objects.all(), + serializer=NestedASNSerializer, + required=False, + many=True + ) + + # Related object counts circuit_count = serializers.IntegerField(read_only=True) class Meta: model = Provider fields = [ 'id', 'url', 'display', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', - 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', + 'comments', 'asns', 'tags', 'custom_fields', 'created', 'last_updated', 'circuit_count', ] @@ -30,15 +40,15 @@ class ProviderSerializer(PrimaryModelSerializer): # Provider networks # -class ProviderNetworkSerializer(PrimaryModelSerializer): +class ProviderNetworkSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:providernetwork-detail') provider = NestedProviderSerializer() class Meta: model = ProviderNetwork fields = [ - 'id', 'url', 'display', 'provider', 'name', 'description', 'comments', 'tags', 'custom_fields', 'created', - 'last_updated', + 'id', 'url', 'display', 'provider', 'name', 'service_id', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', ] @@ -46,7 +56,7 @@ class ProviderNetworkSerializer(PrimaryModelSerializer): # Circuits # -class CircuitTypeSerializer(PrimaryModelSerializer): +class CircuitTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittype-detail') circuit_count = serializers.IntegerField(read_only=True) @@ -70,7 +80,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): ] -class CircuitSerializer(PrimaryModelSerializer): +class CircuitSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() status = ChoiceField(choices=CircuitStatusChoices, required=False) diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py index 5389e0bde..616adfaa4 100644 --- a/netbox/circuits/api/urls.py +++ b/netbox/circuits/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.CircuitsRootView # Providers diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 2b3e3b122..3573c05e3 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -3,8 +3,7 @@ from rest_framework.routers import APIRootView from circuits import filtersets from circuits.models import * from dcim.api.views import PassThroughPortMixin -from extras.api.views import CustomFieldModelViewSet -from netbox.api.views import ModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from utilities.utils import count_related from . import serializers @@ -21,8 +20,8 @@ class CircuitsRootView(APIRootView): # Providers # -class ProviderViewSet(CustomFieldModelViewSet): - queryset = Provider.objects.prefetch_related('tags').annotate( +class ProviderViewSet(NetBoxModelViewSet): + queryset = Provider.objects.prefetch_related('asns', 'tags').annotate( circuit_count=count_related(Circuit, 'provider') ) serializer_class = serializers.ProviderSerializer @@ -33,7 +32,7 @@ class ProviderViewSet(CustomFieldModelViewSet): # Circuit Types # -class CircuitTypeViewSet(CustomFieldModelViewSet): +class CircuitTypeViewSet(NetBoxModelViewSet): queryset = CircuitType.objects.prefetch_related('tags').annotate( circuit_count=count_related(Circuit, 'type') ) @@ -45,7 +44,7 @@ class CircuitTypeViewSet(CustomFieldModelViewSet): # Circuits # -class CircuitViewSet(CustomFieldModelViewSet): +class CircuitViewSet(NetBoxModelViewSet): queryset = Circuit.objects.prefetch_related( 'type', 'tenant', 'provider', 'termination_a', 'termination_z' ).prefetch_related('tags') @@ -57,7 +56,7 @@ class CircuitViewSet(CustomFieldModelViewSet): # Circuit Terminations # -class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): +class CircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet): queryset = CircuitTermination.objects.prefetch_related( 'circuit', 'site', 'provider_network', 'cable' ) @@ -70,7 +69,7 @@ class CircuitTerminationViewSet(PassThroughPortMixin, ModelViewSet): # Provider networks # -class ProviderNetworkViewSet(CustomFieldModelViewSet): +class ProviderNetworkViewSet(NetBoxModelViewSet): queryset = ProviderNetwork.objects.prefetch_related('tags') serializer_class = serializers.ProviderNetworkSerializer filterset_class = filtersets.ProviderNetworkFilterSet diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 0efa431fa..ddb00c64b 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class CircuitStatusChoices(ChoiceSet): + key = 'Circuit.status' STATUS_DEPROVISIONING = 'deprovisioning' STATUS_ACTIVE = 'active' @@ -14,23 +15,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_OFFLINE = 'offline' STATUS_DECOMMISSIONED = 'decommissioned' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_PROVISIONING, 'Provisioning'), - (STATUS_ACTIVE, 'Active'), - (STATUS_OFFLINE, 'Offline'), - (STATUS_DEPROVISIONING, 'Deprovisioning'), - (STATUS_DECOMMISSIONED, 'Decommissioned'), - ) - - CSS_CLASSES = { - STATUS_DEPROVISIONING: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_PROVISIONING: 'primary', - STATUS_OFFLINE: 'danger', - STATUS_DECOMMISSIONED: 'secondary', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_PROVISIONING, 'Provisioning', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_OFFLINE, 'Offline', 'red'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'yellow'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'gray'), + ] # diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 701ff8174..b7fa100a8 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,8 +3,8 @@ from django.db.models import Q from dcim.filtersets import CableTerminationFilterSet from dcim.models import Region, Site, SiteGroup -from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from ipam.models import ASN +from netbox.filtersets import ChangeLoggedModelFilterSet, NetBoxModelFilterSet, OrganizationalModelFilterSet from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -19,11 +19,7 @@ __all__ = ( ) -class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ProviderFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='circuits__terminations__site__region', @@ -61,7 +57,11 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() + asn_id = django_filters.ModelMultipleChoiceFilter( + field_name='asns', + queryset=ASN.objects.all(), + label='ASN (ID)', + ) class Meta: model = Provider @@ -79,11 +79,7 @@ class ProviderFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): ) -class ProviderNetworkFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ProviderNetworkFilterSet(NetBoxModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), label='Provider (ID)', @@ -94,35 +90,30 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Provider (slug)', ) - tag = TagFilter() class Meta: model = ProviderNetwork - fields = ['id', 'name', 'description'] + fields = ['id', 'name', 'service_id', 'description'] def search(self, queryset, name, value): if not value.strip(): return queryset return queryset.filter( Q(name__icontains=value) | + Q(service_id__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ).distinct() class CircuitTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = CircuitType fields = ['id', 'name', 'slug', 'description'] -class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): provider_id = django_filters.ModelMultipleChoiceFilter( queryset=Provider.objects.all(), label='Provider (ID)', @@ -189,7 +180,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Circuit diff --git a/netbox/circuits/forms/bulk_edit.py b/netbox/circuits/forms/bulk_edit.py index 37edd3a62..6e283219a 100644 --- a/netbox/circuits/forms/bulk_edit.py +++ b/netbox/circuits/forms/bulk_edit.py @@ -1,10 +1,15 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from ipam.models import ASN +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea, StaticSelect +from utilities.forms import ( + add_blank_choice, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea, + StaticSelect, +) __all__ = ( 'CircuitBulkEditForm', @@ -14,14 +19,15 @@ __all__ = ( ) -class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Provider.objects.all(), - widget=forms.MultipleHiddenInput - ) +class ProviderBulkEditForm(NetBoxModelBulkEditForm): asn = forms.IntegerField( required=False, - label='ASN' + label='ASN (legacy)' + ) + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), + required=False ) account = forms.CharField( max_length=30, @@ -47,23 +53,27 @@ class ProviderBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', - ] - - -class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ProviderNetwork.objects.all(), - widget=forms.MultipleHiddenInput + model = Provider + fieldsets = ( + (None, ('asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact')), ) + nullable_fields = ( + 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', + ) + + +class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all(), required=False ) - description = forms.CharField( + service_id = forms.CharField( max_length=100, + required=False, + label='Service ID' + ) + description = forms.CharField( + max_length=200, required=False ) comments = CommentField( @@ -71,31 +81,29 @@ class ProviderNetworkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor label='Comments' ) - class Meta: - nullable_fields = [ - 'description', 'comments', - ] - - -class CircuitTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=CircuitType.objects.all(), - widget=forms.MultipleHiddenInput + model = ProviderNetwork + fieldsets = ( + (None, ('provider', 'service_id', 'description')), ) + nullable_fields = ( + 'service_id', 'description', 'comments', + ) + + +class CircuitTypeBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Circuit.objects.all(), - widget=forms.MultipleHiddenInput + model = CircuitType + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class CircuitBulkEditForm(NetBoxModelBulkEditForm): type = DynamicModelChoiceField( queryset=CircuitType.objects.all(), required=False @@ -127,7 +135,10 @@ class CircuitBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'tenant', 'commit_rate', 'description', 'comments', - ] + model = Circuit + fieldsets = ( + (None, ('type', 'provider', 'status', 'tenant', 'commit_rate', 'description')), + ) + nullable_fields = ( + 'tenant', 'commit_rate', 'description', 'comments', + ) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index af5ec4425..6da79f75c 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -1,6 +1,6 @@ from circuits.choices import CircuitStatusChoices from circuits.models import * -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField @@ -12,7 +12,7 @@ __all__ = ( ) -class ProviderCSVForm(CustomFieldModelCSVForm): +class ProviderCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -22,7 +22,7 @@ class ProviderCSVForm(CustomFieldModelCSVForm): ) -class ProviderNetworkCSVForm(CustomFieldModelCSVForm): +class ProviderNetworkCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', @@ -32,11 +32,11 @@ class ProviderNetworkCSVForm(CustomFieldModelCSVForm): class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', + 'provider', 'name', 'service_id', 'description', 'comments', ] -class CircuitTypeCSVForm(CustomFieldModelCSVForm): +class CircuitTypeCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -47,7 +47,7 @@ class CircuitTypeCSVForm(CustomFieldModelCSVForm): } -class CircuitCSVForm(CustomFieldModelCSVForm): +class CircuitCSVForm(NetBoxModelCSVForm): provider = CSVModelChoiceField( queryset=Provider.objects.all(), to_field_name='name', diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index ee7a77572..ca3b003b9 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -4,9 +4,10 @@ from django.utils.translation import gettext as _ from circuits.choices import CircuitStatusChoices from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm +from ipam.models import ASN +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import TenancyFilterForm, ContactModelFilterForm -from utilities.forms import DynamicModelMultipleChoiceField, StaticSelectMultiple, TagFilterField +from utilities.forms import DynamicModelMultipleChoiceField, MultipleChoiceField, TagFilterField __all__ = ( 'CircuitFilterForm', @@ -16,14 +17,14 @@ __all__ = ( ) -class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class ProviderFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Provider - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['asn'], - ['contact', 'contact_role'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('ASN', ('asn',)), + ('Contacts', ('contact', 'contact_role')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -45,40 +46,49 @@ class ProviderFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): ) asn = forms.IntegerField( required=False, - label=_('ASN') + label=_('ASN (legacy)') + ) + asn_id = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + required=False, + label=_('ASNs') ) tag = TagFilterField(model) -class ProviderNetworkFilterForm(CustomFieldModelFilterForm): +class ProviderNetworkFilterForm(NetBoxModelFilterSetForm): model = ProviderNetwork - field_groups = ( - ('q', 'tag'), - ('provider_id',), + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('provider_id', 'service_id')), ) provider_id = DynamicModelMultipleChoiceField( queryset=Provider.objects.all(), required=False, label=_('Provider') ) + service_id = forms.CharField( + max_length=100, + required=False + ) tag = TagFilterField(model) -class CircuitTypeFilterForm(CustomFieldModelFilterForm): +class CircuitTypeFilterForm(NetBoxModelFilterSetForm): model = CircuitType tag = TagFilterField(model) -class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Circuit - field_groups = [ - ['q', 'tag'], - ['provider_id', 'provider_network_id'], - ['type_id', 'status', 'commit_rate'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ['contact', 'contact_role'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Provider', ('provider_id', 'provider_network_id')), + ('Attributes', ('type_id', 'status', 'commit_rate')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, @@ -97,10 +107,9 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldMo }, label=_('Provider network') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=CircuitStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), diff --git a/netbox/circuits/forms/models.py b/netbox/circuits/forms/models.py index 2ea246fd0..8fd5fb92d 100644 --- a/netbox/circuits/forms/models.py +++ b/netbox/circuits/forms/models.py @@ -1,9 +1,10 @@ from django import forms +from django.utils.translation import gettext as _ from circuits.models import * from dcim.models import Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm -from extras.models import Tag +from ipam.models import ASN +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -19,23 +20,25 @@ __all__ = ( ) -class ProviderForm(CustomFieldModelForm): +class ProviderForm(NetBoxModelForm): slug = SlugField() - comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), + asns = DynamicModelMultipleChoiceField( + queryset=ASN.objects.all(), + label=_('ASNs'), required=False ) + comments = CommentField() + + fieldsets = ( + ('Provider', ('name', 'slug', 'asn', 'asns', 'tags')), + ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), + ) class Meta: model = Provider fields = [ - 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments', 'tags', + 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asns', 'comments', 'tags', ] - fieldsets = ( - ('Provider', ('name', 'slug', 'asn', 'tags')), - ('Support Info', ('account', 'portal_url', 'noc_contact', 'admin_contact')), - ) widgets = { 'noc_contact': SmallTextarea( attrs={'rows': 5} @@ -53,32 +56,25 @@ class ProviderForm(CustomFieldModelForm): } -class ProviderNetworkForm(CustomFieldModelForm): +class ProviderNetworkForm(NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Provider Network', ('provider', 'name', 'service_id', 'description', 'tags')), ) class Meta: model = ProviderNetwork fields = [ - 'provider', 'name', 'description', 'comments', 'tags', + 'provider', 'name', 'service_id', 'description', 'comments', 'tags', ] - fieldsets = ( - ('Provider Network', ('provider', 'name', 'description', 'tags')), - ) -class CircuitTypeForm(CustomFieldModelForm): +class CircuitTypeForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = CircuitType @@ -87,7 +83,7 @@ class CircuitTypeForm(CustomFieldModelForm): ] -class CircuitForm(TenancyForm, CustomFieldModelForm): +class CircuitForm(TenancyForm, NetBoxModelForm): provider = DynamicModelChoiceField( queryset=Provider.objects.all() ) @@ -95,9 +91,10 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): queryset=CircuitType.objects.all() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -106,10 +103,6 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): 'cid', 'type', 'provider', 'status', 'install_date', 'commit_rate', 'description', 'tenant_group', 'tenant', 'comments', 'tags', ] - fieldsets = ( - ('Circuit', ('provider', 'cid', 'type', 'status', 'install_date', 'commit_rate', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) help_texts = { 'cid': "Unique circuit ID", 'commit_rate': "Committed rate", @@ -122,6 +115,19 @@ class CircuitForm(TenancyForm, CustomFieldModelForm): class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): + provider = DynamicModelChoiceField( + queryset=Provider.objects.all(), + required=False, + initial_params={ + 'circuits': '$circuit' + } + ) + circuit = DynamicModelChoiceField( + queryset=Circuit.objects.all(), + query_params={ + 'provider_id': '$provider', + }, + ) region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -152,8 +158,8 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): class Meta: model = CircuitTermination fields = [ - 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', 'port_speed', - 'upstream_speed', 'xconnect_id', 'pp_info', 'description', + 'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network', 'mark_connected', + 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', ] help_texts = { 'port_speed': "Physical circuit speed", @@ -161,12 +167,7 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): 'pp_info': "Patch panel ID and port number(s)" } widgets = { - 'term_side': forms.HiddenInput(), + 'term_side': StaticSelect(), 'port_speed': SelectSpeedWidget(), 'upstream_speed': SelectSpeedWidget(), } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['provider_network'].widget.add_query_param('provider_id', self.instance.circuit.provider_id) diff --git a/netbox/circuits/graphql/types.py b/netbox/circuits/graphql/types.py index a6c28c4cd..027b53203 100644 --- a/netbox/circuits/graphql/types.py +++ b/netbox/circuits/graphql/types.py @@ -1,5 +1,5 @@ from circuits import filtersets, models -from netbox.graphql.types import ObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import ObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( 'CircuitTerminationType', @@ -18,7 +18,7 @@ class CircuitTerminationType(ObjectType): filterset_class = filtersets.CircuitTerminationFilterSet -class CircuitType(PrimaryObjectType): +class CircuitType(NetBoxObjectType): class Meta: model = models.Circuit @@ -34,7 +34,7 @@ class CircuitTypeType(OrganizationalObjectType): filterset_class = filtersets.CircuitTypeFilterSet -class ProviderType(PrimaryObjectType): +class ProviderType(NetBoxObjectType): class Meta: model = models.Provider @@ -42,7 +42,7 @@ class ProviderType(PrimaryObjectType): filterset_class = filtersets.ProviderFilterSet -class ProviderNetworkType(PrimaryObjectType): +class ProviderNetworkType(NetBoxObjectType): class Meta: model = models.ProviderNetwork diff --git a/netbox/circuits/migrations/0032_provider_service_id.py b/netbox/circuits/migrations/0032_provider_service_id.py new file mode 100644 index 000000000..91410bd96 --- /dev/null +++ b/netbox/circuits/migrations/0032_provider_service_id.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0004_rename_cable_peer'), + ] + + operations = [ + migrations.AddField( + model_name='providernetwork', + name='service_id', + field=models.CharField(blank=True, max_length=100), + ), + ] diff --git a/netbox/circuits/migrations/0033_standardize_id_fields.py b/netbox/circuits/migrations/0033_standardize_id_fields.py new file mode 100644 index 000000000..475fc2527 --- /dev/null +++ b/netbox/circuits/migrations/0033_standardize_id_fields.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0032_provider_service_id'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='circuit', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittermination', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='circuittype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='provider', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='providernetwork', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='circuittermination', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0034_created_datetimefield.py b/netbox/circuits/migrations/0034_created_datetimefield.py new file mode 100644 index 000000000..4af78c1a2 --- /dev/null +++ b/netbox/circuits/migrations/0034_created_datetimefield.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0033_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittermination', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='circuittype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='provider', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='providernetwork', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/circuits/migrations/0035_provider_asns.py b/netbox/circuits/migrations/0035_provider_asns.py new file mode 100644 index 000000000..afb0da4d6 --- /dev/null +++ b/netbox/circuits/migrations/0035_provider_asns.py @@ -0,0 +1,19 @@ +# Generated by Django 4.0.3 on 2022-03-30 20:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0057_created_datetimefield'), + ('circuits', '0034_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='provider', + name='asns', + field=models.ManyToManyField(blank=True, related_name='providers', to='ipam.asn'), + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 602c0f403..02ba5209d 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -5,8 +5,8 @@ from django.urls import reverse from circuits.choices import * from dcim.models import LinkTermination -from extras.utils import extras_features -from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, OrganizationalModel, NetBoxModel +from netbox.models.features import WebhooksMixin __all__ = ( 'Circuit', @@ -15,7 +15,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class CircuitType(OrganizationalModel): """ Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named @@ -44,8 +43,7 @@ class CircuitType(OrganizationalModel): return reverse('circuits:circuittype', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Circuit(PrimaryModel): +class Circuit(NetBoxModel): """ A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple circuits. Each circuit is also assigned a CircuitType and a Site. Circuit port speed and commit rate are measured @@ -134,12 +132,11 @@ class Circuit(PrimaryModel): def get_absolute_url(self): return reverse('circuits:circuit', args=[self.pk]) - def get_status_class(self): - return CircuitStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return CircuitStatusChoices.colors.get(self.status) -@extras_features('webhooks') -class CircuitTermination(ChangeLoggedModel, LinkTermination): +class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination): circuit = models.ForeignKey( to='circuits.Circuit', on_delete=models.CASCADE, @@ -212,13 +209,9 @@ class CircuitTermination(ChangeLoggedModel, LinkTermination): raise ValidationError("A circuit termination cannot attach to both a site and a provider network.") def to_objectchange(self, action): - # Annotate the parent Circuit - try: - circuit = self.circuit - except Circuit.DoesNotExist: - # Parent circuit has been deleted - circuit = None - return super().to_objectchange(action, related_object=circuit) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.circuit + return objectchange @property def parent_object(self): diff --git a/netbox/circuits/models/providers.py b/netbox/circuits/models/providers.py index b3a6902f9..4211a54a6 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,9 +3,7 @@ from django.db import models from django.urls import reverse from dcim.fields import ASNField -from extras.utils import extras_features -from netbox.models import PrimaryModel -from utilities.querysets import RestrictedQuerySet +from netbox.models import NetBoxModel __all__ = ( 'ProviderNetwork', @@ -13,8 +11,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Provider(PrimaryModel): +class Provider(NetBoxModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model stores information pertinent to the user's relationship with the Provider. @@ -33,6 +30,11 @@ class Provider(PrimaryModel): verbose_name='ASN', help_text='32-bit autonomous system number' ) + asns = models.ManyToManyField( + to='ipam.ASN', + related_name='providers', + blank=True + ) account = models.CharField( max_length=30, blank=True, @@ -73,8 +75,7 @@ class Provider(PrimaryModel): return reverse('circuits:provider', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ProviderNetwork(PrimaryModel): +class ProviderNetwork(NetBoxModel): """ This represents a provider network which exists outside of NetBox, the details of which are unknown or unimportant to the user. @@ -87,6 +88,11 @@ class ProviderNetwork(PrimaryModel): on_delete=models.PROTECT, related_name='networks' ) + service_id = models.CharField( + max_length=100, + blank=True, + verbose_name='Service ID' + ) description = models.CharField( max_length=200, blank=True diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py deleted file mode 100644 index 76fb96aaf..000000000 --- a/netbox/circuits/tables.py +++ /dev/null @@ -1,167 +0,0 @@ -import django_tables2 as tables -from django_tables2.utils import Accessor - -from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, -) -from .models import * - - -__all__ = ( - 'CircuitTable', - 'CircuitTypeTable', - 'ProviderTable', - 'ProviderNetworkTable', -) - - -CIRCUITTERMINATION_LINK = """ -{% if value.site %} - {{ value.site }} -{% elif value.provider_network %} - {{ value.provider_network }} -{% endif %} -""" - -# -# Table columns -# - - -class CommitRateColumn(tables.TemplateColumn): - """ - Humanize the commit rate in the column view - """ - - template_code = """ - {% load helpers %} - {{ record.commit_rate|humanize_speed }} - """ - - def __init__(self, *args, **kwargs): - super().__init__(template_code=self.template_code, *args, **kwargs) - - def value(self, value): - return str(value) if value else None - -# -# Providers -# - - -class ProviderTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - circuit_count = LinkedCountColumn( - accessor=Accessor('count_circuits'), - viewname='circuits:circuit_list', - url_params={'provider_id': 'pk'}, - verbose_name='Circuits' - ) - comments = MarkdownColumn() - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - tags = TagColumn( - url_name='circuits:provider_list' - ) - - class Meta(BaseTable.Meta): - model = Provider - fields = ( - 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'contacts', 'tags', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') - - -# -# Provider networks -# - -class ProviderNetworkTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - provider = tables.Column( - linkify=True - ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='circuits:providernetwork_list' - ) - - class Meta(BaseTable.Meta): - model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'provider', 'description') - - -# -# Circuit types -# - -class CircuitTypeTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - tags = TagColumn( - url_name='circuits:circuittype_list' - ) - circuit_count = tables.Column( - verbose_name='Circuits' - ) - actions = ButtonsColumn(CircuitType) - - class Meta(BaseTable.Meta): - model = CircuitType - fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') - - -# -# Circuits -# - -class CircuitTable(BaseTable): - pk = ToggleColumn() - cid = tables.Column( - linkify=True, - verbose_name='Circuit ID' - ) - provider = tables.Column( - linkify=True - ) - status = ChoiceFieldColumn() - tenant = TenantColumn() - termination_a = tables.TemplateColumn( - template_code=CIRCUITTERMINATION_LINK, - verbose_name='Side A' - ) - termination_z = tables.TemplateColumn( - template_code=CIRCUITTERMINATION_LINK, - verbose_name='Side Z' - ) - commit_rate = CommitRateColumn() - comments = MarkdownColumn() - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - tags = TagColumn( - url_name='circuits:circuit_list' - ) - - class Meta(BaseTable.Meta): - model = Circuit - fields = ( - 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', - ) - default_columns = ( - 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', - ) diff --git a/netbox/circuits/tables/__init__.py b/netbox/circuits/tables/__init__.py new file mode 100644 index 000000000..b61c13cae --- /dev/null +++ b/netbox/circuits/tables/__init__.py @@ -0,0 +1,3 @@ +from .circuits import * +from .columns import * +from .providers import * diff --git a/netbox/circuits/tables/circuits.py b/netbox/circuits/tables/circuits.py new file mode 100644 index 000000000..cb8c940b0 --- /dev/null +++ b/netbox/circuits/tables/circuits.py @@ -0,0 +1,77 @@ +import django_tables2 as tables + +from circuits.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn +from .columns import CommitRateColumn + +__all__ = ( + 'CircuitTable', + 'CircuitTypeTable', +) + + +CIRCUITTERMINATION_LINK = """ +{% if value.site %} + {{ value.site }} +{% elif value.provider_network %} + {{ value.provider_network }} +{% endif %} +""" + + +class CircuitTypeTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + tags = columns.TagColumn( + url_name='circuits:circuittype_list' + ) + circuit_count = tables.Column( + verbose_name='Circuits' + ) + + class Meta(NetBoxTable.Meta): + model = CircuitType + fields = ( + 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') + + +class CircuitTable(NetBoxTable): + cid = tables.Column( + linkify=True, + verbose_name='Circuit ID' + ) + provider = tables.Column( + linkify=True + ) + status = columns.ChoiceFieldColumn() + tenant = TenantColumn() + termination_a = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side A' + ) + termination_z = tables.TemplateColumn( + template_code=CIRCUITTERMINATION_LINK, + verbose_name='Side Z' + ) + commit_rate = CommitRateColumn() + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='circuits:circuit_list' + ) + + class Meta(NetBoxTable.Meta): + model = Circuit + fields = ( + 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', + 'commit_rate', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', + ) diff --git a/netbox/circuits/tables/columns.py b/netbox/circuits/tables/columns.py new file mode 100644 index 000000000..d7a5ffd28 --- /dev/null +++ b/netbox/circuits/tables/columns.py @@ -0,0 +1,21 @@ +import django_tables2 as tables + +__all__ = ( + 'CommitRateColumn', +) + + +class CommitRateColumn(tables.TemplateColumn): + """ + Humanize the commit rate in the column view + """ + template_code = """ + {% load helpers %} + {{ record.commit_rate|humanize_speed }} + """ + + def __init__(self, *args, **kwargs): + super().__init__(template_code=self.template_code, *args, **kwargs) + + def value(self, value): + return str(value) if value else None diff --git a/netbox/circuits/tables/providers.py b/netbox/circuits/tables/providers.py new file mode 100644 index 000000000..e97ade7d8 --- /dev/null +++ b/netbox/circuits/tables/providers.py @@ -0,0 +1,67 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from circuits.models import * +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'ProviderTable', + 'ProviderNetworkTable', +) + + +class ProviderTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) + asn_count = columns.LinkedCountColumn( + accessor=tables.A('asns__count'), + viewname='ipam:asn_list', + url_params={'provider_id': 'pk'}, + verbose_name='ASN Count' + ) + circuit_count = columns.LinkedCountColumn( + accessor=Accessor('count_circuits'), + viewname='circuits:circuit_list', + url_params={'provider_id': 'pk'}, + verbose_name='Circuits' + ) + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='circuits:provider_list' + ) + + class Meta(NetBoxTable.Meta): + model = Provider + fields = ( + 'pk', 'id', 'name', 'asn', 'asns', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'asn_count', + 'circuit_count', 'comments', 'contacts', 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') + + +class ProviderNetworkTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + provider = tables.Column( + linkify=True + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='circuits:providernetwork_list' + ) + + class Meta(NetBoxTable.Meta): + model = ProviderNetwork + fields = ( + 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', + ) + default_columns = ('pk', 'name', 'provider', 'service_id', 'description') diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index 830c7d9ca..02b489ac4 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -3,6 +3,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Site +from ipam.models import ASN, RIR from utilities.testing import APITestCase, APIViewTestCases @@ -18,20 +19,6 @@ class AppTest(APITestCase): class ProviderTest(APIViewTestCases.APIViewTestCase): model = Provider brief_fields = ['circuit_count', 'display', 'id', 'name', 'slug', 'url'] - create_data = [ - { - 'name': 'Provider 4', - 'slug': 'provider-4', - }, - { - 'name': 'Provider 5', - 'slug': 'provider-5', - }, - { - 'name': 'Provider 6', - 'slug': 'provider-6', - }, - ] bulk_update_data = { 'asn': 1234, } @@ -39,6 +26,12 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1'), Provider(name='Provider 2', slug='provider-2'), @@ -46,6 +39,24 @@ class ProviderTest(APIViewTestCases.APIViewTestCase): ) Provider.objects.bulk_create(providers) + cls.create_data = [ + { + 'name': 'Provider 4', + 'slug': 'provider-4', + 'asns': [asns[0].pk, asns[1].pk], + }, + { + 'name': 'Provider 5', + 'slug': 'provider-5', + 'asns': [asns[2].pk, asns[3].pk], + }, + { + 'name': 'Provider 6', + 'slug': 'provider-6', + 'asns': [asns[4].pk, asns[5].pk], + }, + ] + class CircuitTypeTest(APIViewTestCases.APIViewTestCase): model = CircuitType diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 20416c4e6..205236712 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -4,6 +4,7 @@ from circuits.choices import * from circuits.filtersets import * from circuits.models import * from dcim.models import Cable, Region, Site, SiteGroup +from ipam.models import ASN, RIR from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests @@ -15,6 +16,14 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = ( + ASN(asn=64512, rir=rir), + ASN(asn=64513, rir=rir), + ASN(asn=64514, rir=rir), + ) + ASN.objects.bulk_create(asns) + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001, account='1234'), Provider(name='Provider 2', slug='provider-2', asn=65002, account='2345'), @@ -23,6 +32,9 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): Provider(name='Provider 5', slug='provider-5', asn=65005, account='5678'), ) Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0]]) + providers[1].asns.set([asns[1]]) + providers[2].asns.set([asns[2]]) regions = ( Region(name='Test Region 1', slug='test-region-1'), @@ -70,10 +82,15 @@ class ProviderTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'slug': ['provider-1', 'provider-2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_asn(self): + def test_asn(self): # Legacy field params = {'asn': ['65001', '65002']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_asn_id(self): # ASN object assignment + asns = ASN.objects.all()[:2] + params = {'asn_id': [asns[0].pk, asns[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_account(self): params = {'account': ['1234', '2345']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/circuits/tests/test_views.py b/netbox/circuits/tests/test_views.py index 851d52ae8..17c846c86 100644 --- a/netbox/circuits/tests/test_views.py +++ b/netbox/circuits/tests/test_views.py @@ -6,6 +6,7 @@ from django.urls import reverse from circuits.choices import * from circuits.models import * from dcim.models import Cable, Interface, Site +from ipam.models import ASN, RIR from utilities.testing import ViewTestCases, create_tags, create_test_device @@ -15,11 +16,21 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): @classmethod def setUpTestData(cls): - Provider.objects.bulk_create([ + rir = RIR.objects.create(name='RFC 6996', is_private=True) + asns = [ + ASN(asn=65000 + i, rir=rir) for i in range(8) + ] + ASN.objects.bulk_create(asns) + + providers = ( Provider(name='Provider 1', slug='provider-1', asn=65001), Provider(name='Provider 2', slug='provider-2', asn=65002), Provider(name='Provider 3', slug='provider-3', asn=65003), - ]) + ) + Provider.objects.bulk_create(providers) + providers[0].asns.set([asns[0], asns[1]]) + providers[1].asns.set([asns[2], asns[3]]) + providers[2].asns.set([asns[4], asns[5]]) tags = create_tags('Alpha', 'Bravo', 'Charlie') @@ -27,6 +38,7 @@ class ProviderTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'name': 'Provider X', 'slug': 'provider-x', 'asn': 65123, + 'asns': [asns[6].pk, asns[7].pk], 'account': '1234', 'portal_url': 'http://example.com/portal', 'noc_contact': 'noc@example.com', @@ -218,6 +230,7 @@ class CircuitTerminationTestCase( CircuitTermination.objects.bulk_create(circuit_terminations) cls.form_data = { + 'circuit': circuits[2].pk, 'term_side': 'A', 'site': sites[2].pk, 'description': 'New description', diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 1cea1965e..f3ee64cf0 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -1,8 +1,7 @@ from django.urls import path from dcim.views import CableCreateView, PathTraceView -from extras.views import ObjectChangeLogView, ObjectJournalView -from utilities.views import SlugRedirectView +from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * @@ -16,7 +15,6 @@ urlpatterns = [ path('providers/edit/', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), path('providers/delete/', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), path('providers//', views.ProviderView.as_view(), name='provider'), - path('providers//', SlugRedirectView.as_view(), kwargs={'model': Provider}), path('providers//edit/', views.ProviderEditView.as_view(), name='provider_edit'), path('providers//delete/', views.ProviderDeleteView.as_view(), name='provider_delete'), path('providers//changelog/', ObjectChangeLogView.as_view(), name='provider_changelog', kwargs={'model': Provider}), @@ -59,7 +57,7 @@ urlpatterns = [ path('circuits//terminations/swap/', views.CircuitSwapTerminations.as_view(), name='circuit_terminations_swap'), # Circuit terminations - path('circuits//terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), path('circuit-terminations//edit/', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), path('circuit-terminations//delete/', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), path('circuit-terminations//connect//', CableCreateView.as_view(), name='circuittermination_connect', kwargs={'termination_a_type': CircuitTermination}), diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index b549b3a01..c05aa31df 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,10 +5,8 @@ from django.shortcuts import get_object_or_404, redirect, render from netbox.views import generic from utilities.forms import ConfirmationForm -from utilities.tables import paginate_table from utilities.utils import count_related from . import filtersets, forms, tables -from .choices import CircuitTerminationSideChoices from .models import * @@ -35,7 +33,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -44,7 +42,7 @@ class ProviderView(generic.ObjectView): class ProviderEditView(generic.ObjectEditView): queryset = Provider.objects.all() - model_form = forms.ProviderForm + form = forms.ProviderForm class ProviderDeleteView(generic.ObjectDeleteView): @@ -96,7 +94,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -105,7 +103,7 @@ class ProviderNetworkView(generic.ObjectView): class ProviderNetworkEditView(generic.ObjectEditView): queryset = ProviderNetwork.objects.all() - model_form = forms.ProviderNetworkForm + form = forms.ProviderNetworkForm class ProviderNetworkDeleteView(generic.ObjectDeleteView): @@ -150,7 +148,7 @@ class CircuitTypeView(generic.ObjectView): def get_extra_context(self, request, instance): circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits_table = tables.CircuitTable(circuits, exclude=('type',)) - paginate_table(circuits_table, request) + circuits_table.configure(request) return { 'circuits_table': circuits_table, @@ -159,7 +157,7 @@ class CircuitTypeView(generic.ObjectView): class CircuitTypeEditView(generic.ObjectEditView): queryset = CircuitType.objects.all() - model_form = forms.CircuitTypeForm + form = forms.CircuitTypeForm class CircuitTypeDeleteView(generic.ObjectDeleteView): @@ -207,7 +205,7 @@ class CircuitView(generic.ObjectView): class CircuitEditView(generic.ObjectEditView): queryset = Circuit.objects.all() - model_form = forms.CircuitForm + form = forms.CircuitForm class CircuitDeleteView(generic.ObjectDeleteView): @@ -317,17 +315,9 @@ class CircuitSwapTerminations(generic.ObjectEditView): class CircuitTerminationEditView(generic.ObjectEditView): queryset = CircuitTermination.objects.all() - model_form = forms.CircuitTerminationForm + form = forms.CircuitTerminationForm template_name = 'circuits/circuittermination_edit.html' - def alter_obj(self, obj, request, url_args, url_kwargs): - if 'circuit' in url_kwargs: - obj.circuit = get_object_or_404(Circuit, pk=url_kwargs['circuit']) - return obj - - def get_return_url(self, request, obj): - return obj.circuit.get_absolute_url() - class CircuitTerminationDeleteView(generic.ObjectDeleteView): queryset = CircuitTermination.objects.all() diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1fdde78d7..0ec0e07e0 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -4,6 +4,7 @@ from dcim import models from netbox.api.serializers import BaseModelSerializer, WritableNestedSerializer __all__ = [ + 'ComponentNestedModuleSerializer', 'NestedCableSerializer', 'NestedConsolePortSerializer', 'NestedConsolePortTemplateSerializer', @@ -19,7 +20,13 @@ __all__ = [ 'NestedInterfaceSerializer', 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', + 'NestedInventoryItemRoleSerializer', + 'NestedInventoryItemTemplateSerializer', 'NestedManufacturerSerializer', + 'NestedModuleBaySerializer', + 'NestedModuleBayTemplateSerializer', + 'NestedModuleSerializer', + 'NestedModuleTypeSerializer', 'NestedPlatformSerializer', 'NestedPowerFeedSerializer', 'NestedPowerOutletSerializer', @@ -117,7 +124,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer): # -# Device types +# Device/module types # class NestedManufacturerSerializer(WritableNestedSerializer): @@ -139,6 +146,20 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count'] +class NestedModuleTypeSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer(read_only=True) + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.ModuleType + fields = ['id', 'url', 'display', 'manufacturer', 'model'] + + +# +# Component templates +# + class NestedConsolePortTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') @@ -195,6 +216,14 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedModuleBayTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + + class Meta: + model = models.ModuleBayTemplate + fields = ['id', 'url', 'display', 'name'] + + class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') @@ -203,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.InventoryItemTemplate + fields = ['id', 'url', 'display', 'name', '_depth'] + + # # Devices # @@ -235,6 +273,37 @@ class NestedDeviceSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name'] +class ModuleNestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'name'] + + +class ComponentNestedModuleSerializer(WritableNestedSerializer): + """ + Used by device component serializers. + """ + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay'] + + +class NestedModuleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer(read_only=True) + module_bay = ModuleNestedModuleBaySerializer(read_only=True) + module_type = NestedModuleTypeSerializer(read_only=True) + + class Meta: + model = models.Module + fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type'] + + class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) @@ -298,6 +367,15 @@ class NestedFrontPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied'] +class NestedModuleBaySerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + module = NestedModuleSerializer(read_only=True) + + class Meta: + model = models.ModuleBay + fields = ['id', 'url', 'display', 'module', 'name'] + + class NestedDeviceBaySerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer(read_only=True) @@ -317,6 +395,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'device', 'name', '_depth'] +class NestedInventoryItemRoleSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = models.InventoryItemRole + fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count'] + + # # Cables # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 6be27217c..813c946a3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,11 +6,13 @@ from timezone_field.rest_framework import TimeZoneSerializerField from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.api.nested_serializers import NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import ( + NestedASNSerializer, NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer, +) from ipam.models import ASN, VLAN from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField from netbox.api.serializers import ( - NestedGroupModelSerializer, PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, + NestedGroupModelSerializer, NetBoxModelSerializer, ValidatedModelSerializer, WritableNestedSerializer, ) from netbox.config import ConfigItem from tenancy.api.nested_serializers import NestedTenantSerializer @@ -107,7 +109,7 @@ class SiteGroupSerializer(NestedGroupModelSerializer): ] -class SiteSerializer(PrimaryModelSerializer): +class SiteSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:site-detail') status = ChoiceField(choices=SiteStatusChoices, required=False) region = NestedRegionSerializer(required=False, allow_null=True) @@ -132,10 +134,10 @@ class SiteSerializer(PrimaryModelSerializer): class Meta: model = Site fields = [ - 'id', 'url', 'display', 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'asns', - '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', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'asns', 'tags', + 'custom_fields', 'created', 'last_updated', 'circuit_count', 'device_count', 'prefix_count', 'rack_count', + 'virtualmachine_count', 'vlan_count', ] @@ -159,7 +161,7 @@ class LocationSerializer(NestedGroupModelSerializer): ] -class RackRoleSerializer(PrimaryModelSerializer): +class RackRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackrole-detail') rack_count = serializers.IntegerField(read_only=True) @@ -171,7 +173,7 @@ class RackRoleSerializer(PrimaryModelSerializer): ] -class RackSerializer(PrimaryModelSerializer): +class RackSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rack-detail') site = NestedSiteSerializer() location = NestedLocationSerializer(required=False, allow_null=True, default=None) @@ -210,7 +212,7 @@ class RackUnitSerializer(serializers.Serializer): return obj['name'] -class RackReservationSerializer(PrimaryModelSerializer): +class RackReservationSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rackreservation-detail') rack = NestedRackSerializer() user = NestedUserSerializer() @@ -261,10 +263,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): # -# Device types +# Device/module types # -class ManufacturerSerializer(PrimaryModelSerializer): +class ManufacturerSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:manufacturer-detail') devicetype_count = serializers.IntegerField(read_only=True) inventoryitem_count = serializers.IntegerField(read_only=True) @@ -278,7 +280,7 @@ class ManufacturerSerializer(PrimaryModelSerializer): ] -class DeviceTypeSerializer(PrimaryModelSerializer): +class DeviceTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicetype-detail') manufacturer = NestedManufacturerSerializer() subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -294,6 +296,23 @@ class DeviceTypeSerializer(PrimaryModelSerializer): ] +class ModuleTypeSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail') + manufacturer = NestedManufacturerSerializer() + # module_count = serializers.IntegerField(read_only=True) + + class Meta: + model = ModuleType + fields = [ + 'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + +# +# Component templates +# + class ConsolePortTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -409,6 +428,18 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): ] +class ModuleBayTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail') + device_type = NestedDeviceTypeSerializer() + + class Meta: + model = ModuleBayTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', 'created', + 'last_updated', + ] + + class DeviceBayTemplateSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail') device_type = NestedDeviceTypeSerializer() @@ -418,11 +449,45 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] +class InventoryItemTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = NestedDeviceTypeSerializer() + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', + 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + # # Devices # -class DeviceRoleSerializer(PrimaryModelSerializer): +class DeviceRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicerole-detail') device_count = serializers.IntegerField(read_only=True) virtualmachine_count = serializers.IntegerField(read_only=True) @@ -435,7 +500,7 @@ class DeviceRoleSerializer(PrimaryModelSerializer): ] -class PlatformSerializer(PrimaryModelSerializer): +class PlatformSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:platform-detail') manufacturer = NestedManufacturerSerializer(required=False, allow_null=True) device_count = serializers.IntegerField(read_only=True) @@ -449,7 +514,7 @@ class PlatformSerializer(PrimaryModelSerializer): ] -class DeviceSerializer(PrimaryModelSerializer): +class DeviceSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:device-detail') device_type = NestedDeviceTypeSerializer() device_role = NestedDeviceRoleSerializer() @@ -491,6 +556,20 @@ class DeviceSerializer(PrimaryModelSerializer): return data +class ModuleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail') + device = NestedDeviceSerializer() + module_bay = NestedModuleBaySerializer() + module_type = NestedModuleTypeSerializer() + + class Meta: + model = Module + fields = [ + 'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', + ] + + class DeviceWithConfigContextSerializer(DeviceSerializer): config_context = serializers.SerializerMethodField() @@ -515,9 +594,13 @@ class DeviceNAPALMSerializer(serializers.Serializer): # Device components # -class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsoleServerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -533,15 +616,19 @@ class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSeriali class Meta: model = ConsoleServerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class ConsolePortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=ConsolePortTypeChoices, allow_blank=True, @@ -557,15 +644,19 @@ class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = ConsolePort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', - 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] -class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerOutletSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerOutletTypeChoices, allow_blank=True, @@ -587,15 +678,20 @@ class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, C class Meta: model = PowerOutlet fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField( choices=PowerPortTypeChoices, allow_blank=True, @@ -606,20 +702,26 @@ class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = PowerPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', - 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', '_occupied', ] -class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class InterfaceSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=InterfaceTypeChoices) parent = NestedInterfaceSerializer(required=False, allow_null=True) bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, required=False, allow_blank=True) + duplex = ChoiceField(choices=InterfaceDuplexChoices, required=False, allow_blank=True) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_blank=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False, allow_blank=True) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -629,6 +731,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con required=False, many=True ) + vrf = NestedVRFSerializer(required=False, allow_null=True) cable = NestedCableSerializer(read_only=True) wireless_link = NestedWirelessLinkSerializer(read_only=True) wireless_lans = SerializedPKRelatedField( @@ -643,12 +746,12 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con class Meta: model = Interface fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', - 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'wireless_link', - 'link_peer', 'link_peer_type', 'wireless_lans', 'connected_endpoint', 'connected_endpoint_type', - 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', - 'count_fhrp_groups', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', + 'mtu', 'mac_address', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', 'mark_connected', + 'cable', 'wireless_link', 'link_peer', 'link_peer_type', 'wireless_lans', 'vrf', 'connected_endpoint', + 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', '_occupied', ] def validate(self, data): @@ -665,16 +768,20 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con return super().validate(data) -class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): +class RearPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) cable = NestedCableSerializer(read_only=True) class Meta: model = RearPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied', ] @@ -691,9 +798,13 @@ class FrontPortRearPortSerializer(WritableNestedSerializer): fields = ['id', 'url', 'display', 'name', 'label'] -class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): +class FrontPortSerializer(NetBoxModelSerializer, LinkTerminationSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') device = NestedDeviceSerializer() + module = ComponentNestedModuleSerializer( + required=False, + allow_null=True + ) type = ChoiceField(choices=PortTypeChoices) rear_port = FrontPortRearPortSerializer() cable = NestedCableSerializer(read_only=True) @@ -701,13 +812,26 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer): class Meta: model = FrontPort fields = [ - 'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', - 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', - 'created', 'last_updated', '_occupied', + 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', + 'custom_fields', 'created', 'last_updated', '_occupied', ] -class DeviceBaySerializer(PrimaryModelSerializer): +class ModuleBaySerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail') + device = NestedDeviceSerializer() + # installed_module = NestedModuleSerializer(required=False, allow_null=True) + + class Meta: + model = ModuleBay + fields = [ + 'id', 'url', 'display', 'device', 'name', 'label', 'position', 'description', 'tags', 'custom_fields', + 'created', 'last_updated', + ] + + +class DeviceBaySerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail') device = NestedDeviceSerializer() installed_device = NestedDeviceSerializer(required=False, allow_null=True) @@ -720,22 +844,50 @@ class DeviceBaySerializer(PrimaryModelSerializer): ] -# -# Inventory items -# - -class InventoryItemSerializer(PrimaryModelSerializer): +class InventoryItemSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') device = NestedDeviceSerializer() parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) _depth = serializers.IntegerField(source='level', read_only=True) class Meta: model = InventoryItem fields = [ - 'id', 'url', 'display', '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', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags', + 'custom_fields', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + +# +# Device component roles +# + +class InventoryItemRoleSerializer(NetBoxModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail') + inventoryitem_count = serializers.IntegerField(read_only=True) + + class Meta: + model = InventoryItemRole + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', 'inventoryitem_count', ] @@ -743,7 +895,7 @@ class InventoryItemSerializer(PrimaryModelSerializer): # Cables # -class CableSerializer(PrimaryModelSerializer): +class CableSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') termination_a_type = ContentTypeField( queryset=ContentType.objects.filter(CABLE_TERMINATION_MODELS) @@ -849,7 +1001,7 @@ class CablePathSerializer(serializers.ModelSerializer): # Virtual chassis # -class VirtualChassisSerializer(PrimaryModelSerializer): +class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') master = NestedDeviceSerializer(required=False) member_count = serializers.IntegerField(read_only=True) @@ -866,7 +1018,7 @@ class VirtualChassisSerializer(PrimaryModelSerializer): # Power panels # -class PowerPanelSerializer(PrimaryModelSerializer): +class PowerPanelSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerpanel-detail') site = NestedSiteSerializer() location = NestedLocationSerializer( @@ -884,7 +1036,7 @@ class PowerPanelSerializer(PrimaryModelSerializer): ] -class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): +class PowerFeedSerializer(NetBoxModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') power_panel = NestedPowerPanelSerializer() rack = NestedRackSerializer( diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 491f4e7f2..f67d241d5 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.DCIMRootView # Sites @@ -16,9 +16,10 @@ router.register('rack-roles', views.RackRoleViewSet) router.register('racks', views.RackViewSet) router.register('rack-reservations', views.RackReservationViewSet) -# Device types +# Device/module types router.register('manufacturers', views.ManufacturerViewSet) router.register('device-types', views.DeviceTypeViewSet) +router.register('module-types', views.ModuleTypeViewSet) # Device type components router.register('console-port-templates', views.ConsolePortTemplateViewSet) @@ -28,12 +29,15 @@ router.register('power-outlet-templates', views.PowerOutletTemplateViewSet) router.register('interface-templates', views.InterfaceTemplateViewSet) router.register('front-port-templates', views.FrontPortTemplateViewSet) router.register('rear-port-templates', views.RearPortTemplateViewSet) +router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) -# Devices +# Device/modules router.register('device-roles', views.DeviceRoleViewSet) router.register('platforms', views.PlatformViewSet) router.register('devices', views.DeviceViewSet) +router.register('modules', views.ModuleViewSet) # Device components router.register('console-ports', views.ConsolePortViewSet) @@ -43,9 +47,13 @@ router.register('power-outlets', views.PowerOutletViewSet) router.register('interfaces', views.InterfaceViewSet) router.register('front-ports', views.FrontPortViewSet) router.register('rear-ports', views.RearPortViewSet) +router.register('module-bays', views.ModuleBayViewSet) router.register('device-bays', views.DeviceBayViewSet) router.register('inventory-items', views.InventoryItemViewSet) +# Device component roles +router.register('inventory-item-roles', views.InventoryItemRoleViewSet) + # Cables router.register('cables', views.CableViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 5830396ce..e99ef333a 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -14,12 +14,12 @@ from rest_framework.viewsets import ViewSet from circuits.models import Circuit from dcim import filtersets from dcim.models import * -from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet +from extras.api.views import ConfigContextQuerySetMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata -from netbox.api.views import ModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from netbox.config import get_config from utilities.api import get_serializer_for_model from utilities.utils import count_related @@ -103,7 +103,7 @@ class PassThroughPortMixin(object): # Regions # -class RegionViewSet(CustomFieldModelViewSet): +class RegionViewSet(NetBoxModelViewSet): queryset = Region.objects.add_related_count( Region.objects.all(), Site, @@ -119,7 +119,7 @@ class RegionViewSet(CustomFieldModelViewSet): # Site groups # -class SiteGroupViewSet(CustomFieldModelViewSet): +class SiteGroupViewSet(NetBoxModelViewSet): queryset = SiteGroup.objects.add_related_count( SiteGroup.objects.all(), Site, @@ -135,7 +135,7 @@ class SiteGroupViewSet(CustomFieldModelViewSet): # Sites # -class SiteViewSet(CustomFieldModelViewSet): +class SiteViewSet(NetBoxModelViewSet): queryset = Site.objects.prefetch_related( 'region', 'tenant', 'asns', 'tags' ).annotate( @@ -154,7 +154,7 @@ class SiteViewSet(CustomFieldModelViewSet): # Locations # -class LocationViewSet(CustomFieldModelViewSet): +class LocationViewSet(NetBoxModelViewSet): queryset = Location.objects.add_related_count( Location.objects.add_related_count( Location.objects.all(), @@ -176,7 +176,7 @@ class LocationViewSet(CustomFieldModelViewSet): # Rack roles # -class RackRoleViewSet(CustomFieldModelViewSet): +class RackRoleViewSet(NetBoxModelViewSet): queryset = RackRole.objects.prefetch_related('tags').annotate( rack_count=count_related(Rack, 'role') ) @@ -188,7 +188,7 @@ class RackRoleViewSet(CustomFieldModelViewSet): # Racks # -class RackViewSet(CustomFieldModelViewSet): +class RackViewSet(NetBoxModelViewSet): queryset = Rack.objects.prefetch_related( 'site', 'location', 'role', 'tenant', 'tags' ).annotate( @@ -250,7 +250,7 @@ class RackViewSet(CustomFieldModelViewSet): # Rack reservations # -class RackReservationViewSet(ModelViewSet): +class RackReservationViewSet(NetBoxModelViewSet): queryset = RackReservation.objects.prefetch_related('rack', 'user', 'tenant') serializer_class = serializers.RackReservationSerializer filterset_class = filtersets.RackReservationFilterSet @@ -260,7 +260,7 @@ class RackReservationViewSet(ModelViewSet): # Manufacturers # -class ManufacturerViewSet(CustomFieldModelViewSet): +class ManufacturerViewSet(NetBoxModelViewSet): queryset = Manufacturer.objects.prefetch_related('tags').annotate( devicetype_count=count_related(DeviceType, 'manufacturer'), inventoryitem_count=count_related(InventoryItem, 'manufacturer'), @@ -271,10 +271,10 @@ class ManufacturerViewSet(CustomFieldModelViewSet): # -# Device types +# Device/module types # -class DeviceTypeViewSet(CustomFieldModelViewSet): +class DeviceTypeViewSet(NetBoxModelViewSet): queryset = DeviceType.objects.prefetch_related('manufacturer', 'tags').annotate( device_count=count_related(Device, 'device_type') ) @@ -283,63 +283,84 @@ class DeviceTypeViewSet(CustomFieldModelViewSet): brief_prefetch_fields = ['manufacturer'] +class ModuleTypeViewSet(NetBoxModelViewSet): + queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate( + # module_count=count_related(Module, 'module_type') + ) + serializer_class = serializers.ModuleTypeSerializer + filterset_class = filtersets.ModuleTypeFilterSet + brief_prefetch_fields = ['manufacturer'] + + # # Device type components # -class ConsolePortTemplateViewSet(ModelViewSet): +class ConsolePortTemplateViewSet(NetBoxModelViewSet): queryset = ConsolePortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsolePortTemplateSerializer filterset_class = filtersets.ConsolePortTemplateFilterSet -class ConsoleServerPortTemplateViewSet(ModelViewSet): +class ConsoleServerPortTemplateViewSet(NetBoxModelViewSet): queryset = ConsoleServerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.ConsoleServerPortTemplateSerializer filterset_class = filtersets.ConsoleServerPortTemplateFilterSet -class PowerPortTemplateViewSet(ModelViewSet): +class PowerPortTemplateViewSet(NetBoxModelViewSet): queryset = PowerPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerPortTemplateSerializer filterset_class = filtersets.PowerPortTemplateFilterSet -class PowerOutletTemplateViewSet(ModelViewSet): +class PowerOutletTemplateViewSet(NetBoxModelViewSet): queryset = PowerOutletTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.PowerOutletTemplateSerializer filterset_class = filtersets.PowerOutletTemplateFilterSet -class InterfaceTemplateViewSet(ModelViewSet): +class InterfaceTemplateViewSet(NetBoxModelViewSet): queryset = InterfaceTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.InterfaceTemplateSerializer filterset_class = filtersets.InterfaceTemplateFilterSet -class FrontPortTemplateViewSet(ModelViewSet): +class FrontPortTemplateViewSet(NetBoxModelViewSet): queryset = FrontPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.FrontPortTemplateSerializer filterset_class = filtersets.FrontPortTemplateFilterSet -class RearPortTemplateViewSet(ModelViewSet): +class RearPortTemplateViewSet(NetBoxModelViewSet): queryset = RearPortTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.RearPortTemplateSerializer filterset_class = filtersets.RearPortTemplateFilterSet -class DeviceBayTemplateViewSet(ModelViewSet): +class ModuleBayTemplateViewSet(NetBoxModelViewSet): + queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer') + serializer_class = serializers.ModuleBayTemplateSerializer + filterset_class = filtersets.ModuleBayTemplateFilterSet + + +class DeviceBayTemplateViewSet(NetBoxModelViewSet): queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer') serializer_class = serializers.DeviceBayTemplateSerializer filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateViewSet(NetBoxModelViewSet): + queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') + serializer_class = serializers.InventoryItemTemplateSerializer + filterset_class = filtersets.InventoryItemTemplateFilterSet + + # # Device roles # -class DeviceRoleViewSet(CustomFieldModelViewSet): +class DeviceRoleViewSet(NetBoxModelViewSet): queryset = DeviceRole.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'device_role'), virtualmachine_count=count_related(VirtualMachine, 'role') @@ -352,7 +373,7 @@ class DeviceRoleViewSet(CustomFieldModelViewSet): # Platforms # -class PlatformViewSet(CustomFieldModelViewSet): +class PlatformViewSet(NetBoxModelViewSet): queryset = Platform.objects.prefetch_related('tags').annotate( device_count=count_related(Device, 'platform'), virtualmachine_count=count_related(VirtualMachine, 'platform') @@ -362,10 +383,10 @@ class PlatformViewSet(CustomFieldModelViewSet): # -# Devices +# Devices/modules # -class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): +class DeviceViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'tags', @@ -511,83 +532,120 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): return Response(response) +class ModuleViewSet(NetBoxModelViewSet): + queryset = Module.objects.prefetch_related( + 'device', 'module_bay', 'module_type__manufacturer', 'tags', + ) + serializer_class = serializers.ModuleSerializer + filterset_class = filtersets.ModuleFilterSet + + # # Device components # -class ConsolePortViewSet(PathEndpointMixin, ModelViewSet): - queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = ConsolePort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.ConsolePortSerializer filterset_class = filtersets.ConsolePortFilterSet brief_prefetch_fields = ['device'] -class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet): +class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = ConsoleServerPort.objects.prefetch_related( - 'device', '_path__destination', 'cable', '_link_peer', 'tags' + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' ) serializer_class = serializers.ConsoleServerPortSerializer filterset_class = filtersets.ConsoleServerPortFilterSet brief_prefetch_fields = ['device'] -class PowerPortViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = PowerPort.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerPortSerializer filterset_class = filtersets.PowerPortFilterSet brief_prefetch_fields = ['device'] -class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): - queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags') +class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): + queryset = PowerOutlet.objects.prefetch_related( + 'device', 'module__module_bay', '_path__destination', 'cable', '_link_peer', 'tags' + ) serializer_class = serializers.PowerOutletSerializer filterset_class = filtersets.PowerOutletFilterSet brief_prefetch_fields = ['device'] -class InterfaceViewSet(PathEndpointMixin, ModelViewSet): +class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( - 'device', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet brief_prefetch_fields = ['device'] -class FrontPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = FrontPort.objects.prefetch_related('device__device_type__manufacturer', 'rear_port', 'cable', 'tags') +class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): + queryset = FrontPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'rear_port', 'cable', 'tags' + ) serializer_class = serializers.FrontPortSerializer filterset_class = filtersets.FrontPortFilterSet brief_prefetch_fields = ['device'] -class RearPortViewSet(PassThroughPortMixin, ModelViewSet): - queryset = RearPort.objects.prefetch_related('device__device_type__manufacturer', 'cable', 'tags') +class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet): + queryset = RearPort.objects.prefetch_related( + 'device__device_type__manufacturer', 'module__module_bay', 'cable', 'tags' + ) serializer_class = serializers.RearPortSerializer filterset_class = filtersets.RearPortFilterSet brief_prefetch_fields = ['device'] -class DeviceBayViewSet(ModelViewSet): - queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags') +class ModuleBayViewSet(NetBoxModelViewSet): + queryset = ModuleBay.objects.prefetch_related('tags') + serializer_class = serializers.ModuleBaySerializer + filterset_class = filtersets.ModuleBayFilterSet + brief_prefetch_fields = ['device'] + + +class DeviceBayViewSet(NetBoxModelViewSet): + queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags') serializer_class = serializers.DeviceBaySerializer filterset_class = filtersets.DeviceBayFilterSet brief_prefetch_fields = ['device'] -class InventoryItemViewSet(ModelViewSet): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags') +class InventoryItemViewSet(NetBoxModelViewSet): + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags') serializer_class = serializers.InventoryItemSerializer filterset_class = filtersets.InventoryItemFilterSet brief_prefetch_fields = ['device'] +# +# Device component roles +# + +class InventoryItemRoleViewSet(NetBoxModelViewSet): + queryset = InventoryItemRole.objects.prefetch_related('tags').annotate( + inventoryitem_count=count_related(InventoryItem, 'role') + ) + serializer_class = serializers.InventoryItemRoleSerializer + filterset_class = filtersets.InventoryItemRoleFilterSet + + # # Cables # -class CableViewSet(ModelViewSet): +class CableViewSet(NetBoxModelViewSet): metadata_class = ContentTypeMetadata queryset = Cable.objects.prefetch_related( 'termination_a', 'termination_b' @@ -600,7 +658,7 @@ class CableViewSet(ModelViewSet): # Virtual chassis # -class VirtualChassisViewSet(ModelViewSet): +class VirtualChassisViewSet(NetBoxModelViewSet): queryset = VirtualChassis.objects.prefetch_related('tags').annotate( member_count=count_related(Device, 'virtual_chassis') ) @@ -613,7 +671,7 @@ class VirtualChassisViewSet(ModelViewSet): # Power panels # -class PowerPanelViewSet(ModelViewSet): +class PowerPanelViewSet(NetBoxModelViewSet): queryset = PowerPanel.objects.prefetch_related( 'site', 'location' ).annotate( @@ -627,7 +685,7 @@ class PowerPanelViewSet(ModelViewSet): # Power feeds # -class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet): +class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = PowerFeed.objects.prefetch_related( 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags' ) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8900b2d89..c5f70c1b6 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class SiteStatusChoices(ChoiceSet): + key = 'Site.status' STATUS_PLANNED = 'planned' STATUS_STAGING = 'staging' @@ -13,21 +14,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGING, 'Staging'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - (STATUS_RETIRED, 'Retired'), - ) - - CSS_CLASSES = { - STATUS_PLANNED: 'info', - STATUS_STAGING: 'primary', - STATUS_ACTIVE: 'success', - STATUS_DECOMMISSIONING: 'warning', - STATUS_RETIRED: 'danger', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGING, 'Staging', 'blue'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + (STATUS_RETIRED, 'Retired', 'red'), + ] # @@ -67,6 +60,7 @@ class RackWidthChoices(ChoiceSet): class RackStatusChoices(ChoiceSet): + key = 'Rack.status' STATUS_RESERVED = 'reserved' STATUS_AVAILABLE = 'available' @@ -74,21 +68,13 @@ class RackStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( - (STATUS_RESERVED, 'Reserved'), - (STATUS_AVAILABLE, 'Available'), - (STATUS_PLANNED, 'Planned'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_RESERVED: 'warning', - STATUS_AVAILABLE: 'success', - STATUS_PLANNED: 'info', - STATUS_ACTIVE: 'primary', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_RESERVED, 'Reserved', 'yellow'), + (STATUS_AVAILABLE, 'Available', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_ACTIVE, 'Active', 'blue'), + (STATUS_DEPRECATED, 'Deprecated', 'red'), + ] class RackDimensionUnitChoices(ChoiceSet): @@ -144,6 +130,7 @@ class DeviceFaceChoices(ChoiceSet): class DeviceStatusChoices(ChoiceSet): + key = 'Device.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -153,25 +140,15 @@ class DeviceStatusChoices(ChoiceSet): STATUS_INVENTORY = 'inventory' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_INVENTORY, 'Inventory'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_INVENTORY: 'secondary', - STATUS_DECOMMISSIONING: 'warning', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_INVENTORY, 'Inventory', 'purple'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + ] class DeviceAirflowChoices(ChoiceSet): @@ -974,6 +951,19 @@ class InterfaceTypeChoices(ChoiceSet): ) +class InterfaceDuplexChoices(ChoiceSet): + + DUPLEX_HALF = 'half' + DUPLEX_FULL = 'full' + DUPLEX_AUTO = 'auto' + + CHOICES = ( + (DUPLEX_HALF, 'Half'), + (DUPLEX_FULL, 'Full'), + (DUPLEX_AUTO, 'Auto'), + ) + + class InterfaceModeChoices(ChoiceSet): MODE_ACCESS = 'access' @@ -1164,17 +1154,11 @@ class LinkStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_CONNECTED, 'Connected'), - (STATUS_PLANNED, 'Planned'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_CONNECTED, 'Connected', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), ) - CSS_CLASSES = { - STATUS_CONNECTED: 'success', - STATUS_PLANNED: 'info', - STATUS_DECOMMISSIONING: 'warning', - } - class CableLengthUnitChoices(ChoiceSet): @@ -1203,25 +1187,19 @@ class CableLengthUnitChoices(ChoiceSet): # class PowerFeedStatusChoices(ChoiceSet): + key = 'PowerFeed.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' STATUS_FAILED = 'failed' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_FAILED, 'Failed'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_FAILED: 'danger', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + ] class PowerFeedTypeChoices(ChoiceSet): @@ -1230,15 +1208,10 @@ class PowerFeedTypeChoices(ChoiceSet): TYPE_REDUNDANT = 'redundant' CHOICES = ( - (TYPE_PRIMARY, 'Primary'), - (TYPE_REDUNDANT, 'Redundant'), + (TYPE_PRIMARY, 'Primary', 'green'), + (TYPE_REDUNDANT, 'Redundant', 'cyan'), ) - CSS_CLASSES = { - TYPE_PRIMARY: 'success', - TYPE_REDUNDANT: 'info', - } - class PowerFeedSupplyChoices(ChoiceSet): diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 2136f06aa..45844b049 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -50,16 +50,43 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES # -# PowerFeeds +# Power feeds # POWERFEED_VOLTAGE_DEFAULT = 120 - POWERFEED_AMPERAGE_DEFAULT = 20 - POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage +# +# Device components +# + +MODULAR_COMPONENT_TEMPLATE_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleporttemplate', + 'consoleserverporttemplate', + 'frontporttemplate', + 'interfacetemplate', + 'poweroutlettemplate', + 'powerporttemplate', + 'rearporttemplate', + )) + +MODULAR_COMPONENT_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleport', + 'consoleserverport', + 'frontport', + 'interface', + 'poweroutlet', + 'powerport', + 'rearport', + )) + + # # Cabling and connections # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 534a71762..a380fbcce 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,11 +1,10 @@ import django_filters from django.contrib.auth.models import User -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet -from ipam.models import ASN +from ipam.models import ASN, VRF from netbox.filtersets import ( - BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet, + BaseFilterSet, ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet, ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * @@ -39,8 +38,14 @@ __all__ = ( 'InterfaceFilterSet', 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', + 'InventoryItemRoleFilterSet', + 'InventoryItemTemplateFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', + 'ModuleBayFilterSet', + 'ModuleBayTemplateFilterSet', + 'ModuleFilterSet', + 'ModuleTypeFilterSet', 'PathEndpointFilterSet', 'PlatformFilterSet', 'PowerConnectionFilterSet', @@ -73,7 +78,6 @@ class RegionFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) - tag = TagFilter() class Meta: model = Region @@ -91,18 +95,13 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) - tag = TagFilter() class Meta: model = SiteGroup fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): status = django_filters.MultipleChoiceFilter( choices=SiteStatusChoices, null_value=None @@ -131,19 +130,23 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS to_field_name='slug', label='Group (slug)', ) + asn = django_filters.ModelMultipleChoiceFilter( + field_name='asns__asn', + queryset=ASN.objects.all(), + to_field_name='asn', + label='AS (ID)', + ) asn_id = django_filters.ModelMultipleChoiceFilter( field_name='asns', queryset=ASN.objects.all(), label='AS (ID)', ) - tag = TagFilter() class Meta: model = Site - fields = [ - 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'description' - ] + fields = ( + 'id', 'name', 'slug', 'facility', 'latitude', 'longitude', 'description' + ) def search(self, queryset, name, value): if not value.strip(): @@ -154,13 +157,9 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS Q(description__icontains=value) | Q(physical_address__icontains=value) | Q(shipping_address__icontains=value) | - Q(contact_name__icontains=value) | - Q(contact_phone__icontains=value) | - Q(contact_email__icontains=value) | Q(comments__icontains=value) ) try: - qs_filter |= Q(asn=int(value.strip())) qs_filter |= Q(asns__asn=int(value.strip())) except ValueError: pass @@ -217,7 +216,6 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM to_field_name='slug', label='Location (slug)', ) - tag = TagFilter() class Meta: model = Location @@ -233,18 +231,13 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, OrganizationalM class RackRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color', 'description'] -class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -317,7 +310,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS serial = django_filters.CharFilter( lookup_expr='iexact' ) - tag = TagFilter() class Meta: model = Rack @@ -338,11 +330,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterS ) -class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): rack_id = django_filters.ModelMultipleChoiceFilter( queryset=Rack.objects.all(), label='Rack (ID)', @@ -381,7 +369,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) - tag = TagFilter() class Meta: model = RackReservation @@ -399,18 +386,13 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): - tag = TagFilter() class Meta: model = Manufacturer fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class DeviceTypeFilterSet(NetBoxModelFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', @@ -445,11 +427,14 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = DeviceType @@ -488,10 +473,85 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): rearporttemplates__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebaytemplates__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebaytemplates__isnull=value) +class ModuleTypeFilterSet(NetBoxModelFilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + console_ports = django_filters.BooleanFilter( + method='_console_ports', + label='Has console ports', + ) + console_server_ports = django_filters.BooleanFilter( + method='_console_server_ports', + label='Has console server ports', + ) + power_ports = django_filters.BooleanFilter( + method='_power_ports', + label='Has power ports', + ) + power_outlets = django_filters.BooleanFilter( + method='_power_outlets', + label='Has power outlets', + ) + interfaces = django_filters.BooleanFilter( + method='_interfaces', + label='Has interfaces', + ) + pass_through_ports = django_filters.BooleanFilter( + method='_pass_through_ports', + label='Has pass-through ports', + ) + + class Meta: + model = ModuleType + fields = ['id', 'model', 'part_number'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(manufacturer__name__icontains=value) | + Q(model__icontains=value) | + Q(part_number__icontains=value) | + Q(comments__icontains=value) + ) + + def _console_ports(self, queryset, name, value): + return queryset.exclude(consoleporttemplates__isnull=value) + + def _console_server_ports(self, queryset, name, value): + return queryset.exclude(consoleserverporttemplates__isnull=value) + + def _power_ports(self, queryset, name, value): + return queryset.exclude(powerporttemplates__isnull=value) + + def _power_outlets(self, queryset, name, value): + return queryset.exclude(poweroutlettemplates__isnull=value) + + def _interfaces(self, queryset, name, value): + return queryset.exclude(interfacetemplates__isnull=value) + + def _pass_through_ports(self, queryset, name, value): + return queryset.exclude( + frontporttemplates__isnull=value, + rearporttemplates__isnull=value + ) + + class DeviceTypeComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', @@ -509,28 +569,36 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet): return queryset.filter(name__icontains=value) -class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet): + moduletype_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleType.objects.all(), + field_name='module_type_id', + label='Module type (ID)', + ) + + +class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsolePortTemplate fields = ['id', 'name', 'type'] -class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ConsoleServerPortTemplate fields = ['id', 'name', 'type'] -class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = PowerPortTemplate fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw'] -class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): feed_leg = django_filters.MultipleChoiceFilter( choices=PowerOutletFeedLegChoices, null_value=None @@ -541,7 +609,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompone fields = ['id', 'name', 'type', 'feed_leg'] -class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=InterfaceTypeChoices, null_value=None @@ -552,7 +620,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'mgmt_only'] -class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -563,7 +631,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name', 'type', 'color'] -class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -574,6 +642,13 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF fields = ['id', 'name', 'type', 'color', 'positions'] +class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + + class Meta: + model = ModuleBayTemplate + fields = ['id', 'name'] + + class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): class Meta: @@ -581,8 +656,50 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent fields = ['id', 'name'] +class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemTemplate.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() + + class Meta: + model = InventoryItemTemplate + fields = ['id', 'name', 'label', 'part_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(part_id__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + class DeviceRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = DeviceRole @@ -601,18 +718,13 @@ class PlatformFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) - tag = TagFilter() class Meta: model = Platform fields = ['id', 'name', 'slug', 'napalm_driver', 'description'] -class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): manufacturer_id = django_filters.ModelMultipleChoiceFilter( field_name='device_type__manufacturer', queryset=Manufacturer.objects.all(), @@ -763,11 +875,14 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilte method='_pass_through_ports', label='Has pass-through ports', ) + module_bays = django_filters.BooleanFilter( + method='_module_bays', + label='Has module bays', + ) device_bays = django_filters.BooleanFilter( method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = Device @@ -814,10 +929,55 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilte rearports__isnull=value ) + def _module_bays(self, queryset, name, value): + return queryset.exclude(modulebays__isnull=value) + def _device_bays(self, queryset, name, value): return queryset.exclude(devicebays__isnull=value) +class ModuleFilterSet(NetBoxModelFilterSet): + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + module_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='module_type', + queryset=ModuleType.objects.all(), + label='Module type (ID)', + ) + module_type = django_filters.ModelMultipleChoiceFilter( + field_name='module_type__model', + queryset=ModuleType.objects.all(), + to_field_name='model', + label='Module type (model)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + + class Meta: + model = Module + fields = ['id', 'serial', 'asset_tag'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(serial__icontains=value.strip()) | + Q(asset_tag__icontains=value.strip()) | + Q(comments__icontains=value) + ).distinct() + + class DeviceComponentFilterSet(django_filters.FilterSet): q = django_filters.CharFilter( method='search', @@ -892,7 +1052,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label='Virtual Chassis', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -904,6 +1063,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet): ) +class ModularDeviceComponentFilterSet(DeviceComponentFilterSet): + """ + Extends DeviceComponentFilterSet to add a module_id filter for components + which can be associated with a particular module within a device. + """ + module_id = django_filters.ModelMultipleChoiceFilter( + queryset=Module.objects.all(), + label='Module (ID)', + ) + + class CableTerminationFilterSet(django_filters.FilterSet): cabled = django_filters.BooleanFilter( field_name='cable', @@ -924,7 +1094,12 @@ class PathEndpointFilterSet(django_filters.FilterSet): return queryset.filter(Q(_path__isnull=True) | Q(_path__is_active=False)) -class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsolePortFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -935,7 +1110,12 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -946,7 +1126,12 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -957,7 +1142,12 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -972,11 +1162,12 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InterfaceFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet, + PathEndpointFilterSet +): # Override device and device_id filters from DeviceComponentFilterSet to match against any peer virtual chassis # members device = MultiValueCharFilter( @@ -1008,9 +1199,12 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT queryset=Interface.objects.all(), label='LAG interface (ID)', ) + speed = MultiValueNumberFilter() + duplex = django_filters.MultipleChoiceFilter( + choices=InterfaceDuplexChoices + ) mac_address = MultiValueMACAddressFilter() wwn = MultiValueWWNFilter() - tag = TagFilter() vlan_id = django_filters.CharFilter( method='filter_vlan_id', label='Assigned VLAN' @@ -1029,6 +1223,17 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT rf_channel = django_filters.MultipleChoiceFilter( choices=WirelessChannelChoices ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) class Meta: model = Interface @@ -1085,7 +1290,11 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT }.get(value, queryset.none()) -class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1096,7 +1305,11 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'type', 'color', 'description'] -class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet( + NetBoxModelFilterSet, + ModularDeviceComponentFilterSet, + CableTerminationFilterSet +): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1107,18 +1320,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): + + class Meta: + model = ModuleBay + fields = ['id', 'name', 'label', 'description'] + + +class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', @@ -1133,6 +1349,18 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + role_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + field_name='role__slug', + queryset=InventoryItemRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() serial = django_filters.CharFilter( lookup_expr='iexact' ) @@ -1154,11 +1382,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): return queryset.filter(qs_filter) -class VirtualChassisFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): + + class Meta: + model = InventoryItemRole + fields = ['id', 'name', 'slug', 'color'] + + +class VirtualChassisFilterSet(NetBoxModelFilterSet): master_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Master (ID)', @@ -1217,7 +1448,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag = TagFilter() class Meta: model = VirtualChassis @@ -1234,11 +1464,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): termination_a_type = ContentTypeFilter() termination_a_id = MultiValueNumberFilter() termination_b_type = ContentTypeFilter() @@ -1275,7 +1501,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tag = TagFilter() class Meta: model = Cable @@ -1294,11 +1519,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset -class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -1341,7 +1562,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): lookup_expr='in', label='Location (ID)', ) - tag = TagFilter() class Meta: model = PowerPanel @@ -1356,11 +1576,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='power_panel__site__region', @@ -1411,7 +1627,6 @@ class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathE choices=PowerFeedStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = PowerFeed diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 16e860c38..4d73fcc2a 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -4,7 +4,7 @@ from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag from utilities.forms import DynamicModelMultipleChoiceField, form_from_model -from .object_create import ComponentForm +from .object_create import ComponentCreateForm __all__ = ( 'ConsolePortBulkCreateForm', @@ -13,6 +13,7 @@ __all__ = ( # 'FrontPortBulkCreateForm', 'InterfaceBulkCreateForm', 'InventoryItemBulkCreateForm', + 'ModuleBayBulkCreateForm', 'PowerOutletBulkCreateForm', 'PowerPortBulkCreateForm', 'RearPortBulkCreateForm', @@ -23,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm): +class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -71,12 +72,12 @@ class PowerOutletBulkCreateForm( class InterfaceBulkCreateForm( - form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected']), + form_from_model(Interface, ['type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected']), DeviceBulkAddComponentForm ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', + 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'tags', ) @@ -95,17 +96,22 @@ class RearPortBulkCreateForm( field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') +class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): + model = ModuleBay + field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + + class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay field_order = ('name_pattern', 'label_pattern', 'description', 'tags') class InventoryItemBulkCreateForm( - form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), + form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']), DeviceBulkAddComponentForm ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', - 'tags', + 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index a48b1d8b2..b166530c8 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -6,13 +6,12 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX -from ipam.models import VLAN, ASN +from ipam.models import ASN, VLAN, VRF +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, + DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget, ) __all__ = ( @@ -31,8 +30,14 @@ __all__ = ( 'InterfaceBulkEditForm', 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', + 'InventoryItemRoleBulkEditForm', + 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', + 'ModuleBulkEditForm', + 'ModuleBayBulkEditForm', + 'ModuleBayTemplateBulkEditForm', + 'ModuleTypeBulkEditForm', 'PlatformBulkEditForm', 'PowerFeedBulkEditForm', 'PowerOutletBulkEditForm', @@ -52,11 +57,7 @@ __all__ = ( ) -class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Region.objects.all(), - widget=forms.MultipleHiddenInput - ) +class RegionBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -66,15 +67,14 @@ class RegionBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=SiteGroup.objects.all(), - widget=forms.MultipleHiddenInput + model = Region + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class SiteGroupBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False @@ -84,15 +84,14 @@ class SiteGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Site.objects.all(), - widget=forms.MultipleHiddenInput + model = SiteGroup + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class SiteBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(SiteStatusChoices), required=False, @@ -111,12 +110,6 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=Tenant.objects.all(), required=False ) - asn = forms.IntegerField( - min_value=BGP_ASN_MIN, - max_value=BGP_ASN_MAX, - required=False, - label='ASN' - ) asns = DynamicModelMultipleChoiceField( queryset=ASN.objects.all(), label=_('ASNs'), @@ -144,18 +137,16 @@ class SiteBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = [ - 'region', 'group', 'tenant', 'asn', 'asns', 'contact_name', 'contact_phone', 'contact_email', 'description', - 'time_zone', - ] - - -class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Location.objects.all(), - widget=forms.MultipleHiddenInput + model = Site + fieldsets = ( + (None, ('status', 'region', 'group', 'tenant', 'asns', 'time_zone', 'description')), ) + nullable_fields = ( + 'region', 'group', 'tenant', 'asns', 'description', 'time_zone', + ) + + +class LocationBulkEditForm(NetBoxModelBulkEditForm): site = DynamicModelChoiceField( queryset=Site.objects.all(), required=False @@ -176,15 +167,14 @@ class LocationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'tenant', 'description'] - - -class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackRole.objects.all(), - widget=forms.MultipleHiddenInput + model = Location + fieldsets = ( + (None, ('site', 'parent', 'tenant', 'description')), ) + nullable_fields = ('parent', 'tenant', 'description') + + +class RackRoleBulkEditForm(NetBoxModelBulkEditForm): color = ColorField( required=False ) @@ -193,15 +183,14 @@ class RackRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['color', 'description'] - - -class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Rack.objects.all(), - widget=forms.MultipleHiddenInput + model = RackRole + fieldsets = ( + (None, ('color', 'description')), ) + nullable_fields = ('color', 'description') + + +class RackBulkEditForm(NetBoxModelBulkEditForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -291,17 +280,18 @@ class RackBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', - ] - - -class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=RackReservation.objects.all(), - widget=forms.MultipleHiddenInput() + model = Rack + fieldsets = ( + ('Rack', ('status', 'role', 'tenant', 'serial', 'asset_tag')), + ('Location', ('region', 'site_group', 'site', 'location')), + ('Hardware', ('type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit')), ) + nullable_fields = ( + 'location', 'tenant', 'role', 'serial', 'asset_tag', 'outer_width', 'outer_depth', 'outer_unit', 'comments', + ) + + +class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( queryset=User.objects.order_by( 'username' @@ -318,33 +308,33 @@ class RackReservationBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFor required=False ) - class Meta: - nullable_fields = [] - - -class ManufacturerBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Manufacturer.objects.all(), - widget=forms.MultipleHiddenInput + model = RackReservation + fieldsets = ( + (None, ('user', 'tenant', 'description')), ) + + +class ManufacturerBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceType.objects.all(), - widget=forms.MultipleHiddenInput() + model = Manufacturer + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) + part_number = forms.CharField( + required=False + ) u_height = forms.IntegerField( min_value=1, required=False @@ -360,15 +350,30 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ['airflow'] - - -class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceRole.objects.all(), - widget=forms.MultipleHiddenInput + model = DeviceType + fieldsets = ( + (None, ('manufacturer', 'part_number', 'u_height', 'is_full_depth', 'airflow')), ) + nullable_fields = ('part_number', 'airflow') + + +class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + part_number = forms.CharField( + required=False + ) + + model = ModuleType + fieldsets = ( + (None, ('manufacturer', 'part_number')), + ) + nullable_fields = ('part_number',) + + +class DeviceRoleBulkEditForm(NetBoxModelBulkEditForm): color = ColorField( required=False ) @@ -382,15 +387,14 @@ class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['color', 'description'] - - -class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Platform.objects.all(), - widget=forms.MultipleHiddenInput + model = DeviceRole + fieldsets = ( + (None, ('color', 'vm_role', 'description')), ) + nullable_fields = ('color', 'description') + + +class PlatformBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -405,15 +409,14 @@ class PlatformBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['manufacturer', 'napalm_driver', 'description'] - - -class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Device.objects.all(), - widget=forms.MultipleHiddenInput() + model = Platform + fieldsets = ( + (None, ('manufacturer', 'napalm_driver', 'description')), ) + nullable_fields = ('manufacturer', 'napalm_driver', 'description') + + +class DeviceBulkEditForm(NetBoxModelBulkEditForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -464,17 +467,43 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Serial Number' ) - class Meta: - nullable_fields = [ - 'tenant', 'platform', 'serial', 'airflow', - ] - - -class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cable.objects.all(), - widget=forms.MultipleHiddenInput + model = Device + fieldsets = ( + ('Device', ('device_role', 'status', 'tenant', 'platform')), + ('Location', ('site', 'location')), + ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) + nullable_fields = ( + 'tenant', 'platform', 'serial', 'airflow', + ) + + +class ModuleBulkEditForm(NetBoxModelBulkEditForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + serial = forms.CharField( + max_length=50, + required=False, + label='Serial Number' + ) + + model = Module + fieldsets = ( + (None, ('manufacturer', 'module_type', 'serial')), + ) + nullable_fields = ('serial',) + + +class CableBulkEditForm(NetBoxModelBulkEditForm): type = forms.ChoiceField( choices=add_blank_choice(CableTypeChoices), required=False, @@ -509,10 +538,14 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = [ - 'type', 'status', 'tenant', 'label', 'color', 'length', - ] + model = Cable + fieldsets = ( + (None, ('type', 'status', 'tenant', 'label')), + ('Attributes', ('color', 'length', 'length_unit')), + ) + nullable_fields = ( + 'type', 'status', 'tenant', 'label', 'color', 'length', + ) def clean(self): super().clean() @@ -526,25 +559,20 @@ class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): }) -class VirtualChassisBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualChassis.objects.all(), - widget=forms.MultipleHiddenInput() - ) +class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm): domain = forms.CharField( max_length=30, required=False ) - class Meta: - nullable_fields = ['domain'] - - -class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPanel.objects.all(), - widget=forms.MultipleHiddenInput + model = VirtualChassis + fieldsets = ( + (None, ('domain',)), ) + nullable_fields = ('domain',) + + +class PowerPanelBulkEditForm(NetBoxModelBulkEditForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -575,15 +603,14 @@ class PowerPanelBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): } ) - class Meta: - nullable_fields = ['location'] - - -class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerFeed.objects.all(), - widget=forms.MultipleHiddenInput + model = PowerPanel + fieldsets = ( + (None, ('region', 'site_group', 'site', 'location')), ) + nullable_fields = ('location',) + + +class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): power_panel = DynamicModelChoiceField( queryset=PowerPanel.objects.all(), required=False @@ -634,10 +661,12 @@ class PowerFeedBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'location', 'comments', - ] + model = PowerFeed + fieldsets = ( + (None, ('power_panel', 'rack', 'status', 'type', 'mark_connected')), + ('Power', ('supply', 'phase', 'voltage', 'amperage', 'max_utilization')) + ) + nullable_fields = ('location', 'comments') # @@ -659,8 +688,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): widget=StaticSelect() ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): @@ -681,8 +709,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'description') + nullable_fields = ('label', 'type', 'description') class PowerPortTemplateBulkEditForm(BulkEditForm): @@ -713,8 +740,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') + nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') class PowerOutletTemplateBulkEditForm(BulkEditForm): @@ -750,8 +776,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') + nullable_fields = ('label', 'type', 'power_port', 'feed_leg', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -788,8 +813,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') class FrontPortTemplateBulkEditForm(BulkEditForm): @@ -813,8 +837,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) class RearPortTemplateBulkEditForm(BulkEditForm): @@ -838,8 +861,23 @@ class RearPortTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('description',) + nullable_fields = ('description',) + + +class ModuleBayTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=ModuleBayTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + + nullable_fields = ('label', 'position', 'description') class DeviceBayTemplateBulkEditForm(BulkEditForm): @@ -855,90 +893,125 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): required=False ) - class Meta: - nullable_fields = ('label', 'description') + nullable_fields = ('label', 'description') + + +class InventoryItemTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItemTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') # # Device components # -class ConsolePortBulkEditForm( - form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsolePort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class ConsoleServerPortBulkEditForm( - form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=ConsoleServerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class PowerPortBulkEditForm( - form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - mark_connected = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect - ) - - class Meta: - nullable_fields = ['label', 'description'] - - -class PowerOutletBulkEditForm( - form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm -): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput() - ) +class ComponentBulkEditForm(NetBoxModelBulkEditForm): device = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, disabled=True, widget=forms.HiddenInput() ) + module = forms.ModelChoiceField( + queryset=Module.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit module queryset to Modules which belong to the parent Device + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['module'].queryset = Module.objects.filter(device=device) + else: + self.fields['module'].choices = () + self.fields['module'].widget.attrs['disabled'] = True + + +class ConsolePortBulkEditForm( + form_from_model(ConsolePort, ['label', 'type', 'speed', 'mark_connected', 'description']), + ComponentBulkEditForm +): mark_connected = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect ) - class Meta: - nullable_fields = ['label', 'type', 'feed_leg', 'power_port', 'description'] + model = ConsolePort + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + ) + nullable_fields = ('module', 'label', 'description') + + +class ConsoleServerPortBulkEditForm( + form_from_model(ConsoleServerPort, ['label', 'type', 'speed', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = ConsoleServerPort + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'description', 'mark_connected')), + ) + nullable_fields = ('module', 'label', 'description') + + +class PowerPortBulkEditForm( + form_from_model(PowerPort, ['label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = PowerPort + fieldsets = ( + (None, ('module', 'type', 'label', 'description', 'mark_connected')), + ('Power', ('maximum_draw', 'allocated_draw')), + ) + nullable_fields = ('module', 'label', 'description') + + +class PowerOutletBulkEditForm( + form_from_model(PowerOutlet, ['label', 'type', 'feed_leg', 'power_port', 'mark_connected', 'description']), + ComponentBulkEditForm +): + mark_connected = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect + ) + + model = PowerOutlet + fieldsets = ( + (None, ('module', 'type', 'label', 'description', 'mark_connected')), + ('Power', ('feed_leg', 'power_port')), + ) + nullable_fields = ('module', 'label', 'type', 'feed_leg', 'power_port', 'description') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -954,22 +1027,12 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', - 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', + 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', + 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', + 'tx_power', ]), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=Interface.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) enabled = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect @@ -987,7 +1050,13 @@ class InterfaceBulkEditForm( required=False, query_params={ 'type': 'lag', - } + }, + label='LAG' + ) + speed = forms.IntegerField( + required=False, + widget=SelectSpeedWidget(), + label='Speed' ) mgmt_only = forms.NullBooleanField( required=False, @@ -1006,12 +1075,26 @@ class InterfaceBulkEditForm( queryset=VLAN.objects.all(), required=False ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) - class Meta: - nullable_fields = [ - 'label', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel', - 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', - ] + model = Interface + fieldsets = ( + (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), + ) + nullable_fields = ( + 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', + 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan', 'tagged_vlans', + 'vrf', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1075,59 +1158,83 @@ class InterfaceBulkEditForm( class FrontPortBulkEditForm( form_from_model(FrontPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput() + model = FrontPort + fieldsets = ( + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) - - class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('module', 'label', 'description') class RearPortBulkEditForm( form_from_model(RearPort, ['label', 'type', 'color', 'mark_connected', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + ComponentBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=RearPort.objects.all(), - widget=forms.MultipleHiddenInput() + model = RearPort + fieldsets = ( + (None, ('module', 'type', 'label', 'color', 'description', 'mark_connected')), ) + nullable_fields = ('module', 'label', 'description') - class Meta: - nullable_fields = ['label', 'description'] + +class ModuleBayBulkEditForm( + form_from_model(ModuleBay, ['label', 'position', 'description']), + NetBoxModelBulkEditForm +): + model = ModuleBay + fieldsets = ( + (None, ('label', 'position', 'description')), + ) + nullable_fields = ('label', 'position', 'description') class DeviceBayBulkEditForm( form_from_model(DeviceBay, ['label', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() + model = DeviceBay + fieldsets = ( + (None, ('label', 'description')), ) - - class Meta: - nullable_fields = ['label', 'description'] + nullable_fields = ('label', 'description') class InventoryItemBulkEditForm( - form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), - AddRemoveTagsForm, - CustomFieldModelBulkEditForm + form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), + NetBoxModelBulkEditForm ): - pk = forms.ModelMultipleChoiceField( - queryset=InventoryItem.objects.all(), - widget=forms.MultipleHiddenInput() + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) - class Meta: - nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] + model = InventoryItem + fieldsets = ( + (None, ('label', 'role', 'manufacturer', 'part_id', 'description')), + ) + nullable_fields = ('label', 'role', 'manufacturer', 'part_id', 'description') + + +# +# Device component roles +# + +class InventoryItemRoleBulkEditForm(NetBoxModelBulkEditForm): + color = ColorField( + required=False + ) + description = forms.CharField( + max_length=200, + required=False + ) + + model = InventoryItemRole + fieldsets = ( + (None, ('color', 'description')), + ) + nullable_fields = ('color', 'description') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 59b59bb00..d9c738cc2 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -7,7 +7,8 @@ from django.utils.safestring import mark_safe from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelCSVForm +from ipam.models import VRF +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -24,8 +25,11 @@ __all__ = ( 'FrontPortCSVForm', 'InterfaceCSVForm', 'InventoryItemCSVForm', + 'InventoryItemRoleCSVForm', 'LocationCSVForm', 'ManufacturerCSVForm', + 'ModuleCSVForm', + 'ModuleBayCSVForm', 'PlatformCSVForm', 'PowerFeedCSVForm', 'PowerOutletCSVForm', @@ -42,7 +46,7 @@ __all__ = ( ) -class RegionCSVForm(CustomFieldModelCSVForm): +class RegionCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=Region.objects.all(), required=False, @@ -55,7 +59,7 @@ class RegionCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteGroupCSVForm(CustomFieldModelCSVForm): +class SiteGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -68,7 +72,7 @@ class SiteGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class SiteCSVForm(CustomFieldModelCSVForm): +class SiteCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=SiteStatusChoices, help_text='Operational status' @@ -96,8 +100,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): model = Site fields = ( 'name', 'slug', 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', 'comments', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', ) help_texts = { 'time_zone': mark_safe( @@ -106,7 +109,7 @@ class SiteCSVForm(CustomFieldModelCSVForm): } -class LocationCSVForm(CustomFieldModelCSVForm): +class LocationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -133,7 +136,7 @@ class LocationCSVForm(CustomFieldModelCSVForm): fields = ('site', 'parent', 'name', 'slug', 'tenant', 'description') -class RackRoleCSVForm(CustomFieldModelCSVForm): +class RackRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -144,7 +147,7 @@ class RackRoleCSVForm(CustomFieldModelCSVForm): } -class RackCSVForm(CustomFieldModelCSVForm): +class RackCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name' @@ -202,7 +205,7 @@ class RackCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class RackReservationCSVForm(CustomFieldModelCSVForm): +class RackReservationCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -252,14 +255,14 @@ class RackReservationCSVForm(CustomFieldModelCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) -class ManufacturerCSVForm(CustomFieldModelCSVForm): +class ManufacturerCSVForm(NetBoxModelCSVForm): class Meta: model = Manufacturer fields = ('name', 'slug', 'description') -class DeviceRoleCSVForm(CustomFieldModelCSVForm): +class DeviceRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -270,7 +273,7 @@ class DeviceRoleCSVForm(CustomFieldModelCSVForm): } -class PlatformCSVForm(CustomFieldModelCSVForm): +class PlatformCSVForm(NetBoxModelCSVForm): slug = SlugField() manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), @@ -284,7 +287,7 @@ class PlatformCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'manufacturer', 'napalm_driver', 'napalm_args', 'description') -class BaseDeviceCSVForm(CustomFieldModelCSVForm): +class BaseDeviceCSVForm(NetBoxModelCSVForm): device_role = CSVModelChoiceField( queryset=DeviceRole.objects.all(), to_field_name='name', @@ -400,6 +403,35 @@ class DeviceCSVForm(BaseDeviceCSVForm): self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params) +class ModuleCSVForm(NetBoxModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + module_bay = CSVModelChoiceField( + queryset=ModuleBay.objects.all(), + to_field_name='name' + ) + module_type = CSVModelChoiceField( + queryset=ModuleType.objects.all(), + to_field_name='model' + ) + + class Meta: + model = Module + fields = ( + 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', + ) + + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit module_bay queryset by assigned device + params = {f"device__{self.fields['device'].to_field_name}": data.get('device')} + self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params) + + class ChildDeviceCSVForm(BaseDeviceCSVForm): parent = CSVModelChoiceField( queryset=Device.objects.all(), @@ -446,7 +478,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): # Device components # -class ConsolePortCSVForm(CustomFieldModelCSVForm): +class ConsolePortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -469,7 +501,7 @@ class ConsolePortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): +class ConsoleServerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -492,7 +524,7 @@ class ConsoleServerPortCSVForm(CustomFieldModelCSVForm): fields = ('device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description') -class PowerPortCSVForm(CustomFieldModelCSVForm): +class PowerPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -510,7 +542,7 @@ class PowerPortCSVForm(CustomFieldModelCSVForm): ) -class PowerOutletCSVForm(CustomFieldModelCSVForm): +class PowerOutletCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -559,7 +591,7 @@ class PowerOutletCSVForm(CustomFieldModelCSVForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class InterfaceCSVForm(CustomFieldModelCSVForm): +class InterfaceCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -586,11 +618,21 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): choices=InterfaceTypeChoices, help_text='Physical medium' ) + duplex = CSVChoiceField( + choices=InterfaceDuplexChoices, + required=False + ) mode = CSVChoiceField( choices=InterfaceModeChoices, required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Assigned VRF' + ) rf_role = CSVChoiceField( choices=WirelessRoleChoices, required=False, @@ -600,8 +642,8 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): class Meta: model = Interface fields = ( - 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', - 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'mark_connected', 'mac_address', + 'wwn', 'mtu', 'mgmt_only', 'description', 'mode', 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', ) @@ -626,7 +668,7 @@ class InterfaceCSVForm(CustomFieldModelCSVForm): return self.cleaned_data['enabled'] -class FrontPortCSVForm(CustomFieldModelCSVForm): +class FrontPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -674,7 +716,7 @@ class FrontPortCSVForm(CustomFieldModelCSVForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class RearPortCSVForm(CustomFieldModelCSVForm): +class RearPortCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -692,7 +734,18 @@ class RearPortCSVForm(CustomFieldModelCSVForm): } -class DeviceBayCSVForm(CustomFieldModelCSVForm): +class ModuleBayCSVForm(NetBoxModelCSVForm): + device = CSVModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleBay + fields = ('device', 'name', 'label', 'position', 'description') + + +class DeviceBayCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' @@ -738,11 +791,16 @@ class DeviceBayCSVForm(CustomFieldModelCSVForm): self.fields['installed_device'].queryset = Interface.objects.none() -class InventoryItemCSVForm(CustomFieldModelCSVForm): +class InventoryItemCSVForm(NetBoxModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name' ) + role = CSVModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', @@ -758,7 +816,8 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): class Meta: model = InventoryItem fields = ( - 'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', + 'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'description', ) def __init__(self, *args, **kwargs): @@ -777,7 +836,26 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): self.fields['parent'].queryset = InventoryItem.objects.none() -class CableCSVForm(CustomFieldModelCSVForm): +# +# Device component roles +# + +class InventoryItemRoleCSVForm(NetBoxModelCSVForm): + slug = SlugField() + + class Meta: + model = InventoryItemRole + fields = ('name', 'slug', 'color', 'description') + help_texts = { + 'color': mark_safe('RGB color in hexadecimal (e.g. 00ff00)'), + } + + +# +# Cables +# + +class CableCSVForm(NetBoxModelCSVForm): # Termination A side_a_device = CSVModelChoiceField( queryset=Device.objects.all(), @@ -878,7 +956,11 @@ class CableCSVForm(CustomFieldModelCSVForm): return length_unit if length_unit is not None else '' -class VirtualChassisCSVForm(CustomFieldModelCSVForm): +# +# Virtual chassis +# + +class VirtualChassisCSVForm(NetBoxModelCSVForm): master = CSVModelChoiceField( queryset=Device.objects.all(), to_field_name='name', @@ -891,7 +973,11 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm): fields = ('name', 'domain', 'master') -class PowerPanelCSVForm(CustomFieldModelCSVForm): +# +# Power +# + +class PowerPanelCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', @@ -917,7 +1003,7 @@ class PowerPanelCSVForm(CustomFieldModelCSVForm): self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) -class PowerFeedCSVForm(CustomFieldModelCSVForm): +class PowerFeedCSVForm(NetBoxModelCSVForm): site = CSVModelChoiceField( queryset=Site.objects.all(), to_field_name='name', diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py index 6a7a09023..1ba7adf84 100644 --- a/netbox/dcim/forms/connections.py +++ b/netbox/dcim/forms/connections.py @@ -1,7 +1,7 @@ from circuits.models import Circuit, CircuitTermination, Provider from dcim.models import * -from extras.forms import CustomFieldModelForm from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, StaticSelect @@ -18,7 +18,7 @@ __all__ = ( ) -class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToDeviceForm(TenancyForm, NetBoxModelForm): """ Base form for connecting a Cable to a Device component """ @@ -70,10 +70,6 @@ class ConnectCableToDeviceForm(TenancyForm, CustomFieldModelForm): 'rack_id': '$termination_b_rack', } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Cable @@ -171,7 +167,7 @@ class ConnectCableToRearPortForm(ConnectCableToDeviceForm): ) -class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToCircuitTerminationForm(TenancyForm, NetBoxModelForm): termination_b_provider = DynamicModelChoiceField( queryset=Provider.objects.all(), label='Provider', @@ -212,10 +208,6 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): 'circuit_id': '$termination_b_circuit' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta(ConnectCableToDeviceForm.Meta): fields = [ @@ -229,7 +221,7 @@ class ConnectCableToCircuitTerminationForm(TenancyForm, CustomFieldModelForm): return getattr(self.cleaned_data['termination_b_id'], 'pk', None) -class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): +class ConnectCableToPowerFeedForm(TenancyForm, NetBoxModelForm): termination_b_region = DynamicModelChoiceField( queryset=Region.objects.all(), label='Region', @@ -274,10 +266,6 @@ class ConnectCableToPowerFeedForm(TenancyForm, CustomFieldModelForm): 'power_panel_id': '$termination_b_powerpanel' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta(ConnectCableToDeviceForm.Meta): fields = [ diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 91d83ae53..d5335947a 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -5,13 +5,13 @@ from django.utils.translation import gettext as _ from dcim.choices import * from dcim.constants import * from dcim.models import * -from tenancy.models import * -from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm -from ipam.models import ASN +from extras.forms import LocalConfigContextFilterForm +from ipam.models import ASN, VRF +from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import ( - APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, MultipleChoiceField, + StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -28,8 +28,13 @@ __all__ = ( 'InterfaceConnectionFilterForm', 'InterfaceFilterForm', 'InventoryItemFilterForm', + 'InventoryItemRoleFilterForm', 'LocationFilterForm', 'ManufacturerFilterForm', + 'ModuleFilterForm', + 'ModuleFilterForm', + 'ModuleBayFilterForm', + 'ModuleTypeFilterForm', 'PlatformFilterForm', 'PowerConnectionFilterForm', 'PowerFeedFilterForm', @@ -48,7 +53,7 @@ __all__ = ( ) -class DeviceComponentFilterForm(CustomFieldModelFilterForm): +class DeviceComponentFilterForm(NetBoxModelFilterSetForm): name = forms.CharField( required=False ) @@ -99,13 +104,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm): ) -class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Region - field_groups = [ - ['q', 'tag'], - ['parent_id'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag', 'parent_id')), + ('Contacts', ('contact', 'contact_role')) + ) parent_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -114,13 +118,12 @@ class RegionFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class SiteGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = SiteGroup - field_groups = [ - ['q', 'tag'], - ['parent_id'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag', 'parent_id')), + ('Contacts', ('contact', 'contact_role')) + ) parent_id = DynamicModelMultipleChoiceField( queryset=SiteGroup.objects.all(), required=False, @@ -129,19 +132,17 @@ class SiteGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Site - field_groups = [ - ['q', 'tag'], - ['status', 'region_id', 'group_id'], - ['tenant_group_id', 'tenant_id'], - ['asn_id'], - ['contact', 'contact_role'], - ] - status = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('status', 'region_id', 'group_id', 'asn_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) + status = MultipleChoiceField( choices=SiteStatusChoices, - required=False, - widget=StaticSelectMultiple(), + required=False ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -161,14 +162,14 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModel tag = TagFilterField(model) -class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Location - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'parent_id'], - ['tenant_group_id', 'tenant_id'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Parent', ('region_id', 'site_group_id', 'site_id', 'parent_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -200,21 +201,21 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldM tag = TagFilterField(model) -class RackRoleFilterForm(CustomFieldModelFilterForm): +class RackRoleFilterForm(NetBoxModelFilterSetForm): model = RackRole tag = TagFilterField(model) -class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Rack - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_id', 'location_id'], - ['status', 'role_id'], - ['type', 'width', 'serial', 'asset_tag'], - ['tenant_group_id', 'tenant_id'], - ['contact', 'contact_role'] - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_id', 'location_id')), + ('Function', ('status', 'role_id')), + ('Hardware', ('type', 'width', 'serial', 'asset_tag')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -237,20 +238,17 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModel }, label=_('Location') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=RackStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=RackTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - width = forms.MultipleChoiceField( + width = MultipleChoiceField( choices=RackWidthChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) role_id = DynamicModelMultipleChoiceField( queryset=RackRole.objects.all(), @@ -279,14 +277,14 @@ class RackElevationFilterForm(RackFilterForm): ) -class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = RackReservation - field_groups = [ - ['q', 'tag'], - ['user_id'], - ['region_id', 'site_id', 'location_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('User', ('user_id',)), + ('Rack', ('region_id', 'site_id', 'location_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -317,36 +315,40 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ManufacturerFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class ManufacturerFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Manufacturer - field_groups = [ - ['q', 'tag'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Contacts', ('contact', 'contact_role')) + ) tag = TagFilterField(model) -class DeviceTypeFilterForm(CustomFieldModelFilterForm): +class DeviceTypeFilterForm(NetBoxModelFilterSetForm): model = DeviceType - field_groups = [ - ['q', 'tag'], - ['manufacturer_id', 'subdevice_role', 'airflow'], - ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', + )), + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, label=_('Manufacturer') ) - subdevice_role = forms.MultipleChoiceField( - choices=add_blank_choice(SubdeviceRoleChoices), - required=False, - widget=StaticSelectMultiple() + part_number = forms.CharField( + required=False ) - airflow = forms.MultipleChoiceField( + subdevice_role = MultipleChoiceField( + choices=add_blank_choice(SubdeviceRoleChoices), + required=False + ) + airflow = MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), - required=False, - widget=StaticSelectMultiple() + required=False ) console_ports = forms.NullBooleanField( required=False, @@ -393,12 +395,76 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceRoleFilterForm(CustomFieldModelFilterForm): +class ModuleTypeFilterForm(NetBoxModelFilterSetForm): + model = ModuleType + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'part_number')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', + 'pass_through_ports', + )), + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + part_number = forms.CharField( + required=False + ) + console_ports = forms.NullBooleanField( + required=False, + label='Has console ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + console_server_ports = forms.NullBooleanField( + required=False, + label='Has console server ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_ports = forms.NullBooleanField( + required=False, + label='Has power ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + power_outlets = forms.NullBooleanField( + required=False, + label='Has power outlets', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + interfaces = forms.NullBooleanField( + required=False, + label='Has interfaces', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + pass_through_ports = forms.NullBooleanField( + required=False, + label='Has pass-through ports', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + tag = TagFilterField(model) + + +class DeviceRoleFilterForm(NetBoxModelFilterSetForm): model = DeviceRole tag = TagFilterField(model) -class PlatformFilterForm(CustomFieldModelFilterForm): +class PlatformFilterForm(NetBoxModelFilterSetForm): model = Platform manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), @@ -408,20 +474,25 @@ class PlatformFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class DeviceFilterForm( + LocalConfigContextFilterForm, + TenancyFilterForm, + ContactModelFilterForm, + NetBoxModelFilterSetForm +): model = Device - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id'], - ['status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address'], - ['manufacturer_id', 'device_type_id', 'platform_id'], - ['tenant_group_id', 'tenant_id'], - [ - 'has_primary_ip', 'virtual_chassis_member', 'console_ports', 'console_server_ports', 'power_ports', - 'power_outlets', 'interfaces', 'pass_through_ports', 'local_context_data', - ], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Operation', ('status', 'role_id', 'airflow', 'serial', 'asset_tag', 'mac_address')), + ('Hardware', ('manufacturer_id', 'device_type_id', 'platform_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ('Components', ( + 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', + )), + ('Miscellaneous', ('has_primary_ip', 'virtual_chassis_member', 'local_context_data')) + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -484,15 +555,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactM null_option='None', label=_('Platform') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=DeviceStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) - airflow = forms.MultipleChoiceField( + airflow = MultipleChoiceField( choices=add_blank_choice(DeviceAirflowChoices), - required=False, - widget=StaticSelectMultiple() + required=False ) serial = forms.CharField( required=False @@ -563,13 +632,43 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactM tag = TagFilterField(model) -class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxModelFilterSetForm): + model = Module + fieldsets = ( + (None, ('q', 'tag')), + ('Hardware', ('manufacturer_id', 'module_type_id', 'serial', 'asset_tag')), + ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer'), + fetch_trigger='open' + ) + module_type_id = DynamicModelMultipleChoiceField( + queryset=ModuleType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Type'), + fetch_trigger='open' + ) + serial = forms.CharField( + required=False + ) + asset_tag = forms.CharField( + required=False + ) + tag = TagFilterField(model) + + +class VirtualChassisFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = VirtualChassis - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -592,14 +691,14 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): +class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): model = Cable - field_groups = [ - ['q', 'tag'], - ['site_id', 'rack_id', 'device_id'], - ['type', 'status', 'color', 'length', 'length_unit'], - ['tenant_group_id', 'tenant_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('site_id', 'rack_id', 'device_id')), + ('Attributes', ('type', 'status', 'color', 'length', 'length_unit')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -632,15 +731,13 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): }, label=_('Device') ) - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=add_blank_choice(CableTypeChoices), - required=False, - widget=StaticSelect() + required=False ) - status = forms.ChoiceField( + status = MultipleChoiceField( required=False, - choices=add_blank_choice(LinkStatusChoices), - widget=StaticSelect() + choices=add_blank_choice(LinkStatusChoices) ) color = ColorField( required=False @@ -655,12 +752,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = PowerPanel - field_groups = ( - ('q', 'tag'), - ('region_id', 'site_group_id', 'site_id', 'location_id'), - ('contact', 'contact_role') + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id')), + ('Contacts', ('contact', 'contact_role')), ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), @@ -693,14 +790,13 @@ class PowerPanelFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class PowerFeedFilterForm(CustomFieldModelFilterForm): +class PowerFeedFilterForm(NetBoxModelFilterSetForm): model = PowerFeed - field_groups = [ - ['q', 'tag'], - ['region_id', 'site_group_id', 'site_id'], - ['power_panel_id', 'rack_id'], - ['status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')), + ('Attributes', ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')), + ) region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -737,10 +833,9 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): }, label=_('Rack') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=PowerFeedStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) type = forms.ChoiceField( choices=add_blank_choice(PowerFeedTypeChoices), @@ -775,91 +870,93 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm): class ConsolePortFilterForm(DeviceComponentFilterForm): model = ConsolePort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - speed = forms.MultipleChoiceField( + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class ConsoleServerPortFilterForm(DeviceComponentFilterForm): model = ConsoleServerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'speed'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - speed = forms.MultipleChoiceField( + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( choices=ConsolePortSpeedChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class PowerPortFilterForm(DeviceComponentFilterForm): model = PowerPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PowerPortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class PowerOutletFilterForm(DeviceComponentFilterForm): model = PowerOutlet - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PowerOutletTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) tag = TagFilterField(model) class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], - ['rf_role', 'rf_channel', 'rf_channel_width', 'tx_power'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - kind = forms.MultipleChoiceField( - choices=InterfaceKindChoices, - required=False, - widget=StaticSelectMultiple() + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only')), + ('Addressing', ('vrf_id', 'mac_address', 'wwn')), + ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), ) - type = forms.MultipleChoiceField( + kind = MultipleChoiceField( + choices=InterfaceKindChoices, + required=False + ) + type = MultipleChoiceField( choices=InterfaceTypeChoices, + required=False + ) + speed = forms.IntegerField( required=False, - widget=StaticSelectMultiple() + label='Select Speed', + widget=SelectSpeedWidget(attrs={'readonly': None}) + ) + duplex = MultipleChoiceField( + choices=InterfaceDuplexChoices, + required=False ) enabled = forms.NullBooleanField( required=False, @@ -881,16 +978,14 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, label='WWN' ) - rf_role = forms.MultipleChoiceField( + rf_role = MultipleChoiceField( choices=WirelessRoleChoices, required=False, - widget=StaticSelectMultiple(), label='Wireless role' ) - rf_channel = forms.MultipleChoiceField( + rf_channel = MultipleChoiceField( choices=WirelessChannelChoices, required=False, - widget=StaticSelectMultiple(), label='Wireless channel' ) rf_channel_frequency = forms.IntegerField( @@ -907,20 +1002,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm): min_value=0, max_value=127 ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) class FrontPortFilterForm(DeviceComponentFilterForm): - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) model = FrontPort - type = forms.MultipleChoiceField( + type = MultipleChoiceField( choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) color = ColorField( required=False @@ -930,15 +1029,14 @@ class FrontPortFilterForm(DeviceComponentFilterForm): class RearPortFilterForm(DeviceComponentFilterForm): model = RearPort - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'type', 'color'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] - type = forms.MultipleChoiceField( + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'color')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + type = MultipleChoiceField( choices=PortTypeChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) color = ColorField( required=False @@ -946,23 +1044,42 @@ class RearPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +class ModuleBayFilterForm(DeviceComponentFilterForm): + model = ModuleBay + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'position')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + tag = TagFilterField(model) + position = forms.CharField( + required=False + ) + + class DeviceBayFilterForm(DeviceComponentFilterForm): model = DeviceBay - field_groups = [ - ['q', 'tag'], - ['name', 'label'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) tag = TagFilterField(model) class InventoryItemFilterForm(DeviceComponentFilterForm): model = InventoryItem - field_groups = [ - ['q', 'tag'], - ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], - ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ) + role_id = DynamicModelMultipleChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False, + label=_('Role'), + fetch_trigger='open' + ) manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -983,6 +1100,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) +# +# Device component roles +# + +class InventoryItemRoleFilterForm(NetBoxModelFilterSetForm): + model = InventoryItemRole + tag = TagFilterField(model) + + # # Connections # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index a3d6fba3d..57e2fa820 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -7,14 +7,13 @@ from timezone_field import TimeZoneFormField from dcim.choices import * from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm -from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup, ASN +from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( - APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, - SlugField, StaticSelect, + APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, + DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, + SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -37,8 +36,14 @@ __all__ = ( 'InterfaceForm', 'InterfaceTemplateForm', 'InventoryItemForm', + 'InventoryItemRoleForm', + 'InventoryItemTemplateForm', 'LocationForm', 'ManufacturerForm', + 'ModuleForm', + 'ModuleBayForm', + 'ModuleBayTemplateForm', + 'ModuleTypeForm', 'PlatformForm', 'PopulateDeviceBayForm', 'PowerFeedForm', @@ -66,16 +71,12 @@ Tagged (All): Implies all VLANs are available (w/optional untagged VLAN) """ -class RegionForm(CustomFieldModelForm): +class RegionForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=Region.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Region @@ -84,16 +85,12 @@ class RegionForm(CustomFieldModelForm): ) -class SiteGroupForm(CustomFieldModelForm): +class SiteGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=SiteGroup.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = SiteGroup @@ -102,7 +99,7 @@ class SiteGroupForm(CustomFieldModelForm): ) -class SiteForm(TenancyForm, CustomFieldModelForm): +class SiteForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False @@ -123,28 +120,20 @@ class SiteForm(TenancyForm, CustomFieldModelForm): widget=StaticSelect() ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Site', ( + 'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), + ('Contact Info', ('physical_address', 'shipping_address', 'latitude', 'longitude')), ) class Meta: model = Site - fields = [ - 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asn', 'asns', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'comments', 'tags', - ] - fieldsets = ( - ('Site', ( - 'name', 'slug', 'status', 'region', 'group', 'facility', 'asn', 'asns', 'time_zone', 'description', - 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ('Contact Info', ( - 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', - 'contact_email', - )), + fields = ( + 'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone', + 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', ) widgets = { 'physical_address': SmallTextarea( @@ -162,7 +151,6 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } help_texts = { 'name': "Full name of the site", - 'asn': "BGP autonomous system number. This field is depreciated in favour of the ASN model", 'facility': "Data center provider and facility (e.g. Equinix NY7)", 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", @@ -173,7 +161,7 @@ class SiteForm(TenancyForm, CustomFieldModelForm): } -class LocationForm(TenancyForm, CustomFieldModelForm): +class LocationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -203,9 +191,12 @@ class LocationForm(TenancyForm, CustomFieldModelForm): } ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Location', ( + 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', + )), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -213,20 +204,10 @@ class LocationForm(TenancyForm, CustomFieldModelForm): fields = ( 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tenant_group', 'tenant', 'tags', ) - fieldsets = ( - ('Location', ( - 'region', 'site_group', 'site', 'parent', 'name', 'slug', 'description', 'tags', - )), - ('Tenancy', ('tenant_group', 'tenant')), - ) -class RackRoleForm(CustomFieldModelForm): +class RackRoleForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = RackRole @@ -235,7 +216,7 @@ class RackRoleForm(CustomFieldModelForm): ] -class RackForm(TenancyForm, CustomFieldModelForm): +class RackForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -269,10 +250,6 @@ class RackForm(TenancyForm, CustomFieldModelForm): required=False ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Rack @@ -295,7 +272,7 @@ class RackForm(TenancyForm, CustomFieldModelForm): } -class RackReservationForm(TenancyForm, CustomFieldModelForm): +class RackReservationForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -342,9 +319,10 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): ), widget=StaticSelect() ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -353,18 +331,10 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm): 'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'tags', ] - fieldsets = ( - ('Reservation', ('region', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) -class ManufacturerForm(CustomFieldModelForm): +class ManufacturerForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Manufacturer @@ -373,7 +343,7 @@ class ManufacturerForm(CustomFieldModelForm): ] -class DeviceTypeForm(CustomFieldModelForm): +class DeviceTypeForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all() ) @@ -381,9 +351,15 @@ class DeviceTypeForm(CustomFieldModelForm): slug_source='model' ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Device Type', ( + 'manufacturer', 'model', 'slug', 'part_number', 'tags', + )), + ('Chassis', ( + 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', + )), + ('Images', ('front_image', 'rear_image')), ) class Meta: @@ -392,15 +368,6 @@ class DeviceTypeForm(CustomFieldModelForm): 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', 'front_image', 'rear_image', 'comments', 'tags', ] - fieldsets = ( - ('Device Type', ( - 'manufacturer', 'model', 'slug', 'part_number', 'tags', - )), - ('Chassis', ( - 'u_height', 'is_full_depth', 'subdevice_role', 'airflow', - )), - ('Images', ('front_image', 'rear_image')), - ) widgets = { 'subdevice_role': StaticSelect(), 'front_image': ClearableFileInput(attrs={ @@ -412,12 +379,21 @@ class DeviceTypeForm(CustomFieldModelForm): } -class DeviceRoleForm(CustomFieldModelForm): - slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ModuleTypeForm(NetBoxModelForm): + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all() ) + comments = CommentField() + + class Meta: + model = ModuleType + fields = [ + 'manufacturer', 'model', 'part_number', 'comments', 'tags', + ] + + +class DeviceRoleForm(NetBoxModelForm): + slug = SlugField() class Meta: model = DeviceRole @@ -426,7 +402,7 @@ class DeviceRoleForm(CustomFieldModelForm): ] -class PlatformForm(CustomFieldModelForm): +class PlatformForm(NetBoxModelForm): manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -434,10 +410,6 @@ class PlatformForm(CustomFieldModelForm): slug = SlugField( max_length=64 ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Platform @@ -449,7 +421,7 @@ class PlatformForm(CustomFieldModelForm): } -class DeviceForm(TenancyForm, CustomFieldModelForm): +class DeviceForm(TenancyForm, NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -543,10 +515,6 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): required=False, label='' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = Device @@ -626,11 +594,63 @@ class DeviceForm(TenancyForm, CustomFieldModelForm): self.fields['position'].widget.choices = [(position, f'U{position}')] -class CableForm(TenancyForm, CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ModuleForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + initial_params={ + 'modulebays': '$module_bay' + } ) + module_bay = DynamicModelChoiceField( + queryset=ModuleBay.objects.all(), + query_params={ + 'device_id': '$device' + } + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + initial_params={ + 'module_types': '$module_type' + } + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + query_params={ + 'manufacturer_id': '$manufacturer' + } + ) + comments = CommentField() + replicate_components = forms.BooleanField( + required=False, + initial=True, + help_text="Automatically populate components associated with this module type" + ) + + class Meta: + model = Module + fields = [ + 'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', + 'replicate_components', 'comments', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance.pk: + self.fields['replicate_components'].initial = False + self.fields['replicate_components'].disabled = True + + def save(self, *args, **kwargs): + + # If replicate_components is False, disable automatic component replication on the instance + if self.instance.pk or not self.cleaned_data['replicate_components']: + self.instance._disable_replication = True + + return super().save(*args, **kwargs) + + +class CableForm(TenancyForm, NetBoxModelForm): class Meta: model = Cable @@ -649,7 +669,7 @@ class CableForm(TenancyForm, CustomFieldModelForm): } -class PowerPanelForm(CustomFieldModelForm): +class PowerPanelForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -678,9 +698,9 @@ class PowerPanelForm(CustomFieldModelForm): 'site_id': '$site' } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), ) class Meta: @@ -688,12 +708,9 @@ class PowerPanelForm(CustomFieldModelForm): fields = [ 'region', 'site_group', 'site', 'location', 'name', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'tags')), - ) -class PowerFeedForm(CustomFieldModelForm): +class PowerFeedForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -733,9 +750,11 @@ class PowerFeedForm(CustomFieldModelForm): } ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Power Panel', ('region', 'site', 'power_panel')), + ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), + ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ) class Meta: @@ -744,11 +763,6 @@ class PowerFeedForm(CustomFieldModelForm): 'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags', ] - fieldsets = ( - ('Power Panel', ('region', 'site', 'power_panel')), - ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')), - ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), - ) widgets = { 'status': StaticSelect(), 'type': StaticSelect(), @@ -761,15 +775,11 @@ class PowerFeedForm(CustomFieldModelForm): # Virtual chassis # -class VirtualChassisForm(CustomFieldModelForm): +class VirtualChassisForm(NetBoxModelForm): master = forms.ModelChoiceField( queryset=Device.objects.all(), required=False, ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = VirtualChassis @@ -885,10 +895,12 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -896,10 +908,12 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect, } @@ -907,78 +921,96 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), } class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + power_port = DynamicModelChoiceField( + queryset=PowerPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): + rear_port = DynamicModelChoiceField( + queryset=RearPortTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type', + } + ) + class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', ] widgets = { 'device_type': forms.HiddenInput(), - 'rear_port': StaticSelect(), + 'module_type': forms.HiddenInput(), + 'type': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit rear_port choices to current DeviceType - if hasattr(self.instance, 'device_type'): - self.fields['rear_port'].queryset = RearPortTemplate.objects.filter( - device_type=self.instance.device_type - ) - class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'color', 'positions', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { 'device_type': forms.HiddenInput(), + 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } +class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'position', 'description', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate @@ -990,104 +1022,171 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } +class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): + parent = DynamicModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False, + query_params={ + 'devicetype_id': '$device_type' + } + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + ] + widgets = { + 'device_type': forms.HiddenInput(), + } + + # # Device components # -class ConsolePortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ConsolePortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = ConsolePort fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'speed': StaticSelect(), } -class ConsoleServerPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class ConsoleServerPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = ConsoleServerPort fields = [ - 'device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'speed': StaticSelect(), } -class PowerPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class PowerPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = PowerPort fields = [ - 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', 'description', + 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), } -class PowerOutletForm(CustomFieldModelForm): - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False +class PowerOutletForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + power_port = DynamicModelChoiceField( + queryset=PowerPort.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = PowerOutlet fields = [ - 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', ] widgets = { 'device': forms.HiddenInput(), + 'type': StaticSelect(), + 'feed_leg': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit power_port choices to the local device - if hasattr(self.instance, 'device'): - self.fields['power_port'].queryset = PowerPort.objects.filter( - device=self.instance.device - ) - - -class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): +class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'device_id': '$device', + } ) bridge = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'device_id': '$device', + } ) lag = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, label='LAG interface', query_params={ + 'device_id': '$device', 'type': 'lag', } ) @@ -1115,6 +1214,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -1123,23 +1223,39 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_device': '$device', } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) + + fieldsets = ( + ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Addressing', ('vrf', 'mac_address', 'wwn')), + ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), + ('Related Interfaces', ('parent', 'bridge', 'lag')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + ('Wireless', ( + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', + )), ) class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', - 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags', + 'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge', 'lag', + 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', + 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', + 'vrf', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': SelectSpeedWidget(), + 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), @@ -1153,63 +1269,47 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device - - # Restrict parent/bridge/LAG interface assignment by device/VC - self.fields['parent'].widget.add_query_param('device_id', device.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.pk) - self.fields['lag'].widget.add_query_param('device_id', device.pk) - if device.virtual_chassis and device.virtual_chassis.master: - self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - - # Limit VLAN choices by device - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk) - - -class FrontPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class FrontPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } + ) + rear_port = DynamicModelChoiceField( + queryset=RearPort.objects.all(), + query_params={ + 'device_id': '$device', + } ) class Meta: model = FrontPort fields = [ - 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), - 'rear_port': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Limit RearPort choices to the local device - if hasattr(self.instance, 'device'): - self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter( - device=self.instance.device - ) - - -class RearPortForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False +class RearPortForm(NetBoxModelForm): + module = DynamicModelChoiceField( + queryset=Module.objects.all(), + required=False, + query_params={ + 'device_id': '$device', + } ) class Meta: model = RearPort fields = [ - 'device', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { 'device': forms.HiddenInput(), @@ -1217,11 +1317,19 @@ class RearPortForm(CustomFieldModelForm): } -class DeviceBayForm(CustomFieldModelForm): - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) +class ModuleBayForm(NetBoxModelForm): + + class Meta: + model = ModuleBay + fields = [ + 'device', 'name', 'label', 'position', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class DeviceBayForm(NetBoxModelForm): class Meta: model = DeviceBay @@ -1253,10 +1361,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(CustomFieldModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) +class InventoryItemForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -1264,18 +1369,50 @@ class InventoryItemForm(CustomFieldModelForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + fieldsets = ( + ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')), + ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')), ) class Meta: model = InventoryItem fields = [ - 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'tags', + 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', + 'description', 'component_type', 'component_id', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +# +# Device component roles +# + +class InventoryItemRoleForm(NetBoxModelForm): + slug = SlugField() + + class Meta: + model = InventoryItemRole + fields = [ + 'name', 'slug', 'color', 'description', 'tags', ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 92b92ef3e..8618a3b9d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -1,41 +1,25 @@ from django import forms -from dcim.choices import * -from dcim.constants import * from dcim.models import * -from extras.forms import CustomFieldModelForm, CustomFieldsMixin from extras.models import Tag -from ipam.models import VLAN +from netbox.forms import NetBoxModelForm from utilities.forms import ( - add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, StaticSelect, + BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, ) -from wireless.choices import * -from .common import InterfaceCommonForm __all__ = ( - 'ConsolePortCreateForm', - 'ConsolePortTemplateCreateForm', - 'ConsoleServerPortCreateForm', - 'ConsoleServerPortTemplateCreateForm', - 'DeviceBayCreateForm', - 'DeviceBayTemplateCreateForm', + 'ComponentTemplateCreateForm', + 'DeviceComponentCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', - 'InterfaceCreateForm', - 'InterfaceTemplateCreateForm', - 'InventoryItemCreateForm', - 'PowerOutletCreateForm', - 'PowerOutletTemplateCreateForm', - 'PowerPortCreateForm', - 'PowerPortTemplateCreateForm', - 'RearPortCreateForm', - 'RearPortTemplateCreateForm', + 'ModularComponentTemplateCreateForm', + 'ModuleBayCreateForm', + 'ModuleBayTemplateCreateForm', 'VirtualChassisCreateForm', ) -class ComponentForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(BootstrapMixin, forms.Form): """ Subclass this form when facilitating the creation of one or more device component or component templates based on a name pattern. @@ -52,18 +36,170 @@ class ComponentForm(BootstrapMixin, forms.Form): def clean(self): super().clean() - # Validate that the number of components being created from both the name_pattern and label_pattern are equal - if self.cleaned_data['label_pattern']: - name_pattern_count = len(self.cleaned_data['name_pattern']) - label_pattern_count = len(self.cleaned_data['label_pattern']) - if name_pattern_count != label_pattern_count: + # Validate that all patterned fields generate an equal number of values + patterned_fields = [ + field_name for field_name in self.fields if field_name.endswith('_pattern') + ] + pattern_count = len(self.cleaned_data['name_pattern']) + for field_name in patterned_fields: + value_count = len(self.cleaned_data[field_name]) + if self.cleaned_data[field_name] and value_count != pattern_count: raise forms.ValidationError({ - 'label_pattern': f'The provided name pattern will create {name_pattern_count} components, however ' - f'{label_pattern_count} labels will be generated. These counts must match.' + field_name: f'The provided pattern specifies {value_count} values, but {pattern_count} are ' + f'expected.' }, code='label_pattern_mismatch') -class VirtualChassisCreateForm(CustomFieldModelForm): +class ComponentTemplateCreateForm(ComponentCreateForm): + """ + Creation form for component templates that can be assigned only to a DeviceType. + """ + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + ) + field_order = ('device_type', 'name_pattern', 'label_pattern') + + +class ModularComponentTemplateCreateForm(ComponentCreateForm): + """ + Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType. + """ + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False + ) + field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern') + + +class DeviceComponentCreateForm(ComponentCreateForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + field_order = ('device', 'name_pattern', 'label_pattern') + + +class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # TODO: This needs better validation + if 'device_type' in self.initial or self.data.get('device_type'): + parent = DeviceType.objects.get( + pk=self.initial.get('device_type') or self.data.get('device_type') + ) + elif 'module_type' in self.initial or self.data.get('module_type'): + parent = ModuleType.objects.get( + pk=self.initial.get('module_type') or self.data.get('module_type') + ) + else: + return + + # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in parent.frontporttemplates.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = parent.rearporttemplates.all() + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class FrontPortCreateForm(DeviceComponentCreateForm): + rear_port_set = forms.MultipleChoiceField( + choices=[], + label='Rear ports', + help_text='Select one rear port assignment for each front port being created.', + ) + field_order = ( + 'device', 'name_pattern', 'label_pattern', 'rear_port_set', + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + device = Device.objects.get( + pk=self.initial.get('device') or self.data.get('device') + ) + + # Determine which rear port positions are occupied. These will be excluded from the list of available + # mappings. + occupied_port_positions = [ + (front_port.rear_port_id, front_port.rear_port_position) + for front_port in device.frontports.all() + ] + + # Populate rear port choices + choices = [] + rear_ports = RearPort.objects.filter(device=device) + for rear_port in rear_ports: + for i in range(1, rear_port.positions + 1): + if (rear_port.pk, i) not in occupied_port_positions: + choices.append( + ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) + ) + self.fields['rear_port_set'].choices = choices + + def get_iterative_data(self, iteration): + + # Assign rear port and position from selected set + rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + + return { + 'rear_port': int(rear_port), + 'rear_port_position': int(position), + } + + +class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) + field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern') + + +class ModuleBayCreateForm(DeviceComponentCreateForm): + position_pattern = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + ) + field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') + + +class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( queryset=Region.objects.all(), required=False, @@ -107,10 +243,6 @@ class VirtualChassisCreateForm(CustomFieldModelForm): required=False, help_text='Position of the first member device. Increases by one for each additional member.' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = VirtualChassis @@ -136,521 +268,3 @@ class VirtualChassisCreateForm(CustomFieldModelForm): member.save() return instance - - -# -# Component templates -# - -class ComponentTemplateCreateForm(ComponentForm): - """ - Base form for the creation of device component templates (subclassed from ComponentTemplateModel). - """ - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False, - initial_params={ - 'device_types': 'device_type' - } - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - query_params={ - 'manufacturer_id': '$manufacturer' - } - ) - description = forms.CharField( - required=False - ) - - -class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - widget=StaticSelect() - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description') - - -class PowerPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum power draw (watts)" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated power draw (watts)" - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', - 'description', - ) - - -class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False - ) - power_port = forms.ModelChoiceField( - queryset=PowerPortTemplate.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - widget=StaticSelect() - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', - 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port choices to current DeviceType - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - self.fields['power_port'].queryset = PowerPortTemplate.objects.filter( - device_type=device_type - ) - - -class InterfaceTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect() - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only' - ) - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description') - - -class FrontPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect() - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device_type = DeviceType.objects.get( - pk=self.initial.get('device_type') or self.data.get('device_type') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device_type.frontporttemplates.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPortTemplate.objects.filter(device_type=device_type) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortTemplateCreateForm(ComponentTemplateCreateForm): - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - initial=1, - help_text='The number of front ports which may be mapped to each rear port' - ) - field_order = ( - 'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description', - ) - - -class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm): - field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description') - - -# -# Device components -# - -class ComponentCreateForm(CustomFieldsMixin, ComponentForm): - """ - Base form for the creation of device components (models subclassed from ComponentModel). - """ - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - description = forms.CharField( - max_length=200, - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - - -class ConsolePortCreateForm(ComponentCreateForm): - model = ConsolePort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class ConsoleServerPortCreateForm(ComponentCreateForm): - model = ConsoleServerPort - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect() - ) - speed = forms.ChoiceField( - choices=add_blank_choice(ConsolePortSpeedChoices), - required=False, - widget=StaticSelect() - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags') - - -class PowerPortCreateForm(ComponentCreateForm): - model = PowerPort - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect() - ) - maximum_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Maximum draw in watts" - ) - allocated_draw = forms.IntegerField( - min_value=1, - required=False, - help_text="Allocated draw in watts" - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', 'tags', - ) - - -class PowerOutletCreateForm(ComponentCreateForm): - model = PowerOutlet - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False, - widget=StaticSelect() - ) - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False - ) - 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) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - - -class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm): - model = Interface - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect(), - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - bridge = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) - lag = DynamicModelChoiceField( - queryset=Interface.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - 'type': 'lag', - }, - label='LAG' - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - wwn = forms.CharField( - required=False, - label='WWN' - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only', - help_text='This interface is used only for out-of-band management' - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect() - ) - rf_role = forms.ChoiceField( - choices=add_blank_choice(WirelessRoleChoices), - required=False, - widget=StaticSelect(), - label='Wireless role' - ) - rf_channel = forms.ChoiceField( - choices=add_blank_choice(WirelessChannelChoices), - required=False, - widget=StaticSelect(), - label='Wireless channel' - ) - rf_channel_frequency = forms.DecimalField( - required=False, - label='Channel frequency (MHz)' - ) - rf_channel_width = forms.DecimalField( - required=False, - label='Channel width (MHz)' - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Untagged VLAN' - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False, - label='Tagged VLANs' - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address', - 'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency', - 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit VLAN choices by device - device_id = self.initial.get('device') or self.data.get('device') - self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id) - - -class FrontPortCreateForm(ComponentCreateForm): - model = FrontPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - rear_port_set = forms.MultipleChoiceField( - choices=[], - label='Rear ports', - help_text='Select one rear port assignment for each front port being created.', - ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description', - 'tags', - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) - - # Determine which rear port positions are occupied. These will be excluded from the list of available - # mappings. - occupied_port_positions = [ - (front_port.rear_port_id, front_port.rear_port_position) - for front_port in device.frontports.all() - ] - - # Populate rear port choices - choices = [] - rear_ports = RearPort.objects.filter(device=device) - for rear_port in rear_ports: - for i in range(1, rear_port.positions + 1): - if (rear_port.pk, i) not in occupied_port_positions: - choices.append( - ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) - ) - self.fields['rear_port_set'].choices = choices - - def clean(self): - super().clean() - - # Validate that the number of ports being created equals the number of selected (rear port, position) tuples - front_port_count = len(self.cleaned_data['name_pattern']) - rear_port_count = len(self.cleaned_data['rear_port_set']) - if front_port_count != rear_port_count: - raise forms.ValidationError({ - 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments ' - 'were selected. These counts must match.'.format(front_port_count, rear_port_count) - }) - - def get_iterative_data(self, iteration): - - # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') - - return { - 'rear_port': int(rear_port), - 'rear_port_position': int(position), - } - - -class RearPortCreateForm(ComponentCreateForm): - model = RearPort - type = forms.ChoiceField( - choices=PortTypeChoices, - widget=StaticSelect(), - ) - color = ColorField( - required=False - ) - positions = forms.IntegerField( - min_value=REARPORT_POSITIONS_MIN, - max_value=REARPORT_POSITIONS_MAX, - 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', 'color', 'positions', 'mark_connected', 'description', - 'tags', - ) - - -class DeviceBayCreateForm(ComponentCreateForm): - model = DeviceBay - field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags') - - -class InventoryItemCreateForm(ComponentCreateForm): - model = InventoryItem - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) - parent = DynamicModelChoiceField( - queryset=InventoryItem.objects.all(), - required=False, - query_params={ - 'device_id': '$device' - } - ) - part_id = forms.CharField( - max_length=50, - required=False, - label='Part ID' - ) - serial = forms.CharField( - max_length=50, - required=False, - ) - asset_tag = forms.CharField( - max_length=50, - required=False, - ) - field_order = ( - 'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', - 'description', 'tags', - ) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 03f040a00..afbcd6543 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,9 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'InventoryItemTemplateImportForm', + 'ModuleBayTemplateImportForm', + 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', 'PowerPortTemplateImportForm', 'RearPortTemplateImportForm', @@ -31,31 +34,23 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm): ] +class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name' + ) + + class Meta: + model = ModuleType + fields = ['manufacturer', 'model', 'part_number', 'comments'] + + # # Component template import forms # class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def __init__(self, device_type, data=None, *args, **kwargs): - - # Must pass the parent DeviceType on form initialization - data.update({ - 'device_type': device_type.pk, - }) - - super().__init__(data, *args, **kwargs) - - def clean_device_type(self): - - data = self.cleaned_data['device_type'] - - # Limit fields referencing other components to the parent DeviceType - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name != 'device_type': - field.queryset = field.queryset.filter(device_type=data) - - return data + pass class ConsolePortTemplateImportForm(ComponentTemplateImportForm): @@ -63,7 +58,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsolePortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -72,7 +67,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = ConsoleServerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] @@ -81,7 +76,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerPortTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] @@ -95,9 +90,23 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm): class Meta: model = PowerOutletTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(module_type=module_type) + + return module_type + class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( @@ -107,7 +116,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm): class Meta: model = InterfaceTemplate fields = [ - 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description', + 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', ] @@ -120,10 +129,24 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): to_field_name='name' ) + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(module_type=module_type) + + return module_type + class Meta: model = FrontPortTemplate fields = [ - 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description', ] @@ -135,7 +158,16 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm): class Meta: model = RearPortTemplate fields = [ - 'device_type', 'name', 'type', 'positions', 'label', 'description', + 'device_type', 'module_type', 'name', 'type', 'positions', 'label', 'description', + ] + + +class ModuleBayTemplateImportForm(ComponentTemplateImportForm): + + class Meta: + model = ModuleBayTemplate + fields = [ + 'device_type', 'name', 'label', 'position', 'description', ] @@ -146,3 +178,40 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm): fields = [ 'device_type', 'name', 'label', 'description', ] + + +class InventoryItemTemplateImportForm(ComponentTemplateImportForm): + parent = forms.ModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False + ) + role = forms.ModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + ] + + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(module_type=module_type) + + return module_type diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 13e0c20ec..1d5b6a580 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -50,12 +50,30 @@ class DCIMQuery(graphene.ObjectType): inventory_item = ObjectField(InventoryItemType) inventory_item_list = ObjectListField(InventoryItemType) + inventory_item_role = ObjectField(InventoryItemRoleType) + inventory_item_role_list = ObjectListField(InventoryItemRoleType) + + inventory_item_template = ObjectField(InventoryItemTemplateType) + inventory_item_template_list = ObjectListField(InventoryItemTemplateType) + location = ObjectField(LocationType) location_list = ObjectListField(LocationType) manufacturer = ObjectField(ManufacturerType) manufacturer_list = ObjectListField(ManufacturerType) + module = ObjectField(ModuleType) + module_list = ObjectListField(ModuleType) + + module_bay = ObjectField(ModuleBayType) + module_bay_list = ObjectListField(ModuleBayType) + + module_bay_template = ObjectField(ModuleBayTemplateType) + module_bay_template_list = ObjectListField(ModuleBayTemplateType) + + module_type = ObjectField(ModuleTypeType) + module_type_list = ObjectListField(ModuleTypeType) + platform = ObjectField(PlatformType) platform_list = ObjectListField(PlatformType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8ce10979e..d25a6bba6 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -6,7 +6,7 @@ from extras.graphql.mixins import ( ) from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from netbox.graphql.scalars import BigInt -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( 'CableType', @@ -25,8 +25,14 @@ __all__ = ( 'InterfaceType', 'InterfaceTemplateType', 'InventoryItemType', + 'InventoryItemRoleType', + 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', + 'ModuleType', + 'ModuleBayType', + 'ModuleBayTemplateType', + 'ModuleTypeType', 'PlatformType', 'PowerFeedType', 'PowerOutletType', @@ -79,7 +85,7 @@ class ComponentTemplateObjectType( # Model types # -class CableType(PrimaryObjectType): +class CableType(NetBoxObjectType): class Meta: model = models.Cable @@ -137,7 +143,7 @@ class ConsoleServerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, PrimaryObjectType): +class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, NetBoxObjectType): class Meta: model = models.Device @@ -167,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType): filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.InventoryItemTemplate + fields = '__all__' + filterset_class = filtersets.InventoryItemTemplateFilterSet + + class DeviceRoleType(OrganizationalObjectType): class Meta: @@ -175,7 +189,7 @@ class DeviceRoleType(OrganizationalObjectType): filterset_class = filtersets.DeviceRoleFilterSet -class DeviceTypeType(PrimaryObjectType): +class DeviceTypeType(NetBoxObjectType): class Meta: model = models.DeviceType @@ -238,6 +252,14 @@ class InventoryItemType(ComponentObjectType): filterset_class = filtersets.InventoryItemFilterSet +class InventoryItemRoleType(OrganizationalObjectType): + + class Meta: + model = models.InventoryItemRole + fields = '__all__' + filterset_class = filtersets.InventoryItemRoleFilterSet + + class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType): class Meta: @@ -254,6 +276,38 @@ class ManufacturerType(OrganizationalObjectType): filterset_class = filtersets.ManufacturerFilterSet +class ModuleType(ComponentObjectType): + + class Meta: + model = models.Module + fields = '__all__' + filterset_class = filtersets.ModuleFilterSet + + +class ModuleBayType(ComponentObjectType): + + class Meta: + model = models.ModuleBay + fields = '__all__' + filterset_class = filtersets.ModuleBayFilterSet + + +class ModuleBayTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.ModuleBayTemplate + fields = '__all__' + filterset_class = filtersets.ModuleBayTemplateFilterSet + + +class ModuleTypeType(NetBoxObjectType): + + class Meta: + model = models.ModuleType + fields = '__all__' + filterset_class = filtersets.ModuleTypeFilterSet + + class PlatformType(OrganizationalObjectType): class Meta: @@ -262,7 +316,7 @@ class PlatformType(OrganizationalObjectType): filterset_class = filtersets.PlatformFilterSet -class PowerFeedType(PrimaryObjectType): +class PowerFeedType(NetBoxObjectType): class Meta: model = models.PowerFeed @@ -298,7 +352,7 @@ class PowerOutletTemplateType(ComponentTemplateObjectType): return self.type or None -class PowerPanelType(PrimaryObjectType): +class PowerPanelType(NetBoxObjectType): class Meta: model = models.PowerPanel @@ -328,7 +382,7 @@ class PowerPortTemplateType(ComponentTemplateObjectType): return self.type or None -class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): +class RackType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): class Meta: model = models.Rack @@ -342,7 +396,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): return self.outer_unit or None -class RackReservationType(PrimaryObjectType): +class RackReservationType(NetBoxObjectType): class Meta: model = models.RackReservation @@ -382,7 +436,7 @@ class RegionType(VLANGroupsMixin, OrganizationalObjectType): filterset_class = filtersets.RegionFilterSet -class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, PrimaryObjectType): +class SiteType(VLANGroupsMixin, ImageAttachmentsMixin, NetBoxObjectType): asn = graphene.Field(BigInt) class Meta: @@ -399,7 +453,7 @@ class SiteGroupType(VLANGroupsMixin, OrganizationalObjectType): filterset_class = filtersets.SiteGroupFilterSet -class VirtualChassisType(PrimaryObjectType): +class VirtualChassisType(NetBoxObjectType): class Meta: model = models.VirtualChassis diff --git a/netbox/dcim/migrations/0002_squashed.py b/netbox/dcim/migrations/0002_squashed.py index a1b6db30a..c7325210e 100644 --- a/netbox/dcim/migrations/0002_squashed.py +++ b/netbox/dcim/migrations/0002_squashed.py @@ -58,7 +58,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='rearport', @@ -73,7 +73,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rearport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='rearport', @@ -128,7 +128,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='powerport', @@ -148,7 +148,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='powerport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='powerport', @@ -173,7 +173,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlettemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='poweroutlettemplate', @@ -198,7 +198,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='poweroutlet', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='poweroutlet', @@ -258,7 +258,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='inventoryitem', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitems', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='inventoryitem', @@ -278,7 +278,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interfacetemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='interface', @@ -298,7 +298,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='interface', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='interface', diff --git a/netbox/dcim/migrations/0003_squashed_0130.py b/netbox/dcim/migrations/0003_squashed_0130.py index 48ea238d9..592aaf9a8 100644 --- a/netbox/dcim/migrations/0003_squashed_0130.py +++ b/netbox/dcim/migrations/0003_squashed_0130.py @@ -165,7 +165,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='frontporttemplate', @@ -185,7 +185,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='frontport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='frontport', @@ -210,12 +210,12 @@ class Migration(migrations.Migration): migrations.AddField( model_name='devicebaytemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebaytemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='devicebay', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='devicebays', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='devicebay', @@ -290,7 +290,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleserverport', @@ -310,7 +310,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleserverport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleserverport', @@ -320,7 +320,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleporttemplate', name='device_type', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), ), migrations.AddField( model_name='consoleport', @@ -340,7 +340,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='consoleport', name='device', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.device'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device'), ), migrations.AddField( model_name='consoleport', diff --git a/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py new file mode 100644 index 000000000..86918447d --- /dev/null +++ b/netbox/dcim/migrations/0145_site_remove_deprecated_fields.py @@ -0,0 +1,59 @@ +import os + +from django.db import migrations +from django.db.utils import DataError + + +def check_legacy_data(apps, schema_editor): + """ + Abort the migration if any legacy site fields still contain data. + """ + Site = apps.get_model('dcim', 'Site') + + site_count = Site.objects.exclude(asn__isnull=True).count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting asn field from Site model: Found {site_count} sites with " + f"legacy ASN data. Please ensure all legacy site ASN data has been migrated to ASN objects " + f"before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment variable to bypass " + f"this safeguard and delete all legacy site ASN data." + ) + + site_count = Site.objects.exclude(contact_name='', contact_phone='', contact_email='').count() + if site_count and 'NETBOX_DELETE_LEGACY_DATA' not in os.environ: + raise DataError( + f"Unable to proceed with deleting contact fields from Site model: Found {site_count} sites " + f"with legacy contact data. Please ensure all legacy site contact data has been migrated to " + f"contact objects before proceeding. Or, set the NETBOX_DELETE_LEGACY_DATA environment " + f"variable to bypass this safeguard and delete all legacy site contact data." + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0144_fix_cable_abs_length'), + ] + + operations = [ + migrations.RunPython( + code=check_legacy_data, + reverse_code=migrations.RunPython.noop + ), + migrations.RemoveField( + model_name='site', + name='asn', + ), + migrations.RemoveField( + model_name='site', + name='contact_email', + ), + migrations.RemoveField( + model_name='site', + name='contact_name', + ), + migrations.RemoveField( + model_name='site', + name='contact_phone', + ), + ] diff --git a/netbox/dcim/migrations/0146_modules.py b/netbox/dcim/migrations/0146_modules.py new file mode 100644 index 000000000..11324fc58 --- /dev/null +++ b/netbox/dcim/migrations/0146_modules.py @@ -0,0 +1,279 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0066_customfield_name_validation'), + ('dcim', '0145_site_remove_deprecated_fields'), + ] + + operations = [ + # Rename any indexes left over from the old Module model (now InventoryItem) (#8656) + migrations.RunSQL( + """ + DO $$ + DECLARE + idx record; + BEGIN + FOR idx IN + SELECT indexname AS old_name, + replace(indexname, 'module', 'inventoryitem') AS new_name + FROM pg_indexes + WHERE schemaname = 'public' AND + tablename = 'dcim_inventoryitem' AND + indexname LIKE 'dcim_module_%' + LOOP + EXECUTE format( + 'ALTER INDEX %I RENAME TO %I;', + idx.old_name, + idx.new_name + ); + END LOOP; + END$$; + """ + ), + + migrations.AlterModelOptions( + name='consoleporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='consoleserverporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='frontporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='interfacetemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='poweroutlettemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='powerporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterModelOptions( + name='rearporttemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.CreateModel( + name='ModuleType', + 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)), + ('model', models.CharField(max_length=100)), + ('part_number', models.CharField(blank=True, max_length=50)), + ('comments', models.TextField(blank=True)), + ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('manufacturer', 'model'), + 'unique_together': {('manufacturer', 'model')}, + }, + ), + migrations.CreateModel( + name='ModuleBay', + 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=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.device')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('device', '_name'), + 'unique_together': {('device', 'name')}, + }, + ), + migrations.CreateModel( + name='Module', + 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)), + ('local_context_data', models.JSONField(blank=True, null=True)), + ('serial', models.CharField(blank=True, max_length=50)), + ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)), + ('comments', models.TextField(blank=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')), + ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')), + ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('module_bay',), + }, + ), + migrations.AddField( + model_name='consoleport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='consoleserverport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='frontport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='frontporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='interface', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='interfacetemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='poweroutlet', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='poweroutlettemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='powerport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='powerporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AddField( + model_name='rearport', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='rearporttemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='frontporttemplate', + unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.AlterUniqueTogether( + name='rearporttemplate', + unique_together={('device_type', 'name'), ('module_type', 'name')}, + ), + migrations.CreateModel( + name='ModuleBayTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('position', models.CharField(blank=True, max_length=30)), + ('description', models.CharField(blank=True, max_length=200)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ], + options={ + 'ordering': ('device_type', '_name'), + 'unique_together': {('device_type', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/migrations/0147_inventoryitemrole.py b/netbox/dcim/migrations/0147_inventoryitemrole.py new file mode 100644 index 000000000..f5e1f23f5 --- /dev/null +++ b/netbox/dcim/migrations/0147_inventoryitemrole.py @@ -0,0 +1,38 @@ +import django.core.serializers.json +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers +import utilities.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0068_configcontext_cluster_types'), + ('dcim', '0146_modules'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemRole', + 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)), + ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)), + ('description', models.CharField(blank=True, max_length=200)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='inventoryitem', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'), + ), + ] diff --git a/netbox/dcim/migrations/0148_inventoryitem_component.py b/netbox/dcim/migrations/0148_inventoryitem_component.py new file mode 100644 index 000000000..a18f41d3d --- /dev/null +++ b/netbox/dcim/migrations/0148_inventoryitem_component.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0147_inventoryitemrole'), + ] + + operations = [ + migrations.AddField( + model_name='inventoryitem', + name='component_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='inventoryitem', + name='component_type', + field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/dcim/migrations/0149_inventoryitem_templates.py b/netbox/dcim/migrations/0149_inventoryitem_templates.py new file mode 100644 index 000000000..f0b1f3cff --- /dev/null +++ b/netbox/dcim/migrations/0149_inventoryitem_templates.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0148_inventoryitem_component'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('part_id', models.CharField(blank=True, max_length=50)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype')), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), + ], + options={ + 'ordering': ('device_type__id', 'parent__id', '_name'), + 'unique_together': {('device_type', 'parent', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/migrations/0150_interface_vrf.py b/netbox/dcim/migrations/0150_interface_vrf.py new file mode 100644 index 000000000..f8741e4a0 --- /dev/null +++ b/netbox/dcim/migrations/0150_interface_vrf.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.11 on 2022-01-07 18:34 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0054_vlangroup_min_max_vids'), + ('dcim', '0149_inventoryitem_templates'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='interfaces', to='ipam.vrf'), + ), + ] diff --git a/netbox/dcim/migrations/0151_interface_speed_duplex.py b/netbox/dcim/migrations/0151_interface_speed_duplex.py new file mode 100644 index 000000000..7e800f42a --- /dev/null +++ b/netbox/dcim/migrations/0151_interface_speed_duplex.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.10 on 2022-01-08 18:23 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_interface_vrf'), + ] + + operations = [ + migrations.AddField( + model_name='interface', + name='duplex', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AddField( + model_name='interface', + name='speed', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0152_standardize_id_fields.py b/netbox/dcim/migrations/0152_standardize_id_fields.py new file mode 100644 index 000000000..6bf5b43f4 --- /dev/null +++ b/netbox/dcim/migrations/0152_standardize_id_fields.py @@ -0,0 +1,274 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0151_interface_speed_duplex'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='cable', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='cablepath', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='device', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicerole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='devicetype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='location', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='manufacturer', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='module', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebay', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='moduletype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='platform', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerfeed', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlet', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerpanel', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rack', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackreservation', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rackrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearport', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='region', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='site', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='sitegroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualchassis', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='cable', + name='termination_a_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cable', + name='termination_b_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='cablepath', + name='destination_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='cablepath', + name='origin_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='consoleport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='_link_peer_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0153_created_datetimefield.py b/netbox/dcim/migrations/0153_created_datetimefield.py new file mode 100644 index 000000000..c1cc4132e --- /dev/null +++ b/netbox/dcim/migrations/0153_created_datetimefield.py @@ -0,0 +1,208 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0152_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='cable', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='device', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebay', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicebaytemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicerole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interface', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitem', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitemrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='inventoryitemtemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='location', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='manufacturer', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='module', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='modulebay', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='moduletype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='platform', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerpanel', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rack', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackreservation', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rackrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='region', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='site', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='sitegroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualchassis', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 333972b21..dcc564717 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -11,8 +11,7 @@ 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.utils import extras_features -from netbox.models import BigIDModel, PrimaryModel +from netbox.models import NetBoxModel from utilities.fields import ColorField from utilities.utils import to_meters from .devices import Device @@ -29,8 +28,7 @@ __all__ = ( # Cables # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Cable(PrimaryModel): +class Cable(NetBoxModel): """ A physical connection between two endpoints. """ @@ -40,7 +38,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_a_id = models.PositiveIntegerField() + termination_a_id = models.PositiveBigIntegerField() termination_a = GenericForeignKey( ct_field='termination_a_type', fk_field='termination_a_id' @@ -51,7 +49,7 @@ class Cable(PrimaryModel): on_delete=models.PROTECT, related_name='+' ) - termination_b_id = models.PositiveIntegerField() + termination_b_id = models.PositiveBigIntegerField() termination_b = GenericForeignKey( ct_field='termination_b_type', fk_field='termination_b_id' @@ -288,8 +286,8 @@ class Cable(PrimaryModel): # Update the private pk used in __str__ in case this is a new object (i.e. just got its pk) self._pk = self.pk - def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return LinkStatusChoices.colors.get(self.status) def get_compatible_types(self): """ @@ -300,7 +298,7 @@ class Cable(PrimaryModel): return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] -class CablePath(BigIDModel): +class CablePath(models.Model): """ A CablePath instance represents the physical path from an origin to a destination, including all intermediate elements in the path. Every instance must specify an `origin`, whereas `destination` may be null (for paths which do @@ -329,7 +327,7 @@ class CablePath(BigIDModel): on_delete=models.CASCADE, related_name='+' ) - origin_id = models.PositiveIntegerField() + origin_id = models.PositiveBigIntegerField() origin = GenericForeignKey( ct_field='origin_type', fk_field='origin_id' @@ -341,7 +339,7 @@ class CablePath(BigIDModel): blank=True, null=True ) - destination_id = models.PositiveIntegerField( + destination_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 42e453669..b363d6ea4 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,15 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.fields import ColorField, NaturalOrderingField +from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, ) @@ -19,13 +24,15 @@ __all__ = ( 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'InventoryItemTemplate', + 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', 'RearPortTemplate', ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -64,17 +71,61 @@ class ComponentTemplateModel(ChangeLoggedModel): raise NotImplementedError() def to_objectchange(self, action): - # Annotate the parent DeviceType - try: - device_type = self.device_type - except ObjectDoesNotExist: - # The parent DeviceType has already been deleted - device_type = None - return super().to_objectchange(action, related_object=device_type) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device_type + return objectchange -@extras_features('webhooks') -class ConsolePortTemplate(ComponentTemplateModel): +class ModularComponentTemplateModel(ComponentTemplateModel): + """ + A ComponentTemplateModel which supports optional assignment to a ModuleType. + """ + device_type = models.ForeignKey( + to='dcim.DeviceType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + + class Meta: + abstract = True + + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + if self.device_type is not None: + objectchange.related_object = self.device_type + elif self.module_type is not None: + objectchange.related_object = self.module_type + return objectchange + + def clean(self): + super().clean() + + # A component template must belong to a DeviceType *or* to a ModuleType + if self.device_type and self.module_type: + raise ValidationError( + "A component template cannot be associated with both a device type and a module type." + ) + if not self.device_type and not self.module_type: + raise ValidationError( + "A component template must be associated with either a device type or a module type." + ) + + def resolve_name(self, module): + if module: + return self.name.replace('{module}', module.module_bay.position) + return self.name + + +class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. """ @@ -84,21 +135,25 @@ class ConsolePortTemplate(ComponentTemplateModel): blank=True ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = ConsolePort - def instantiate(self, device): - return ConsolePort( - device=device, - name=self.name, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, - type=self.type + type=self.type, + **kwargs ) -@extras_features('webhooks') -class ConsoleServerPortTemplate(ComponentTemplateModel): +class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. """ @@ -108,21 +163,25 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): blank=True ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = ConsoleServerPort - def instantiate(self, device): - return ConsoleServerPort( - device=device, - name=self.name, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, - type=self.type + type=self.type, + **kwargs ) -@extras_features('webhooks') -class PowerPortTemplate(ComponentTemplateModel): +class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. """ @@ -144,18 +203,23 @@ class PowerPortTemplate(ComponentTemplateModel): help_text="Allocated power draw (watts)" ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = PowerPort - def instantiate(self, device): - return PowerPort( - device=device, - name=self.name, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, maximum_draw=self.maximum_draw, - allocated_draw=self.allocated_draw + allocated_draw=self.allocated_draw, + **kwargs ) def clean(self): @@ -168,8 +232,7 @@ class PowerPortTemplate(ComponentTemplateModel): }) -@extras_features('webhooks') -class PowerOutletTemplate(ComponentTemplateModel): +class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. """ @@ -192,36 +255,46 @@ class PowerOutletTemplate(ComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) + component_model = PowerOutlet + class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) def clean(self): super().clean() # Validate power port assignment - if self.power_port and self.power_port.device_type != self.device_type: - raise ValidationError( - "Parent power port ({}) must belong to the same device type".format(self.power_port) - ) - - def instantiate(self, device): if self.power_port: - power_port = PowerPort.objects.get(device=device, name=self.power_port.name) + if self.device_type and self.power_port.device_type != self.device_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same device type" + ) + if self.module_type and self.power_port.module_type != self.module_type: + raise ValidationError( + f"Parent power port ({self.power_port}) must belong to the same module type" + ) + + def instantiate(self, **kwargs): + if self.power_port: + power_port_name = self.power_port.resolve_name(kwargs.get('module')) + power_port = PowerPort.objects.get(name=power_port_name, **kwargs) else: power_port = None - return PowerOutlet( - device=device, - name=self.name, + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, power_port=power_port, - feed_leg=self.feed_leg + feed_leg=self.feed_leg, + **kwargs ) -@extras_features('webhooks') -class InterfaceTemplate(ComponentTemplateModel): +class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. """ @@ -241,22 +314,26 @@ class InterfaceTemplate(ComponentTemplateModel): verbose_name='Management only' ) - class Meta: - ordering = ('device_type', '_name') - unique_together = ('device_type', 'name') + component_model = Interface - def instantiate(self, device): - return Interface( - device=device, - name=self.name, + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, - mgmt_only=self.mgmt_only + mgmt_only=self.mgmt_only, + **kwargs ) -@extras_features('webhooks') -class FrontPortTemplate(ComponentTemplateModel): +class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. """ @@ -280,10 +357,13 @@ class FrontPortTemplate(ComponentTemplateModel): ] ) + component_model = FrontPort + class Meta: - ordering = ('device_type', '_name') + ordering = ('device_type', 'module_type', '_name') unique_together = ( ('device_type', 'name'), + ('module_type', 'name'), ('rear_port', 'rear_port_position'), ) @@ -309,24 +389,24 @@ class FrontPortTemplate(ComponentTemplateModel): except RearPortTemplate.DoesNotExist: pass - def instantiate(self, device): + def instantiate(self, **kwargs): if self.rear_port: - rear_port = RearPort.objects.get(device=device, name=self.rear_port.name) + rear_port_name = self.rear_port.resolve_name(kwargs.get('module')) + rear_port = RearPort.objects.get(name=rear_port_name, **kwargs) else: rear_port = None - return FrontPort( - device=device, - name=self.name, + return self.component_model( + name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, color=self.color, rear_port=rear_port, - rear_port_position=self.rear_port_position + rear_port_position=self.rear_port_position, + **kwargs ) -@extras_features('webhooks') -class RearPortTemplate(ComponentTemplateModel): +class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. """ @@ -345,32 +425,63 @@ class RearPortTemplate(ComponentTemplateModel): ] ) + component_model = RearPort + + class Meta: + ordering = ('device_type', 'module_type', '_name') + unique_together = ( + ('device_type', 'name'), + ('module_type', 'name'), + ) + + def instantiate(self, **kwargs): + return self.component_model( + name=self.resolve_name(kwargs.get('module')), + label=self.label, + type=self.type, + color=self.color, + positions=self.positions, + **kwargs + ) + + +class ModuleBayTemplate(ComponentTemplateModel): + """ + A template for a ModuleBay to be created for a new parent Device. + """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + + component_model = ModuleBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return RearPort( + return self.component_model( device=device, name=self.name, label=self.label, - type=self.type, - color=self.color, - positions=self.positions + position=self.position ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ + component_model = DeviceBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return DeviceBay( + return self.component_model( device=device, name=self.name, label=self.label @@ -381,3 +492,78 @@ class DeviceBayTemplate(ComponentTemplateModel): raise ValidationError( f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." ) + + +class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): + """ + A template for an InventoryItem to be created for a new parent Device. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True, + db_index=True + ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True, + help_text='Manufacturer-assigned part identifier' + ) + + objects = TreeManager() + component_model = InventoryItem + + class Meta: + ordering = ('device_type__id', 'parent__id', '_name') + unique_together = ('device_type', 'parent', 'name') + + def instantiate(self, **kwargs): + parent = InventoryItem.objects.get(name=self.parent.name, **kwargs) if self.parent else None + if self.component: + model = self.component.component_model + component = model.objects.get(name=self.component.name, **kwargs) + else: + component = None + return self.component_model( + parent=parent, + name=self.name, + label=self.label, + component=component, + role=self.role, + manufacturer=self.manufacturer, + part_id=self.part_id, + **kwargs + ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index b20df82b7..ab5d24867 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,8 +11,8 @@ from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField, WWNField from dcim.svg import CableTraceSVG -from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel +from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface @@ -30,6 +30,8 @@ __all__ = ( 'FrontPort', 'Interface', 'InventoryItem', + 'InventoryItemRole', + 'ModuleBay', 'PathEndpoint', 'PowerOutlet', 'PowerPort', @@ -37,7 +39,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(NetBoxModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -73,19 +75,34 @@ class ComponentModel(PrimaryModel): return self.name def to_objectchange(self, action): - # Annotate the parent Device - try: - device = self.device - except ObjectDoesNotExist: - # The parent Device has already been deleted - device = None - return super().to_objectchange(action, related_object=device) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device + return super().to_objectchange(action) @property def parent_object(self): return self.device +class ModularComponentModel(ComponentModel): + module = models.ForeignKey( + to='dcim.Module', + on_delete=models.CASCADE, + related_name='%(class)ss', + blank=True, + null=True + ) + inventory_items = GenericRelation( + to='dcim.InventoryItem', + content_type_field='component_type', + object_id_field='component_id', + related_name='%(class)ss', + ) + + class Meta: + abstract = True + + class LinkTermination(models.Model): """ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples @@ -109,7 +126,7 @@ class LinkTermination(models.Model): blank=True, null=True ) - _link_peer_id = models.PositiveIntegerField( + _link_peer_id = models.PositiveBigIntegerField( blank=True, null=True ) @@ -229,11 +246,10 @@ class PathEndpoint(models.Model): # -# Console ports +# Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): +class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. """ @@ -260,12 +276,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -# -# Console server ports -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): +class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. """ @@ -293,11 +304,10 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint): # -# Power ports +# Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPort(ComponentModel, LinkTermination, PathEndpoint): +class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. """ @@ -389,12 +399,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint): } -# -# Power outlets -# - -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint): +class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. """ @@ -508,8 +513,7 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): +class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. """ @@ -537,6 +541,16 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): verbose_name='Management only', help_text='This interface is used only for out-of-band management' ) + speed = models.PositiveIntegerField( + blank=True, + null=True + ) + duplex = models.CharField( + max_length=50, + blank=True, + null=True, + choices=InterfaceDuplexChoices + ) wwn = WWNField( null=True, blank=True, @@ -602,6 +616,14 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): blank=True, verbose_name='Tagged VLANs' ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.SET_NULL, + related_name='interfaces', + null=True, + blank=True, + verbose_name='VRF' + ) ip_addresses = GenericRelation( to='ipam.IPAddress', content_type_field='assigned_object_type', @@ -775,8 +797,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint): # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FrontPort(ComponentModel, LinkTermination): +class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. """ @@ -829,8 +850,7 @@ class FrontPort(ComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RearPort(ComponentModel, LinkTermination): +class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. """ @@ -870,10 +890,29 @@ class RearPort(ComponentModel, LinkTermination): # -# Device bays +# Bays # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') +class ModuleBay(ComponentModel): + """ + An empty space within a Device which can house a child device + """ + position = models.CharField( + max_length=30, + blank=True, + help_text='Identifier to reference when renaming installed components' + ) + + clone_fields = ['device'] + + class Meta: + ordering = ('device', '_name') + unique_together = ('device', 'name') + + def get_absolute_url(self): + return reverse('dcim:modulebay', kwargs={'pk': self.pk}) + + class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -923,7 +962,37 @@ class DeviceBay(ComponentModel): # Inventory items # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') + +class InventoryItemRole(OrganizationalModel): + """ + Inventory items may optionally be assigned a functional role. + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + color = ColorField( + default=ColorChoices.COLOR_GREY + ) + description = models.CharField( + max_length=200, + blank=True, + ) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:inventoryitemrole', args=[self.pk]) + + class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. @@ -937,6 +1006,29 @@ class InventoryItem(MPTTModel, ComponentModel): null=True, db_index=True ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_items', + blank=True, + null=True + ) manufacturer = models.ForeignKey( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -970,7 +1062,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'manufacturer', 'part_id'] + clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id'] class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 737685fd9..d95063601 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -13,9 +13,8 @@ from dcim.choices import * from dcim.constants import * from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import ConfigItem -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from .device_components import * @@ -26,6 +25,8 @@ __all__ = ( 'DeviceRole', 'DeviceType', 'Manufacturer', + 'Module', + 'ModuleType', 'Platform', 'VirtualChassis', ) @@ -35,7 +36,6 @@ __all__ = ( # Device Types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Manufacturer(OrganizationalModel): """ A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. @@ -68,8 +68,7 @@ class Manufacturer(OrganizationalModel): return reverse('dcim:manufacturer', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class DeviceType(PrimaryModel): +class DeviceType(NetBoxModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -253,6 +252,15 @@ class DeviceType(PrimaryModel): } for c in self.rearporttemplates.all() ] + if self.modulebaytemplates.exists(): + data['module-bays'] = [ + { + 'name': c.name, + 'label': c.label, + 'description': c.description, + } + for c in self.modulebaytemplates.all() + ] if self.devicebaytemplates.exists(): data['device-bays'] = [ { @@ -342,11 +350,144 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD +class ModuleType(NetBoxModel): + """ + A ModuleType represents a hardware element that can be installed within a device and which houses additional + components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a + DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It + cannot, however house device bays or module bays. + """ + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='module_types' + ) + model = models.CharField( + max_length=100 + ) + part_number = models.CharField( + max_length=50, + blank=True, + help_text='Discrete part number (optional)' + ) + comments = models.TextField( + blank=True + ) + + # Generic relations + images = GenericRelation( + to='extras.ImageAttachment' + ) + + clone_fields = ('manufacturer',) + + class Meta: + ordering = ('manufacturer', 'model') + unique_together = ( + ('manufacturer', 'model'), + ) + + def __str__(self): + return self.model + + def get_absolute_url(self): + return reverse('dcim:moduletype', args=[self.pk]) + + def to_yaml(self): + data = OrderedDict(( + ('manufacturer', self.manufacturer.name), + ('model', self.model), + ('part_number', self.part_number), + ('comments', self.comments), + )) + + # Component templates + if self.consoleporttemplates.exists(): + data['console-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleporttemplates.all() + ] + if self.consoleserverporttemplates.exists(): + data['console-server-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'label': c.label, + 'description': c.description, + } + for c in self.consoleserverporttemplates.all() + ] + if self.powerporttemplates.exists(): + data['power-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'maximum_draw': c.maximum_draw, + 'allocated_draw': c.allocated_draw, + 'label': c.label, + 'description': c.description, + } + for c in self.powerporttemplates.all() + ] + if self.poweroutlettemplates.exists(): + data['power-outlets'] = [ + { + 'name': c.name, + 'type': c.type, + 'power_port': c.power_port.name if c.power_port else None, + 'feed_leg': c.feed_leg, + 'label': c.label, + 'description': c.description, + } + for c in self.poweroutlettemplates.all() + ] + if self.interfacetemplates.exists(): + data['interfaces'] = [ + { + 'name': c.name, + 'type': c.type, + 'mgmt_only': c.mgmt_only, + 'label': c.label, + 'description': c.description, + } + for c in self.interfacetemplates.all() + ] + if self.frontporttemplates.exists(): + data['front-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'rear_port': c.rear_port.name, + 'rear_port_position': c.rear_port_position, + 'label': c.label, + 'description': c.description, + } + for c in self.frontporttemplates.all() + ] + if self.rearporttemplates.exists(): + data['rear-ports'] = [ + { + 'name': c.name, + 'type': c.type, + 'positions': c.positions, + 'label': c.label, + 'description': c.description, + } + for c in self.rearporttemplates.all() + ] + + return yaml.dump(dict(data), sort_keys=False) + + # # Devices # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceRole(OrganizationalModel): """ Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a @@ -384,7 +525,6 @@ class DeviceRole(OrganizationalModel): return reverse('dcim:devicerole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Platform(OrganizationalModel): """ Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". @@ -434,8 +574,7 @@ class Platform(OrganizationalModel): return reverse('dcim:platform', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Device(PrimaryModel, ConfigContextModel): +class Device(NetBoxModel, ConfigContextModel): """ A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, DeviceRole, and (optionally) a Platform. Device names are not required, however if one is set it must be unique. @@ -767,29 +906,35 @@ class Device(PrimaryModel, ConfigContextModel): # If this is a new Device, instantiate all of the related components per the DeviceType definition if is_new: ConsolePort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()] ) ConsoleServerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()] ) PowerPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.powerporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()] ) PowerOutlet.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()] + [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()] ) Interface.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.interfacetemplates.all()] + [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()] ) RearPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.rearporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()] ) FrontPort.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.frontporttemplates.all()] + [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()] + ) + ModuleBay.objects.bulk_create( + [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()] ) DeviceBay.objects.bulk_create( - [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()] + [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) + # Avoid bulk_create to handle MPTT + for x in self.device_type.inventoryitemtemplates.all(): + x.instantiate(device=self).save() # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) @@ -862,16 +1007,94 @@ class Device(PrimaryModel, ConfigContextModel): """ return Device.objects.filter(parent_bay__device=self.pk) - def get_status_class(self): - return DeviceStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return DeviceStatusChoices.colors.get(self.status) + + +class Module(NetBoxModel, ConfigContextModel): + """ + A Module represents a field-installable component within a Device which may itself hold multiple device components + (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes. + """ + device = models.ForeignKey( + to='dcim.Device', + on_delete=models.CASCADE, + related_name='modules' + ) + module_bay = models.OneToOneField( + to='dcim.ModuleBay', + on_delete=models.CASCADE, + related_name='installed_module' + ) + module_type = models.ForeignKey( + to='dcim.ModuleType', + on_delete=models.PROTECT, + related_name='instances' + ) + serial = models.CharField( + max_length=50, + blank=True, + verbose_name='Serial number' + ) + asset_tag = models.CharField( + max_length=50, + blank=True, + null=True, + unique=True, + verbose_name='Asset tag', + help_text='A unique tag used to identify this device' + ) + comments = models.TextField( + blank=True + ) + + clone_fields = ('device', 'module_type') + + class Meta: + ordering = ('module_bay',) + + def __str__(self): + return f'{self.module_bay.name}: {self.module_type} ({self.pk})' + + def get_absolute_url(self): + return reverse('dcim:module', args=[self.pk]) + + def save(self, *args, **kwargs): + is_new = self.pk is None + + super().save(*args, **kwargs) + + # If this is a new Module and component replication has not been disabled, instantiate all its + # related components per the ModuleType definition + if is_new and not getattr(self, '_disable_replication', False): + ConsolePort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()] + ) + ConsoleServerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()] + ) + PowerPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()] + ) + PowerOutlet.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()] + ) + Interface.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()] + ) + RearPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()] + ) + FrontPort.objects.bulk_create( + [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()] + ) # # Virtual chassis # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VirtualChassis(PrimaryModel): +class VirtualChassis(NetBoxModel): """ A collection of Devices which operate with a shared control plane (e.g. a switch stack). """ diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index b5d8d4c83..08f89e3b0 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -6,8 +6,7 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * -from extras.utils import extras_features -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.validators import ExclusionValidator from .device_components import LinkTermination, PathEndpoint @@ -21,8 +20,7 @@ __all__ = ( # Power # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerPanel(PrimaryModel): +class PowerPanel(NetBoxModel): """ A distribution point for electrical power; e.g. a data center RPP. """ @@ -68,8 +66,7 @@ class PowerPanel(PrimaryModel): ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): +class PowerFeed(NetBoxModel, PathEndpoint, LinkTermination): """ An electrical circuit delivered from a PowerPanel. """ @@ -173,8 +170,8 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): def parent_object(self): return self.power_panel - def get_type_class(self): - return PowerFeedTypeChoices.CSS_CLASSES.get(self.type) + def get_type_color(self): + return PowerFeedTypeChoices.colors.get(self.type) - def get_status_class(self): - return PowerFeedStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return PowerFeedStatusChoices.colors.get(self.status) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 9413d834e..81d699b11 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -13,9 +13,8 @@ from django.urls import reverse from dcim.choices import * from dcim.constants import * from dcim.svg import RackElevationSVG -from extras.utils import extras_features from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.utils import array_to_string @@ -34,7 +33,6 @@ __all__ = ( # Racks # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RackRole(OrganizationalModel): """ Racks can be organized by functional role, similar to Devices. @@ -65,8 +63,7 @@ class RackRole(OrganizationalModel): return reverse('dcim:rackrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Rack(PrimaryModel): +class Rack(NetBoxModel): """ 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 Location. @@ -250,8 +247,8 @@ class Rack(PrimaryModel): else: return reversed(range(1, self.u_height + 1)) - def get_status_class(self): - return RackStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return RackStatusChoices.colors.get(self.status) def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ @@ -438,8 +435,7 @@ class Rack(PrimaryModel): return int(allocated_draw_total / available_power_total * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RackReservation(PrimaryModel): +class RackReservation(NetBoxModel): """ One or more reserved units within a Rack. """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index a19ae8050..53e3bcceb 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -7,9 +7,7 @@ from timezone_field import TimeZoneField from dcim.choices import * from dcim.constants import * -from dcim.fields import ASNField -from extras.utils import extras_features -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from utilities.fields import NaturalOrderingField __all__ = ( @@ -24,7 +22,6 @@ __all__ = ( # Regions # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Region(NestedGroupModel): """ A region represents a geographic collection of sites. For example, you might create regions representing countries, @@ -111,7 +108,6 @@ class Region(NestedGroupModel): # Site groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class SiteGroup(NestedGroupModel): """ A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and @@ -198,8 +194,7 @@ class SiteGroup(NestedGroupModel): # Sites # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Site(PrimaryModel): +class Site(NetBoxModel): """ A Site represents a geographic location within a network; typically a building or campus. The optional facility field can be used to include an external designation, such as a data center name (e.g. Equinix SV6). @@ -248,12 +243,6 @@ class Site(PrimaryModel): blank=True, help_text='Local facility ID or description' ) - asn = ASNField( - blank=True, - null=True, - verbose_name='ASN', - help_text='32-bit autonomous system number' - ) asns = models.ManyToManyField( to='ipam.ASN', related_name='sites', @@ -288,18 +277,6 @@ class Site(PrimaryModel): null=True, help_text='GPS coordinate (longitude)' ) - contact_name = models.CharField( - max_length=50, - blank=True - ) - contact_phone = models.CharField( - max_length=20, - blank=True - ) - contact_email = models.EmailField( - blank=True, - verbose_name='Contact E-mail' - ) comments = models.TextField( blank=True ) @@ -319,8 +296,8 @@ class Site(PrimaryModel): ) clone_fields = [ - 'status', 'region', 'group', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', + 'status', 'region', 'group', 'tenant', 'facility', 'time_zone', 'description', 'physical_address', + 'shipping_address', 'latitude', 'longitude', ] class Meta: @@ -332,15 +309,14 @@ class Site(PrimaryModel): def get_absolute_url(self): return reverse('dcim:site', args=[self.pk]) - def get_status_class(self): - return SiteStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return SiteStatusChoices.colors.get(self.status) # # Locations # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', '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 diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 825e60d57..e3b2a42ba 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,11 +1,12 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, BooleanColumn +from netbox.tables import BaseTable, columns from dcim.models import ConsolePort, Interface, PowerPort from .cables import * from .devices import * from .devicetypes import * +from .modules import * from .power import * from .racks import * from .sites import * @@ -35,7 +36,7 @@ class ConsoleConnectionTable(BaseTable): linkify=True, verbose_name='Console Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -43,7 +44,6 @@ class ConsoleConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = ConsolePort fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - exclude = ('id', ) class PowerConnectionTable(BaseTable): @@ -66,7 +66,7 @@ class PowerConnectionTable(BaseTable): linkify=True, verbose_name='Power Port' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -74,7 +74,6 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - exclude = ('id', ) class InterfaceConnectionTable(BaseTable): @@ -100,7 +99,7 @@ class InterfaceConnectionTable(BaseTable): linkify=True, verbose_name='Interface B' ) - reachable = BooleanColumn( + reachable = columns.BooleanColumn( accessor=Accessor('_path__is_active'), verbose_name='Reachable' ) @@ -108,4 +107,3 @@ class InterfaceConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') - exclude = ('id', ) diff --git a/netbox/dcim/tables/cables.py b/netbox/dcim/tables/cables.py index 97b54bf41..4b062ad48 100644 --- a/netbox/dcim/tables/cables.py +++ b/netbox/dcim/tables/cables.py @@ -2,8 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Cable +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT __all__ = ( @@ -15,8 +15,7 @@ __all__ = ( # Cables # -class CableTable(BaseTable): - pk = ToggleColumn() +class CableTable(NetBoxTable): termination_a_parent = tables.TemplateColumn( template_code=CABLE_TERMINATION_PARENT, accessor=Accessor('termination_a'), @@ -53,18 +52,18 @@ class CableTable(BaseTable): linkify=True, verbose_name='Termination B' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() - length = TemplateColumn( + length = columns.TemplateColumn( template_code=CABLE_LENGTH, order_by=('_abs_length', 'length_unit') ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:cable_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Cable fields = ( 'pk', 'id', 'label', 'termination_a_parent', 'rack_a', 'termination_a', 'termination_b_parent', 'rack_b', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 80e5dd30d..f3e1d39e0 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -2,14 +2,11 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import ( - ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform, - PowerOutlet, PowerPort, RearPort, VirtualChassis, + ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, + InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis, ) +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, - MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, -) from .template_code import * __all__ = ( @@ -25,6 +22,7 @@ __all__ = ( 'DeviceImportTable', 'DeviceInterfaceTable', 'DeviceInventoryItemTable', + 'DeviceModuleBayTable', 'DevicePowerPortTable', 'DevicePowerOutletTable', 'DeviceRearPortTable', @@ -32,7 +30,9 @@ __all__ = ( 'DeviceTable', 'FrontPortTable', 'InterfaceTable', + 'InventoryItemRoleTable', 'InventoryItemTable', + 'ModuleBayTable', 'PlatformTable', 'PowerOutletTable', 'PowerPortTable', @@ -45,7 +45,7 @@ def get_cabletermination_row_class(record): if record.mark_connected: return 'success' elif record.cable: - return record.cable.get_status_class() + return record.cable.get_status_color() return '' @@ -66,74 +66,70 @@ def get_interface_state_attribute(record): else: return "disabled" + # # Device roles # - -class DeviceRoleTable(BaseTable): - pk = ToggleColumn() +class DeviceRoleTable(NetBoxTable): name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'role_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'role_id': 'pk'}, verbose_name='VMs' ) - color = ColorColumn() - vm_role = BooleanColumn() - tags = TagColumn( + color = columns.ColorColumn() + vm_role = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:devicerole_list' ) - actions = ButtonsColumn(DeviceRole) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceRole fields = ( 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions') + default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') # # Platforms # -class PlatformTable(BaseTable): - pk = ToggleColumn() +class PlatformTable(NetBoxTable): name = tables.Column( linkify=True ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'platform_id': 'pk'}, verbose_name='Devices' ) - vm_count = LinkedCountColumn( + vm_count = columns.LinkedCountColumn( viewname='virtualization:virtualmachine_list', url_params={'platform_id': 'pk'}, verbose_name='VMs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:platform_list' ) - actions = ButtonsColumn(Platform) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Platform fields = ( 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'description', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', + 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', ) @@ -141,13 +137,12 @@ class PlatformTable(BaseTable): # Devices # -class DeviceTable(BaseTable): - pk = ToggleColumn() +class DeviceTable(NetBoxTable): name = tables.TemplateColumn( order_by=('_name',), template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -158,7 +153,7 @@ class DeviceTable(BaseTable): rack = tables.Column( linkify=True ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( verbose_name='Role' ) manufacturer = tables.Column( @@ -194,15 +189,15 @@ class DeviceTable(BaseTable): vc_priority = tables.Column( verbose_name='VC Priority' ) + comments = columns.MarkdownColumn() contacts = tables.ManyToManyColumn( linkify_item=True ) - comments = MarkdownColumn() - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:device_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', @@ -216,11 +211,11 @@ class DeviceTable(BaseTable): ) -class DeviceImportTable(BaseTable): +class DeviceImportTable(NetBoxTable): name = tables.TemplateColumn( template_code=DEVICE_LINK ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() site = tables.Column( linkify=True @@ -235,7 +230,7 @@ class DeviceImportTable(BaseTable): verbose_name='Type' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Device fields = ('id', 'name', 'status', 'tenant', 'site', 'rack', 'position', 'device_role', 'device_type') empty_text = False @@ -245,8 +240,7 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentTable(BaseTable): - pk = ToggleColumn() +class DeviceComponentTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -255,30 +249,43 @@ class DeviceComponentTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): order_by = ('device', 'name') -class CableTerminationTable(BaseTable): +class ModularDeviceComponentTable(DeviceComponentTable): + module_bay = tables.Column( + accessor=Accessor('module__module_bay'), + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + module = tables.Column( + linkify=True + ) + + +class CableTerminationTable(NetBoxTable): cable = tables.Column( linkify=True ) - cable_color = ColorColumn( + cable_color = columns.ColorColumn( accessor='cable__color', orderable=False, verbose_name='Cable Color' ) - link_peer = TemplateColumn( + link_peer = columns.TemplateColumn( accessor='_link_peer', template_code=LINKTERMINATION, orderable=False, verbose_name='Link Peer' ) - mark_connected = BooleanColumn() + mark_connected = columns.BooleanColumn() class PathEndpointTable(CableTerminationTable): - connection = TemplateColumn( + connection = columns.TemplateColumn( accessor='_path__last_node', template_code=LINKTERMINATION, verbose_name='Connection', @@ -286,22 +293,22 @@ class PathEndpointTable(CableTerminationTable): ) -class ConsolePortTable(DeviceComponentTable, PathEndpointTable): +class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -312,40 +319,38 @@ class DeviceConsolePortTable(ConsolePortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsolePort, - buttons=('edit', 'delete'), - prepend_template=CONSOLEPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=CONSOLEPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ConsolePort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions' + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } -class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable): +class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_consoleserverports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleserverport_list' ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -357,40 +362,39 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=ConsoleServerPort, - buttons=('edit', 'delete'), - prepend_template=CONSOLESERVERPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=CONSOLESERVERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ConsoleServerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions') + default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection') row_attrs = { 'class': get_cabletermination_row_class } -class PowerPortTable(DeviceComponentTable, PathEndpointTable): +class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_powerports', 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerport_list' ) class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', - 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', + 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description') @@ -402,28 +406,25 @@ class DevicePowerPortTable(PowerPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerPort, - buttons=('edit', 'delete'), - prepend_template=POWERPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=POWERPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = PowerPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class } -class PowerOutletTable(DeviceComponentTable, PathEndpointTable): +class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_poweroutlets', @@ -433,15 +434,16 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable): power_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:poweroutlet_list' ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', - 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', + 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description') @@ -452,28 +454,26 @@ class DevicePowerOutletTable(PowerOutletTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=PowerOutlet, - buttons=('edit', 'delete'), - prepend_template=POWEROUTLET_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=POWEROUTLET_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = PowerOutlet fields = ( - 'pk', 'id', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'connection', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions', + 'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', ) row_attrs = { 'class': get_cabletermination_row_class } -class BaseInterfaceTable(BaseTable): - enabled = BooleanColumn() +class BaseInterfaceTable(NetBoxTable): + enabled = columns.BooleanColumn() ip_addresses = tables.TemplateColumn( template_code=INTERFACE_IPADDRESSES, orderable=False, @@ -486,40 +486,44 @@ class BaseInterfaceTable(BaseTable): verbose_name='FHRP Groups' ) untagged_vlan = tables.Column(linkify=True) - tagged_vlans = TemplateColumn( + tagged_vlans = columns.TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, orderable=False, verbose_name='Tagged VLANs' ) -class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable): +class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpointTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_interfaces', 'args': [Accessor('device_id')], } ) - mgmt_only = BooleanColumn() + mgmt_only = columns.BooleanColumn() wireless_link = tables.Column( linkify=True ) - wireless_lans = TemplateColumn( + wireless_lans = columns.TemplateColumn( template_code=INTERFACE_WIRELESS_LANS, orderable=False, verbose_name='Wireless LANs' ) - tags = TagColumn( + vrf = tables.Column( + linkify=True + ) + tags = columns.TagColumn( url_name='dcim:interface_list' ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', - 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', - 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', - 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', + 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -542,24 +546,23 @@ class DeviceInterfaceTable(InterfaceTable): linkify=True, verbose_name='LAG' ) - actions = ButtonsColumn( - model=Interface, - buttons=('edit', 'delete'), - prepend_template=INTERFACE_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=INTERFACE_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = Interface fields = ( - 'pk', 'id', 'name', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', - 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', - 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', + 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', + 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', + 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', - 'cable', 'connection', 'actions', + 'cable', 'connection', ) row_attrs = { 'class': get_interface_row_class, @@ -568,29 +571,30 @@ class DeviceInterfaceTable(InterfaceTable): } -class FrontPortTable(DeviceComponentTable, CableTerminationTable): +class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_frontports', 'args': [Accessor('device_id')], } ) - color = ColorColumn() + color = columns.ColorColumn() rear_port_position = tables.Column( verbose_name='Position' ) rear_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:frontport_list' ) class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', + 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', @@ -604,44 +608,41 @@ class DeviceFrontPortTable(FrontPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=FrontPort, - buttons=('edit', 'delete'), - prepend_template=FRONTPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=FRONTPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = FrontPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position', + 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class } -class RearPortTable(DeviceComponentTable, CableTerminationTable): +class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): device = tables.Column( linkify={ 'viewname': 'dcim:device_rearports', 'args': [Accessor('device_id')], } ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rearport_list' ) class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', - 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -653,20 +654,18 @@ class DeviceRearPortTable(RearPortTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=RearPort, - buttons=('edit', 'delete'), - prepend_template=REARPORT_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=REARPORT_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = RearPort fields = ( - 'pk', 'id', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color', - 'link_peer', 'tags', 'actions', + 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected', + 'cable', 'cable_color', 'link_peer', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', + 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', ) row_attrs = { 'class': get_cabletermination_row_class @@ -680,7 +679,7 @@ class DeviceBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( accessor=Accessor('installed_device__device_role'), verbose_name='Role' ) @@ -696,7 +695,7 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:devicebay_list' ) @@ -717,10 +716,8 @@ class DeviceDeviceBayTable(DeviceBayTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=DEVICEBAY_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=DEVICEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): @@ -728,9 +725,39 @@ class DeviceDeviceBayTable(DeviceBayTable): fields = ( 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', ) - default_columns = ( - 'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions', - ) + default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') + + +class ModuleBayTable(DeviceComponentTable): + device = tables.Column( + linkify={ + 'viewname': 'dcim:device_modulebays', + 'args': [Accessor('device_id')], + } + ) + installed_module = tables.Column( + linkify=True, + verbose_name='Installed module' + ) + tags = columns.TagColumn( + url_name='dcim:modulebay_list' + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'description', 'tags') + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') + + +class DeviceModuleBayTable(ModuleBayTable): + actions = columns.ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS + ) + + class Meta(DeviceComponentTable.Meta): + model = ModuleBay + fields = ('pk', 'id', 'name', 'label', 'position', 'installed_module', 'description', 'tags', 'actions') + default_columns = ('pk', 'name', 'label', 'installed_module', 'description') class InventoryItemTable(DeviceComponentTable): @@ -740,22 +767,29 @@ class InventoryItemTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + role = columns.ColoredLabelColumn() manufacturer = tables.Column( linkify=True ) - discovered = BooleanColumn() - tags = TagColumn( + component = tables.Column( + orderable=False, + linkify=True + ) + discovered = columns.BooleanColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitem_list' ) cable = None # Override DeviceComponentTable - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', - 'discovered', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', + 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', ) - default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') class DeviceInventoryItemTable(InventoryItemTable): @@ -765,45 +799,61 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( - 'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'tags', 'actions', + 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', + 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', ) +class InventoryItemRoleTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + inventoryitem_count = columns.LinkedCountColumn( + viewname='dcim:inventoryitem_list', + url_params={'role_id': 'pk'}, + verbose_name='Items' + ) + color = columns.ColorColumn() + tags = columns.TagColumn( + url_name='dcim:inventoryitemrole_list' + ) + + class Meta(NetBoxTable.Meta): + model = InventoryItemRole + fields = ( + 'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description') + + # # Virtual chassis # -class VirtualChassisTable(BaseTable): - pk = ToggleColumn() +class VirtualChassisTable(NetBoxTable): name = tables.Column( linkify=True ) master = tables.Column( linkify=True ) - member_count = LinkedCountColumn( + member_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'virtual_chassis_id': 'pk'}, verbose_name='Members' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:virtualchassis_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VirtualChassis fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'domain', 'master', 'member_count') diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index fde9ca61c..f5f5ed7bf 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -2,11 +2,10 @@ import django_tables2 as tables from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, -) -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, + InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) +from netbox.tables import NetBoxTable, columns +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( 'ConsolePortTemplateTable', @@ -15,7 +14,9 @@ __all__ = ( 'DeviceTypeTable', 'FrontPortTemplateTable', 'InterfaceTemplateTable', + 'InventoryItemTemplateTable', 'ManufacturerTable', + 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', 'PowerPortTemplateTable', 'RearPortTemplateTable', @@ -26,8 +27,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(BaseTable): - pk = ToggleColumn() +class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -44,19 +44,18 @@ class ManufacturerTable(BaseTable): contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:manufacturer_list' ) - actions = ButtonsColumn(Manufacturer) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Manufacturer fields = ( 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'contacts', 'actions', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', + 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', ) @@ -64,8 +63,7 @@ class ManufacturerTable(BaseTable): # Device types # -class DeviceTypeTable(BaseTable): - pk = ToggleColumn() +class DeviceTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Device Type' @@ -73,20 +71,20 @@ class DeviceTypeTable(BaseTable): manufacturer = tables.Column( linkify=True ) - is_full_depth = BooleanColumn( + is_full_depth = columns.BooleanColumn( verbose_name='Full Depth' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'device_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:devicetype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = DeviceType fields = ( 'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', @@ -101,8 +99,7 @@ class DeviceTypeTable(BaseTable): # Device type components # -class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() +class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name='ID' ) @@ -110,14 +107,14 @@ class ComponentTemplateTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): exclude = ('id', ) class ConsolePortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsolePortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -127,9 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -139,9 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -151,9 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -163,12 +160,12 @@ class PowerOutletTemplateTable(ComponentTemplateTable): class InterfaceTemplateTable(ComponentTemplateTable): - mgmt_only = BooleanColumn( + mgmt_only = columns.BooleanColumn( verbose_name='Management Only' ) - actions = ButtonsColumn( - model=InterfaceTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -181,10 +178,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' ) - color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete') + color = columns.ColorColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -194,10 +191,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): - color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete') + color = columns.ColorColumn() + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -206,13 +203,45 @@ class RearPortTemplateTable(ComponentTemplateTable): empty_text = "None" +class ModuleBayTemplateTable(ComponentTemplateTable): + actions = columns.ActionsColumn( + actions=('edit', 'delete') + ) + + class Meta(ComponentTemplateTable.Meta): + model = ModuleBayTemplate + fields = ('pk', 'name', 'label', 'position', 'description', 'actions') + empty_text = "None" + + class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + actions=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): model = DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" + + +class InventoryItemTemplateTable(ComponentTemplateTable): + actions = columns.ActionsColumn( + actions=('edit', 'delete') + ) + role = tables.Column( + linkify=True + ) + manufacturer = tables.Column( + linkify=True + ) + component = tables.Column( + orderable=False + ) + + class Meta(ComponentTemplateTable.Meta): + model = InventoryItemTemplate + fields = ( + 'pk', 'name', 'label', 'parent', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions', + ) + empty_text = "None" diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py new file mode 100644 index 000000000..5b009e42e --- /dev/null +++ b/netbox/dcim/tables/modules.py @@ -0,0 +1,59 @@ +import django_tables2 as tables + +from dcim.models import Module, ModuleType +from netbox.tables import NetBoxTable, columns + +__all__ = ( + 'ModuleTable', + 'ModuleTypeTable', +) + + +class ModuleTypeTable(NetBoxTable): + model = tables.Column( + linkify=True, + verbose_name='Module Type' + ) + instance_count = columns.LinkedCountColumn( + viewname='dcim:module_list', + url_params={'module_type_id': 'pk'}, + verbose_name='Instances' + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='dcim:moduletype_list' + ) + + class Meta(NetBoxTable.Meta): + model = ModuleType + fields = ( + 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'model', 'manufacturer', 'part_number', + ) + + +class ModuleTable(NetBoxTable): + device = tables.Column( + linkify=True + ) + module_bay = tables.Column( + linkify=True + ) + module_type = tables.Column( + linkify=True + ) + comments = columns.MarkdownColumn() + tags = columns.TagColumn( + url_name='dcim:module_list' + ) + + class Meta(NetBoxTable.Meta): + model = Module + fields = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', + ) + default_columns = ( + 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', + ) diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index 517a48aa1..cab95bb02 100644 --- a/netbox/dcim/tables/power.py +++ b/netbox/dcim/tables/power.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import PowerFeed, PowerPanel -from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import NetBoxTable, columns from .devices import CableTerminationTable __all__ = ( @@ -14,15 +14,14 @@ __all__ = ( # Power panels # -class PowerPanelTable(BaseTable): - pk = ToggleColumn() +class PowerPanelTable(NetBoxTable): name = tables.Column( linkify=True ) site = tables.Column( linkify=True ) - powerfeed_count = LinkedCountColumn( + powerfeed_count = columns.LinkedCountColumn( viewname='dcim:powerfeed_list', url_params={'power_panel_id': 'pk'}, verbose_name='Feeds' @@ -30,11 +29,11 @@ class PowerPanelTable(BaseTable): contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerpanel_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerPanel fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'contacts', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -47,7 +46,6 @@ class PowerPanelTable(BaseTable): # We're not using PathEndpointTable for PowerFeed because power connections # cannot traverse pass-through ports. class PowerFeedTable(CableTerminationTable): - pk = ToggleColumn() name = tables.Column( linkify=True ) @@ -57,20 +55,20 @@ class PowerFeedTable(CableTerminationTable): rack = tables.Column( linkify=True ) - status = ChoiceFieldColumn() - type = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() + type = columns.ChoiceFieldColumn() max_utilization = tables.TemplateColumn( template_code="{{ value }}%" ) available_power = tables.Column( verbose_name='Available power (VA)' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:powerfeed_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = PowerFeed fields = ( 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 4d2aac3dd..e5a1c8488 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -2,11 +2,8 @@ import django_tables2 as tables from django_tables2.utils import Accessor from dcim.models import Rack, RackReservation, RackRole +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, - TagColumn, ToggleColumn, UtilizationColumn, -) __all__ = ( 'RackTable', @@ -19,31 +16,28 @@ __all__ = ( # Rack roles # -class RackRoleTable(BaseTable): - pk = ToggleColumn() +class RackRoleTable(NetBoxTable): name = tables.Column(linkify=True) rack_count = tables.Column(verbose_name='Racks') - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rackrole_list' ) - actions = ButtonsColumn(RackRole) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackRole fields = ( - 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', - 'created', 'last_updated', + 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', + 'last_updated', ) - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + default_columns = ('pk', 'name', 'rack_count', 'color', 'description') # # Racks # -class RackTable(BaseTable): - pk = ToggleColumn() +class RackTable(NetBoxTable): name = tables.Column( order_by=('_name',), linkify=True @@ -55,30 +49,30 @@ class RackTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn() - role = ColoredLabelColumn() + status = columns.ChoiceFieldColumn() + role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( template_code="{{ record.u_height }}U", verbose_name='Height' ) - comments = MarkdownColumn() - device_count = LinkedCountColumn( + comments = columns.MarkdownColumn() + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'rack_id': 'pk'}, verbose_name='Devices' ) - get_utilization = UtilizationColumn( + get_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Space' ) - get_power_utilization = UtilizationColumn( + get_power_utilization = columns.UtilizationColumn( orderable=False, verbose_name='Power' ) contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rack_list' ) outer_width = tables.TemplateColumn( @@ -90,7 +84,7 @@ class RackTable(BaseTable): verbose_name='Outer Depth' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Rack fields = ( 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', @@ -107,8 +101,7 @@ class RackTable(BaseTable): # Rack reservations # -class RackReservationTable(BaseTable): - pk = ToggleColumn() +class RackReservationTable(NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -125,17 +118,14 @@ class RackReservationTable(BaseTable): orderable=False, verbose_name='Units' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rackreservation_list' ) - actions = ButtonsColumn(RackReservation) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RackReservation fields = ( 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', - ) + default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index b749315eb..84522480f 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -1,11 +1,9 @@ import django_tables2 as tables from dcim.models import Location, Region, Site, SiteGroup +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, -) -from .template_code import LOCATION_ELEVATIONS +from .template_code import LOCATION_BUTTONS __all__ = ( 'LocationTable', @@ -19,12 +17,11 @@ __all__ = ( # Regions # -class RegionTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class RegionTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'region_id': 'pk'}, verbose_name='Sites' @@ -32,27 +29,28 @@ class RegionTable(BaseTable): contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:region_list' ) - actions = ButtonsColumn(Region) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Region - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'actions', + ) + default_columns = ('pk', 'name', 'site_count', 'description') # # Site groups # -class SiteGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class SiteGroupTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) - site_count = LinkedCountColumn( + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'group_id': 'pk'}, verbose_name='Sites' @@ -60,58 +58,59 @@ class SiteGroupTable(BaseTable): contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:sitegroup_list' ) - actions = ButtonsColumn(SiteGroup) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = SiteGroup - fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'actions', + ) + default_columns = ('pk', 'name', 'site_count', 'description') # # Sites # -class SiteTable(BaseTable): - pk = ToggleColumn() +class SiteTable(NetBoxTable): name = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() region = tables.Column( linkify=True ) group = tables.Column( linkify=True ) - asn_count = LinkedCountColumn( + asns = tables.ManyToManyColumn( + linkify_item=True, + verbose_name='ASNs' + ) + asn_count = columns.LinkedCountColumn( accessor=tables.A('asns__count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASN Count' ) - asns = tables.ManyToManyColumn( - linkify_item=True, - verbose_name='ASNs' - ) tenant = TenantColumn() + comments = columns.MarkdownColumn() contacts = tables.ManyToManyColumn( linkify_item=True ) - comments = MarkdownColumn() - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:site_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ( 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count', - 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', - 'contact_phone', 'contact_email', 'contacts', 'comments', 'tags', 'created', 'last_updated', + 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', + 'contacts', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -120,21 +119,20 @@ class SiteTable(BaseTable): # Locations # -class LocationTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( +class LocationTable(NetBoxTable): + name = columns.MPTTColumn( linkify=True ) site = tables.Column( linkify=True ) tenant = TenantColumn() - rack_count = LinkedCountColumn( + rack_count = columns.LinkedCountColumn( viewname='dcim:rack_list', url_params={'location_id': 'pk'}, verbose_name='Racks' ) - device_count = LinkedCountColumn( + device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'location_id': 'pk'}, verbose_name='Devices' @@ -142,18 +140,17 @@ class LocationTable(BaseTable): contacts = tables.ManyToManyColumn( linkify_item=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:location_list' ) - actions = ButtonsColumn( - model=Location, - prepend_template=LOCATION_ELEVATIONS + actions = columns.ActionsColumn( + extra_buttons=LOCATION_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Location fields = ( 'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'contacts', 'tags', 'actions', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions') + default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description') diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 1d68c466a..92739c6ed 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -31,7 +31,7 @@ DEVICE_LINK = """ DEVICEBAY_STATUS = """ {% if record.installed_device_id %} - + {{ record.installed_device.get_status_display }} {% else %} @@ -43,7 +43,7 @@ INTERFACE_IPADDRESSES = """
    {% for ip in record.ip_addresses.all %} {% if ip.status != 'active' %} - {{ ip }} + {{ ip }} {% else %} {{ ip }} {% endif %} @@ -88,17 +88,35 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ """ +# +# Device component templatebuttons +# + +MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ +{% load helpers %} +{% if perms.dcim.add_inventoryitemtemplate %} + + + +{% endif %} +""" + # # Device component buttons # CONSOLEPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -126,6 +144,11 @@ CONSOLEPORT_BUTTONS = """ """ CONSOLESERVERPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -153,6 +176,11 @@ CONSOLESERVERPORT_BUTTONS = """ """ POWERPORT_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -179,6 +207,11 @@ POWERPORT_BUTTONS = """ """ POWEROUTLET_BUTTONS = """ +{% if perms.dcim.add_inventoryitem %} + + + +{% endif %} {% if record.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %} @@ -201,10 +234,20 @@ POWEROUTLET_BUTTONS = """ """ INTERFACE_BUTTONS = """ -{% if perms.ipam.add_ipaddress %} - - - +{% if perms.ipam.add_ipaddress or perms.dcim.add_inventoryitem %} + + + + {% endif %} {% if record.link %} @@ -227,7 +270,7 @@ INTERFACE_BUTTONS = """ {% if not record.mark_connected %} -
    + {% if object.connected_endpoint %} + + Device + {{ object.connected_endpoint.device|linkify }} + + + Name + {{ object.connected_endpoint|linkify:"name" }} + + + Type + {{ object.connected_endpoint.get_type_display|placeholder }} + + + Description + {{ object.connected_endpoint.description|placeholder }} + + + Path Status + + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} + + + {% endif %} + + {% else %} +
    + Not Connected + {% if perms.dcim.add_cable %} + + {% endif %} +
    + {% endif %} + + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html index b44c4a9b8..52b1a3229 100644 --- a/netbox/templates/dcim/consoleserverport.html +++ b/netbox/templates/dcim/consoleserverport.html @@ -20,9 +20,11 @@ - + + + + + @@ -64,7 +66,7 @@ - + @@ -143,6 +143,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 3beaed4b4..d075a801d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -19,28 +19,26 @@ - + @@ -127,7 +121,7 @@ {% for vc_member in vc_members %} diff --git a/netbox/templates/dcim/inc/endpoint_connection.html b/netbox/templates/dcim/inc/endpoint_connection.html index d5b9f6112..c0167ff0f 100644 --- a/netbox/templates/dcim/inc/endpoint_connection.html +++ b/netbox/templates/dcim/inc/endpoint_connection.html @@ -1,7 +1,7 @@ {% if path.destination_id %} {% with endpoint=path.destination %} - - + + {% endwith %} {% else %} diff --git a/netbox/templates/dcim/inc/interface_vlans_table.html b/netbox/templates/dcim/inc/interface_vlans_table.html index 67ffb2954..0c23b5702 100644 --- a/netbox/templates/dcim/inc/interface_vlans_table.html +++ b/netbox/templates/dcim/inc/interface_vlans_table.html @@ -8,9 +8,7 @@ {% with tagged_vlans=obj.tagged_vlans.all %} {% if obj.untagged_vlan and obj.untagged_vlan not in tagged_vlans %} - + - + {% if device.parent_bay %} - + {% else %} diff --git a/netbox/templates/dcim/inc/panels/inventory_items.html b/netbox/templates/dcim/inc/panels/inventory_items.html new file mode 100644 index 000000000..d89b64814 --- /dev/null +++ b/netbox/templates/dcim/inc/panels/inventory_items.html @@ -0,0 +1,49 @@ +{% load helpers %} + +
    +
    Inventory Items
    +
    +
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -74,14 +76,12 @@
    Device - {{ object.connected_endpoint.device }} + {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type {% if object.site.region %} {% for region in object.site.region.get_ancestors %} - {{ region }} / + {{ region|linkify }} / {% endfor %} - {{ object.site.region }} + {{ object.site.region|linkify }} {% else %} - None + None {% endif %}
    Site - {{ object.site }} - {{ object.site|linkify }}
    Location {% if object.location %} {% for location in object.location.get_ancestors %} - {{ location }} / + {{ location|linkify }} / {% endfor %} - {{ object.location }} + {{ object.location|linkify }} {% else %} None {% endif %} @@ -61,7 +59,7 @@ {% if object.parent_bay %} {% with object.parent_bay.device as parent %} - {{ parent }} / {{ object.parent_bay }} + {{ parent|linkify }} / {{ object.parent_bay }} {% if parent.position %} (U{{ parent.position }} / {{ parent.get_face_display }}) {% endif %} @@ -78,20 +76,16 @@
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Device Type - {{ object.device_type }} ({{ object.device_type.u_height }}U) + {{ object.device_type|linkify }} ({{ object.device_type.u_height }}U)
    - {{ vc_member }} + {{ vc_member|linkify }} {% badge vc_member.vc_position show_empty=True %} @@ -163,9 +157,7 @@ - + @@ -175,13 +167,7 @@ - + @@ -189,9 +175,9 @@ {% if object.primary_ip4 %} {{ object.primary_ip4.address.ip }} {% if object.primary_ip4.nat_inside %} - (NAT for {{ object.primary_ip4.nat_inside.address.ip }}) + (NAT for {{ object.primary_ip4.nat_inside.address.ip|linkify }}) {% elif object.primary_ip4.nat_outside %} - (NAT: {{ object.primary_ip4.nat_outside.address.ip }}) + (NAT: {{ object.primary_ip4.nat_outside.address.ip|linkify }}) {% endif %} {% else %} @@ -204,9 +190,9 @@ {% if object.primary_ip6 %} {{ object.primary_ip6.address.ip }} {% if object.primary_ip6.nat_inside %} - (NAT for {{ object.primary_ip6.nat_inside.address.ip }}) + (NAT for {{ object.primary_ip6.nat_inside.address.ip|linkify }}) {% elif object.primary_ip6.nat_outside %} - (NAT: {{ object.primary_ip6.nat_outside.address.ip }}) + (NAT: {{ object.primary_ip6.nat_outside.address.ip|linkify }}) {% endif %} {% else %} @@ -218,9 +204,9 @@ {% endif %} diff --git a/netbox/templates/dcim/device/base.html b/netbox/templates/dcim/device/base.html index 705a51eea..ea67154b1 100644 --- a/netbox/templates/dcim/device/base.html +++ b/netbox/templates/dcim/device/base.html @@ -16,84 +16,61 @@ {% block extra_controls %} {% if perms.dcim.change_device %} {% endif %} {% endblock %} -{% block tab_items %} - +{% block extra_tabs %} + {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} + {% if active_tab == tab_name or devicebay_count %} + + {% endif %} + {% endwith %} + + {% with tab_name='module-bays' modulebay_count=object.modulebays.count %} + {% if active_tab == tab_name or modulebay_count %} + + {% endif %} + {% endwith %} {% with tab_name='interfaces' interface_count=object.interfaces_count %} {% if active_tab == tab_name or interface_count %} @@ -151,13 +128,6 @@ {% endif %} {% endwith %} - {% with tab_name='device-bays' devicebay_count=object.devicebays.count %} - {% if active_tab == tab_name or devicebay_count %} - - {% endif %} - {% endwith %} {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %} {% if active_tab == tab_name or inventoryitem_count %} diff --git a/netbox/templates/dcim/device/modulebays.html b/netbox/templates/dcim/device/modulebays.html new file mode 100644 index 000000000..e9c672b57 --- /dev/null +++ b/netbox/templates/dcim/device/modulebays.html @@ -0,0 +1,43 @@ +{% extends 'dcim/device/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} +{% load static %} + +{% block content %} + + {% csrf_token %} + {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %} + +
    +
    + {% include 'htmx/table.html' %} +
    +
    + +
    +
    + {% if perms.dcim.change_modulebay %} + + + {% endif %} + {% if perms.dcim.delete_modulebay %} + + {% endif %} +
    + {% if perms.dcim.add_modulebay %} + + {% endif %} +
    + + {% table_config_form table %} +{% endblock %} diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html index 2eb2fcd96..44b93d870 100644 --- a/netbox/templates/dcim/device_component_edit.html +++ b/netbox/templates/dcim/device_component_edit.html @@ -1,14 +1,16 @@ {% extends 'generic/object_edit.html' %} {% load form_helpers %} -{% block form_fields %} +{% block form %} +
    {% if form.instance.device %} -
    - -
    - -
    +
    + +
    +
    +
    {% endif %} {% render_form form %} -{% endblock %} +
    +{% endblock form %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html index 1be272d3a..7cbb224c9 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -34,19 +34,19 @@ {% render_field form.location %} {% render_field form.rack %} - {% if obj.device_type.is_child_device and obj.parent_bay %} + {% if object.device_type.is_child_device and object.parent_bay %}
    - +
    @@ -64,7 +64,7 @@
    {% render_field form.status %} {% render_field form.platform %} - {% if obj.pk %} + {% if object.pk %} {% render_field form.primary_ip4 %} {% render_field form.primary_ip6 %} {% endif %} diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html index caf94ada6..b30de60c2 100644 --- a/netbox/templates/dcim/device_import.html +++ b/netbox/templates/dcim/device_import.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_import.html' %} +{% extends 'generic/bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' %} diff --git a/netbox/templates/dcim/device_import_child.html b/netbox/templates/dcim/device_import_child.html index bec8b21a8..d0dc72b61 100644 --- a/netbox/templates/dcim/device_import_child.html +++ b/netbox/templates/dcim/device_import_child.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_import.html' %} +{% extends 'generic/bulk_import.html' %} {% block tabs %} {% include 'dcim/inc/device_import_header.html' with active_tab='child_import' %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 177a0fb36..60efc842e 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -56,6 +56,13 @@ {% endif %} + {% if perms.dcim.add_modulebay %} +
  • + +
  • + {% endif %} {% if perms.dcim.add_inventoryitem %}
  • {% endif %} + {{ block.super }} {% endblock %} diff --git a/netbox/templates/dcim/devicebay.html b/netbox/templates/dcim/devicebay.html index 3a470bec5..38cc8f16b 100644 --- a/netbox/templates/dcim/devicebay.html +++ b/netbox/templates/dcim/devicebay.html @@ -20,9 +20,7 @@
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Role
    Platform - {% if object.platform %} - {{ object.platform }} - {% else %} - None - {% endif %} - {{ object.platform|linkify|placeholder }}
    Primary IPv4Cluster {% if object.cluster.group %} - {{ object.cluster.group }} / + {{ object.cluster.group|linkify }} / {% endif %} - {{ object.cluster }} + {{ object.cluster|linkify }}
    - + @@ -54,9 +52,7 @@
    Device - {{ object.device }} - {{ object.device|linkify }}
    Name
    - + diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 4b9dc8db7..e717a48aa 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -14,7 +14,7 @@
    Device - {{ device }} - {{ device|linkify }}
    Device Type
    - + diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index b3c161002..83ee1f41e 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -38,20 +38,36 @@ {% if perms.dcim.add_rearporttemplate %}
  • Rear Ports
  • {% endif %} + {% if perms.dcim.add_modulebaytemplate %} +
  • Module Bays
  • + {% endif %} {% if perms.dcim.add_devicebaytemplate %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitemtemplate %} +
  • Inventory Items
  • + {% endif %} {% endif %} {% endblock %} -{% block tab_items %} - +{% block extra_tabs %} + {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} + {% if active_tab == tab_name or devicebay_count %} + + {% endif %} + {% endwith %} + + {% with tab_name='module-bay-templates' modulebay_count=object.modulebaytemplates.count %} + {% if active_tab == tab_name or modulebay_count %} + + {% endif %} + {% endwith %} {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %} {% if active_tab == tab_name or interface_count %} @@ -109,10 +125,10 @@ {% endif %} {% endwith %} - {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %} - {% if active_tab == tab_name or devicebay_count %} + {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} + {% if active_tab == tab_name or inventoryitem_count %} {% endif %} {% endwith %} diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html index 05be82fc9..891f217ee 100644 --- a/netbox/templates/dcim/frontport.html +++ b/netbox/templates/dcim/frontport.html @@ -20,9 +20,11 @@
    Manufacturer{{ object.manufacturer }}{{ object.manufacturer|linkify }}
    Model Name
    - + + + + + @@ -44,9 +46,7 @@ - + @@ -78,7 +78,7 @@ - + - {% if termination.device.site %} - + - {% endif %} {% if termination.device.rack %} - - - - + + + + {% endif %} - + - + {% else %} {# Circuit termination #} - + - + {% endif %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Rear Port - {{ object.rear_port }} - {{ object.rear_port|linkify }}
    Rear Port Position
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -129,6 +129,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/frontporttemplate_create.html b/netbox/templates/dcim/frontporttemplate_create.html new file mode 100644 index 000000000..50e9d355c --- /dev/null +++ b/netbox/templates/dcim/frontporttemplate_create.html @@ -0,0 +1,7 @@ +{% extends 'dcim/component_template_create.html' %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% render_field replication_form.rear_port_set %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inc/cable_termination.html b/netbox/templates/dcim/inc/cable_termination.html index c9f3f0d4a..f44c3b9d1 100644 --- a/netbox/templates/dcim/inc/cable_termination.html +++ b/netbox/templates/dcim/inc/cable_termination.html @@ -4,51 +4,35 @@ {# Device component #}
    Device - {{ termination.device }} - {{ termination.device|linkify }}
    Site - {{ termination.device.site }} - {{ termination.device.site|linkify }}
    Rack - {{ termination.device.rack }} -
    Rack{{ termination.device.rack|linkify }}
    Type - {{ termination|meta:"verbose_name"|capfirst }} - {{ termination|meta:"verbose_name"|capfirst }}
    Component - {{ termination }} - {{ termination|linkify }}
    Provider - {{ termination.circuit.provider }} - {{ termination.circuit.provider|linkify }}
    Circuit - {{ termination.circuit }} ({{ termination }}) - {{ termination.|linkify }} ({{ termination }})
    diff --git a/netbox/templates/dcim/inc/cabletermination.html b/netbox/templates/dcim/inc/cabletermination.html index 26a7e1cd3..c7fa7918a 100644 --- a/netbox/templates/dcim/inc/cabletermination.html +++ b/netbox/templates/dcim/inc/cabletermination.html @@ -6,9 +6,9 @@ {{ termination.parent_object }} {% else %} - {{ termination.parent_object }} + {{ termination.parent_object|linkify }} {% endif %}
    - {{ termination }} + {{ termination|linkify }} {{ endpoint.parent_object }}{{ endpoint }}{{ endpoint.parent_object|linkify }}{{ endpoint|linkify }}Not connected
    - {{ obj.untagged_vlan.vid }} - {{ obj.untagged_vlan|linkify:"vid" }} {{ obj.untagged_vlan.name }} @@ -22,9 +20,7 @@ {% endif %} {% for vlan in tagged_vlans %}
    - {{ vlan.vid }} - {{ vlan|linkify:"vid" }} {{ vlan.name }} diff --git a/netbox/templates/dcim/inc/nonracked_devices.html b/netbox/templates/dcim/inc/nonracked_devices.html index f1b669eb9..7f4da2f24 100644 --- a/netbox/templates/dcim/inc/nonracked_devices.html +++ b/netbox/templates/dcim/inc/nonracked_devices.html @@ -21,7 +21,7 @@ {{ device.device_role }} {{ device.device_type }}{{ device.parent_bay.device }}{{ device.parent_bay.device|linkify }} {{ device.parent_bay }}
    + + + + + + + + + + {% for item in object.inventory_items.all %} + + + + + + + {% empty %} + + + + {% endfor %} + +
    NameLabelRole
    {{ item|linkify:"name" }}{{ item.label|placeholder }}{{ item.role|linkify|placeholder }} + {% if perms.dcim.change_inventoryitem %} + + + + {% endif %} + {% if perms.ipam.delete_inventoryitem %} + + + + {% endif %} +
    None
    + + + diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index bf1445a1c..358922730 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -20,468 +20,444 @@ {% endblock %} {% block content %} -
    -
    -
    -
    - Interface -
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Device - {{ object.device }} -
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Type{{ object.get_type_display }}
    Enabled{% checkmark object.enabled %}
    Management Only{% checkmark object.mgmt_only %}
    Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - None - {% endif %} -
    Bridge - {% if object.bridge %} - {{ object.bridge }} - {% else %} - None - {% endif %} -
    LAG - {% if object.lag%} - {{ object.lag }} - {% else %} - None - {% endif %} -
    Description{{ object.description|placeholder }}
    MTU{{ object.mtu|placeholder }}
    MAC Address{{ object.mac_address|placeholder }}
    WWN{{ object.wwn|placeholder }}
    Transmit power (dBm){{ object.tx_power|placeholder }}
    802.1Q Mode{{ object.get_mode_display|placeholder }}
    -
    -
    - {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% plugin_left_page object %} +
    +
    +
    +
    Interface
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Device{{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Type{{ object.get_type_display }}
    Speed/Duplex + {{ object.speed|humanize_speed|placeholder }} / + {{ object.get_duplex_display|placeholder }} +
    MTU{{ object.mtu|placeholder }}
    Enabled{% checkmark object.enabled %}
    Management Only{% checkmark object.mgmt_only %}
    Description{{ object.description|placeholder }}
    802.1Q Mode{{ object.get_mode_display|placeholder }}
    Transmit power (dBm){{ object.tx_power|placeholder }}
    -
    - {% if not object.is_virtual %} -
    -
    - Connection -
    -
    - {% if object.mark_connected %} -
    - Marked as Connected -
    - {% elif object.cable %} - - {% if object.connected_endpoint.device %} - - - - {% endif %} - - - - - {% if object.connected_endpoint.device %} - {% with iface=object.connected_endpoint %} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - {% endwith %} - {% elif object.connected_endpoint.circuit %} - {% with ct=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} - {% endif %} - - - - -
    - {% if object.connected_endpoint.enabled %} - Enabled - {% else %} - Disabled - {% endif %} -
    Cable - {{ object.cable }} - - - -
    Device - {{ iface.device }} -
    Name - {{ iface.name }} -
    Type{{ iface.get_type_display }}
    LAG - {% if iface.lag%} - {{ iface.lag }} - {% else %} - None - {% endif %} -
    Description{{ iface.description|placeholder }}
    MTU{{ iface.mtu|placeholder }}
    MAC Address{{ iface.mac_address|placeholder }}
    802.1Q Mode{{ iface.get_mode_display }}
    Provider{{ ct.circuit.provider }}
    Circuit{{ ct.circuit }}
    Side{{ ct.term_side }}
    Path Status - {% if object.path.is_active %} - Reachable - {% else %} - Not Reachable - {% endif %} -
    - {% elif object.wireless_link %} - - - - - - {% with peer_interface=object.connected_endpoint %} - - - - - - - - - - - - - {% endwith %} -
    Wireless Link - {{ object.wireless_link }} - - - -
    Device - {{ peer_interface.device }} -
    Name - {{ peer_interface }} -
    Type{{ peer_interface.get_type_display }}
    - {% else %} -
    - Not Connected - {% if object.is_wired and perms.dcim.add_cable %} - - {% elif object.is_wireless and perms.wireless.add_wirelesslink %} - - {% endif %} -
    - {% endif %} -
    -
    - {% endif %} - {% if object.is_wireless %} -
    -
    Wireless
    -
    - {% with peer=object.connected_endpoint %} - - - - - - {% if peer %} - - {% endif %} - - - - - - {% if peer %} - - {% endif %} - - - - - {% if peer %} - - {{ peer.get_rf_channel_display|placeholder }} - - {% endif %} - - - - - {% if peer %} - - {% if peer.rf_channel_frequency %} - {{ peer.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} - - {% endif %} - - - - - {% if peer %} - - {% if peer.rf_channel_width %} - {{ peer.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} - - {% endif %} - -
    LocalPeer
    Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
    Channel{{ object.get_rf_channel_display|placeholder }}
    Channel Frequency - {% if object.rf_channel_frequency %} - {{ object.rf_channel_frequency|simplify_decimal }} MHz - {% else %} - - {% endif %} -
    Channel Width - {% if object.rf_channel_width %} - {{ object.rf_channel_width|simplify_decimal }} MHz - {% else %} - - {% endif %} -
    - {% endwith %} -
    -
    -
    -
    Wireless LANs
    -
    - - - - - - - - - {% for wlan in object.wireless_lans.all %} - - - - - {% empty %} - - - - {% endfor %} - -
    GroupSSID
    - {% if wlan.group %} - {{ wlan.group }} - {% else %} - — - {% endif %} - - {{ wlan.ssid }} -
    None
    -
    -
    - {% endif %} - {% if object.is_lag %} -
    -
    LAG Members
    -
    - - - - - - - - - - {% for member in object.member_interfaces.all %} - - - - - - {% empty %} - - - - {% endfor %} - -
    ParentInterfaceType
    - {{ member.device }} - - {{ member }} - - {{ member.get_type_display }} -
    No member interfaces
    -
    -
    - {% endif %} - {% include 'ipam/inc/panels/fhrp_groups.html' %} - {% plugin_right_page object %} +
    +
    +
    Related Interfaces
    +
    + + + + + + + + + + + + + +
    Parent{{ object.parent|linkify|placeholder }}
    Bridge{{ object.bridge|linkify|placeholder }}
    LAG{{ object.lag|linkify|placeholder }}
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %}
    -
    -
    -
    -
    - IP Addresses -
    -
    - {% if ipaddress_table.rows %} - {% render_table ipaddress_table 'inc/table.html' %} - {% else %} -
    None
    - {% endif %} -
    - {% if perms.ipam.add_ipaddress %} - +
    +
    +
    Addressing
    +
    + + + + + + + + + + + + + +
    MAC Address{{ object.mac_address|placeholder }}
    WWN{{ object.wwn|placeholder }}
    VRF{{ object.vrf|linkify|placeholder }}
    +
    +
    + {% if not object.is_virtual %} +
    +
    Connection
    +
    + {% if object.mark_connected %} +
    + Marked as Connected +
    + {% elif object.cable %} + + {% if object.connected_endpoint.device %} + + + {% endif %} - + + + + + {% if object.connected_endpoint.device %} + {% with iface=object.connected_endpoint %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endwith %} + {% elif object.connected_endpoint.circuit %} + {% with ct=object.connected_endpoint %} + + + + + + + + + + + + + {% endwith %} + {% endif %} + + + + +
    + {% if object.connected_endpoint.enabled %} + Enabled + {% else %} + Disabled + {% endif %} +
    Cable + {{ object.cable|linkify }} + + + +
    Device{{ iface.device|linkify }}
    Name{{ iface|linkify:"name" }}
    Type{{ iface.get_type_display }}
    LAG{{ iface.lag|linkify|placeholder }}
    Description{{ iface.description|placeholder }}
    MTU{{ iface.mtu|placeholder }}
    MAC Address{{ iface.mac_address|placeholder }}
    802.1Q Mode{{ iface.get_mode_display }}
    Provider{{ ct.circuit.provider|linkify }}
    Circuit{{ ct.circuit|linkify }}
    Side{{ ct.term_side }}
    Path Status + {% if object.path.is_active %} + Reachable + {% else %} + Not Reachable + {% endif %} +
    + {% elif object.wireless_link %} + + + + + + {% with peer_interface=object.connected_endpoint %} + + + + + + + + + + + + + {% endwith %} +
    Wireless Link + {{ object.wireless_link|linkify }} + + + +
    Device{{ peer_interface.device|linkify }}
    Name{{ peer_interface|linkify }}
    Type{{ peer_interface.get_type_display }}
    + {% else %} +
    + Not Connected + {% if object.is_wired and perms.dcim.add_cable %} + + {% elif object.is_wireless and perms.wireless.add_wirelesslink %} + + {% endif %} +
    + {% endif %} +
    + {% endif %} + {% if object.is_wireless %} +
    +
    Wireless
    +
    + {% with peer=object.connected_endpoint %} + + + + + + {% if peer %} + + {% endif %} + + + + + + {% if peer %} + + {% endif %} + + + + + {% if peer %} + + {{ peer.get_rf_channel_display|placeholder }} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_frequency %} + {{ peer.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + + + + + {% if peer %} + + {% if peer.rf_channel_width %} + {{ peer.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} + + {% endif %} + +
    LocalPeer
    Role{{ object.get_rf_role_display|placeholder }}{{ peer.get_rf_role_display|placeholder }}
    Channel{{ object.get_rf_channel_display|placeholder }}
    Channel Frequency + {% if object.rf_channel_frequency %} + {{ object.rf_channel_frequency|simplify_decimal }} MHz + {% else %} + + {% endif %} +
    Channel Width + {% if object.rf_channel_width %} + {{ object.rf_channel_width|simplify_decimal }} MHz + {% else %} + + {% endif %} +
    + {% endwith %} +
    +
    +
    +
    Wireless LANs
    +
    + + + + + + + + + {% for wlan in object.wireless_lans.all %} + + + + + {% empty %} + + + + {% endfor %} + +
    GroupSSID
    {{ wlan.group|linkify|placeholder }}{{ wlan|linkify:"ssid" }}
    None
    +
    +
    + {% endif %} + {% if object.is_lag %} +
    +
    LAG Members
    +
    + + + + + + + + + + {% for member in object.member_interfaces.all %} + + + + + + {% empty %} + + + + {% endfor %} + +
    ParentInterfaceType
    {{ member.device|linkify }}{{ member|linkify }}{{ member.get_type_display }}
    No member interfaces
    +
    +
    + {% endif %} + {% include 'ipam/inc/panels/fhrp_groups.html' %} + {% include 'dcim/inc/panels/inventory_items.html' %} + {% plugin_right_page object %}
    +
    +
    +
    +
    +
    IP Addresses
    +
    + {% if ipaddress_table.rows %} + {% render_table ipaddress_table 'inc/table.html' %} + {% else %} +
    None
    + {% endif %} +
    + {% if perms.ipam.add_ipaddress %} + + {% endif %} +
    +
    +
    +
    +
    + {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} +
    +
    + {% if object.is_bridge %}
    -
    - {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %} -
    +
    + {% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %} +
    - {% if object.is_bridge %} -
    -
    - {% include 'inc/panel_table.html' with table=bridge_interfaces_table heading="Bridge Interfaces" %} -
    + {% endif %} +
    +
    + {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
    - {% endif %} -
    -
    - {% include 'inc/panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %} -
    -
    -
    -
    - {% plugin_full_width_page object %} -
    +
    +
    +
    + {% plugin_full_width_page object %}
    +
    {% endblock %} diff --git a/netbox/templates/dcim/interface_create.html b/netbox/templates/dcim/interface_create.html deleted file mode 100644 index 6b5486eff..000000000 --- a/netbox/templates/dcim/interface_create.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} - -{% block buttons %} - Cancel - {% if component_type == 'interface' and perms.ipam.add_ipaddress %} - - {% endif %} - - -{% endblock %} \ No newline at end of file diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html index a5f686633..ddda1ae31 100644 --- a/netbox/templates/dcim/interface_edit.html +++ b/netbox/templates/dcim/interface_edit.html @@ -2,20 +2,28 @@ {% load form_helpers %} {% block form %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
    Interface
    {% if form.instance.device %}
    - +
    {% endif %} + {% render_field form.module %} {% render_field form.name %} {% render_field form.type %} + {% render_field form.speed %} + {% render_field form.duplex %} {% render_field form.label %} {% render_field form.description %} {% render_field form.tags %} @@ -25,6 +33,7 @@
    Addressing
    + {% render_field form.vrf %} {% render_field form.mac_address %} {% render_field form.wwn %}
    @@ -85,8 +94,8 @@ {% block buttons %} Cancel - {% if obj.pk %} - + {% if object.pk %} + {% else %} diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 36ba0469f..b52a7457b 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -13,26 +13,16 @@
    -
    - Inventory Item -
    +
    Inventory Item
    - + - + @@ -42,15 +32,17 @@ + + + + + + + + - + diff --git a/netbox/templates/dcim/inventoryitem_bulk_delete.html b/netbox/templates/dcim/inventoryitem_bulk_delete.html index fc31537b3..6f5735c33 100644 --- a/netbox/templates/dcim/inventoryitem_bulk_delete.html +++ b/netbox/templates/dcim/inventoryitem_bulk_delete.html @@ -1,4 +1,4 @@ -{% extends 'generic/object_bulk_delete.html' %} +{% extends 'generic/bulk_delete.html' %} {% block message_extra %}

    This will also delete all child inventory items of those listed.

    diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html new file mode 100644 index 000000000..be910f143 --- /dev/null +++ b/netbox/templates/dcim/inventoryitem_create.html @@ -0,0 +1,17 @@ +{% extends 'dcim/component_create.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% if object.component %} +
    + +
    + +
    +
    + {% endif %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitemrole.html b/netbox/templates/dcim/inventoryitemrole.html new file mode 100644 index 000000000..f750d74ce --- /dev/null +++ b/netbox/templates/dcim/inventoryitemrole.html @@ -0,0 +1,53 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Inventory Item Role
    +
    +
    Device - {{ object.device }} - {{ object.device|linkify }}
    Parent Item - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - {{ object.parent|linkify|placeholder }}
    NameLabel {{ object.label|placeholder }}
    Role{{ object.role|linkify|placeholder }}
    Component{{ object.component|linkify|placeholder }}
    Manufacturer - {% if object.manufacturer %} - {{ object.manufacturer }} - {% else %} - - {% endif %} - {{ object.manufacturer|linkify|placeholder }}
    Part ID
    + + + + + + + + + + + + + + + + +
    Name{{ object.name }}
    Description{{ object.description|placeholder }}
    Color +   +
    Inventory Items + {{ inventoryitem_count }} +
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/inventoryitemtemplate_create.html b/netbox/templates/dcim/inventoryitemtemplate_create.html new file mode 100644 index 000000000..9180cf6ab --- /dev/null +++ b/netbox/templates/dcim/inventoryitemtemplate_create.html @@ -0,0 +1,17 @@ +{% extends 'dcim/component_template_create.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% if object.component %} +
    + +
    + +
    +
    + {% endif %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 43bbfd114..b2b2bc4cd 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -6,7 +6,7 @@ {% block breadcrumbs %} {{ block.super }} {% for location in object.get_ancestors %} - + {% endfor %} {% endblock %} @@ -37,30 +37,20 @@ Site - {{ object.site }} + {{ object.site|linkify }} Parent - - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - + {{ object.parent|linkify|placeholder }} - Tenant - - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None - {% endif %} - + Tenant + + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} + Racks diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index c79ec923e..43d16afcb 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -34,6 +34,12 @@ {{ devicetypes_table.rows|length }} + + Module types + + {{ module_type_count }} + + Inventory Items diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html new file mode 100644 index 000000000..130cd046f --- /dev/null +++ b/netbox/templates/dcim/module.html @@ -0,0 +1,181 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load tz %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_module %} + + {% endif %} +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device{{ object.device|linkify }}
    Device Type{{ object.device.device_type|linkify }}
    Module Type{{ object.module_type|linkify }}
    Serial Number{{ object.serial|placeholder }}
    Asset Tag{{ object.asset_tag|placeholder }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
    +
    +
    +
    Components
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Interfaces + {% with component_count=object.interfaces.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Console Ports + {% with component_count=object.consoleports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Console Server Ports + {% with component_count=object.consoleserverports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Power Ports + {% with component_count=object.powerports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Power Outlets + {% with component_count=object.poweroutlets.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Front Ports + {% with component_count=object.frontports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    Rear Ports + {% with component_count=object.rearports.count %} + {% if component_count %} + {{ component_count }} + {% else %} + {{ ''|placeholder }} + {% endif %} + {% endwith %} +
    +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/modulebay.html b/netbox/templates/dcim/modulebay.html new file mode 100644 index 000000000..a12e020e9 --- /dev/null +++ b/netbox/templates/dcim/modulebay.html @@ -0,0 +1,78 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block content %} +
    +
    +
    +
    Module Bay
    +
    + + + + + + + + + + + + + + + + + + + + + +
    Device + {{ object.device }} +
    Name{{ object.name }}
    Label{{ object.label|placeholder }}
    Position{{ object.position|placeholder }}
    Description{{ object.description|placeholder }}
    +
    +
    + {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/custom_fields.html' %} +
    +
    Installed Module
    +
    + {% if object.installed_module %} + {% with module=object.installed_module %} + + + + + + + + + +
    Manufacturer{{ module.module_type.manufacturer|linkify }}
    Module Type{{ module.module_type|linkify }}
    + {% endwith %} + {% else %} +
    None
    + {% endif %} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/modulebaytemplate_create.html b/netbox/templates/dcim/modulebaytemplate_create.html new file mode 100644 index 000000000..74323ac4b --- /dev/null +++ b/netbox/templates/dcim/modulebaytemplate_create.html @@ -0,0 +1,7 @@ +{% extends 'dcim/component_template_create.html' %} +{% load form_helpers %} + +{% block replication_fields %} + {{ block.super }} + {% render_field replication_form.position_pattern %} +{% endblock replication_fields %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html new file mode 100644 index 000000000..2c8e77be3 --- /dev/null +++ b/netbox/templates/dcim/moduletype.html @@ -0,0 +1,47 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
    +
    +
    +
    Module Type
    +
    + + + + + + + + + + + + + + + + + +
    Manufacturer{{ object.manufacturer|linkify }}
    Model Name{{ object.model }}
    Part Number{{ object.part_number|placeholder }}
    Instances{{ instance_count }}
    +
    +
    + {% include 'inc/panels/custom_fields.html' %} + {% plugin_left_page object %} +
    +
    + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_right_page object %} +
    +
    +
    +
    + {% plugin_full_width_page object %} +
    +
    +{% endblock %} diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html new file mode 100644 index 000000000..f5713efc3 --- /dev/null +++ b/netbox/templates/dcim/moduletype/base.html @@ -0,0 +1,102 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} + +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + +{% endblock %} + +{% block extra_controls %} + {% if perms.dcim.change_devicetype %} + + {% endif %} +{% endblock %} + +{% block extra_tabs %} + {% with interface_count=object.interfacetemplates.count %} + {% if interface_count %} + + {% endif %} + {% endwith %} + + {% with frontport_count=object.frontporttemplates.count %} + {% if frontport_count %} + + {% endif %} + {% endwith %} + + {% with rearport_count=object.rearporttemplates.count %} + {% if rearport_count %} + + {% endif %} + {% endwith %} + + {% with consoleport_count=object.consoleporttemplates.count %} + {% if consoleport_count %} + + {% endif %} + {% endwith %} + + {% with consoleserverport_count=object.consoleserverporttemplates.count %} + {% if consoleserverport_count %} + + {% endif %} + {% endwith %} + + {% with powerport_count=object.powerporttemplates.count %} + {% if powerport_count %} + + {% endif %} + {% endwith %} + + {% with poweroutlet_count=object.poweroutlettemplates.count %} + {% if poweroutlet_count %} + + {% endif %} + {% endwith %} +{% endblock %} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html new file mode 100644 index 000000000..d2806330a --- /dev/null +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -0,0 +1,44 @@ +{% extends 'dcim/moduletype/base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block content %} + {% if perms.dcim.change_moduletype %} +
    + {% csrf_token %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    + +
    +
    + {% else %} +
    +
    {{ title }}
    +
    + {% include 'htmx/table.html' %} +
    +
    + {% endif %} +{% endblock content %} diff --git a/netbox/templates/dcim/platform.html b/netbox/templates/dcim/platform.html index d5642fd94..8d1e3797a 100644 --- a/netbox/templates/dcim/platform.html +++ b/netbox/templates/dcim/platform.html @@ -37,13 +37,7 @@ Manufacturer - - {% if object.manufacturer %} - {{ object.manufacturer }} - {% else %} - None - {% endif %} - + {{ object.manufacturer|linkify|placeholder }} NAPALM Driver @@ -73,7 +67,7 @@ NAPALM Arguments
    -
    {{ object.napalm_args|render_json }}
    +
    {{ object.napalm_args|json }}
    {% include 'inc/panels/custom_fields.html' %} diff --git a/netbox/templates/dcim/powerfeed.html b/netbox/templates/dcim/powerfeed.html index 1824cac19..777af5563 100644 --- a/netbox/templates/dcim/powerfeed.html +++ b/netbox/templates/dcim/powerfeed.html @@ -24,37 +24,25 @@ - + - + - + - + - + - + diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html index 3f2c469af..6408bc759 100644 --- a/netbox/templates/dcim/poweroutlet.html +++ b/netbox/templates/dcim/poweroutlet.html @@ -20,9 +20,11 @@
    Power Panel - {{ object.power_panel }} - {{ object.power_panel|linkify }}
    Rack - {% if object.rack %} - {{ object.rack }} - {% else %} - None - {% endif %} - {{ object.rack|linkify|placeholder }}
    Type - {{ object.get_type_display }} - {% badge object.get_type_display bg_color=object.get_type_color %}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Connected Device {% if object.connected_endpoint %} - {{ object.connected_endpoint.device }} ({{ object.connected_endpoint }}) + {{ object.connected_endpoint.device|linkify }} ({{ object.connected_endpoint }}) {% else %} None {% endif %} @@ -126,7 +114,7 @@
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -135,15 +123,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type
    - + + + + + @@ -70,7 +72,7 @@ - + - + @@ -121,6 +119,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index 02db0e56f..b7fe8eb39 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -7,7 +7,7 @@ {{ block.super }} {% if object.location %} - + {% endif %} {% endblock %} @@ -22,19 +22,11 @@
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -79,15 +81,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type
    - + - +
    Site - {{ object.site }} - {{ object.site|linkify }}
    Location - {% if object.location %} - {{ object.location }} - {% else %} - None - {% endif %} - {{ object.location|linkify|placeholder }}
    diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html index f38edec8e..6dc06f528 100644 --- a/netbox/templates/dcim/powerport.html +++ b/netbox/templates/dcim/powerport.html @@ -20,9 +20,11 @@ - + + + + + @@ -70,7 +72,7 @@ - + - + @@ -131,6 +129,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 4eb94a0ce..6574e9b74 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -39,9 +39,9 @@ @@ -49,9 +49,9 @@ - + - + @@ -181,18 +169,10 @@ {% for powerfeed in power_feeds %} - - - + + + + {% with power_port=powerfeed.connected_endpoint %} {% if power_port %} @@ -222,16 +202,8 @@ {% for resv in reservations %} - - + + - + - + {% endwith %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -79,15 +81,11 @@ {% if object.connected_endpoint %}
    Device - {{ object.connected_endpoint.device }} - {{ object.connected_endpoint.device|linkify }}
    Name - {{ object.connected_endpoint.name }} - {{ object.connected_endpoint|linkify:"name" }}
    Type Site {% if object.site.region %} - {{ object.site.region }} / + {{ object.site.region|linkify }} / {% endif %} - {{ object.site }} + {{ object.site|linkify }}
    {% if object.location %} {% for location in object.location.get_ancestors %} - {{ location }} / + {{ location|linkify }} / {% endfor %} - {{ object.location }} + {{ object.location|linkify }} {% else %} None {% endif %} @@ -64,31 +64,19 @@
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Role - {% if object.role %} - {{ object.role }} - {% else %} - None - {% endif %} - {{ object.role|linkify|placeholder }}
    Serial Number
    - {{ powerfeed.power_panel.name }} - - - {{ powerfeed.name }} - - {{ powerfeed.get_status_display }} - - {{ powerfeed.get_type_display }} - {{ powerfeed.power_panel|linkify }}{{ powerfeed|linkify }}{% badge powerfeed.get_status_display bg_color=powerfeed.get_status_color %}{% badge powerfeed.get_type_display bg_color=powerfeed.get_type_color %}{% utilization_graph power_port.get_power_draw.allocated|percentage:powerfeed.available_power %}
    - {{ resv.unit_list }} - - {% if resv.tenant %} - {{ resv.tenant }} - {% else %} - None - {% endif %} - {{ resv|linkify:"unit_list" }}{{ resv.tenant|linkify|placeholder }} {{ resv.description }}
    {{ resv.user }} · {{ resv.created|annotated_date }} diff --git a/netbox/templates/dcim/rackreservation.html b/netbox/templates/dcim/rackreservation.html index 1e16af675..ebdd1d845 100644 --- a/netbox/templates/dcim/rackreservation.html +++ b/netbox/templates/dcim/rackreservation.html @@ -24,26 +24,18 @@
    Site {% if rack.site.region %} - {{ rack.site.region }} / + {{ rack.site.region|linkify }} / {% endif %} - {{ rack.site }} + {{ rack.site|linkify }}
    Location - {% if rack.location %} - {{ rack.location }} - {% else %} - None - {% endif %} - {{ rack.location|linkify|placeholder }}
    Rack - {{ rack }} - {{ rack|linkify }}
    @@ -62,14 +54,10 @@ Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }} diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html index 311ccd7ff..3cdccbd7c 100644 --- a/netbox/templates/dcim/rearport.html +++ b/netbox/templates/dcim/rearport.html @@ -20,9 +20,11 @@ - + + + + + @@ -72,7 +74,7 @@ - + diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index cf03fbf2e..fe0a558eb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -30,9 +30,9 @@ - + @@ -78,17 +72,6 @@ - - - - - {# Legacy contact fields #} - {% with deprecation_warning="This field will be removed in NetBox v3.2. Please migrate this data to contact objects." %} - - - - - - - - - - - - - {% endwith %}
    Device - {{ object.device }} - {{ object.device|linkify }}
    Module{{ object.module|linkify|placeholder }}
    Name
    Cable - {{ object.cable }} + {{ object.cable|linkify }} @@ -117,6 +119,7 @@ {% endif %} + {% include 'dcim/inc/panels/inventory_items.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 10ecb9ea6..8a548dd4a 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -37,13 +37,7 @@
    Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - {{ object.parent|linkify|placeholder }}
    Sites {% if object.region %} {% for region in object.region.get_ancestors %} - {{ region }} / + {{ region|linkify }} / {% endfor %} - {{ object.region }} + {{ object.region|linkify }} {% else %} None {% endif %} @@ -43,9 +43,9 @@ {% if object.group %} {% for group in object.group.get_ancestors %} - {{ group }} / + {{ group|linkify }} / {% endfor %} - {{ object.group }} + {{ object.group|linkify }} {% else %} None {% endif %} @@ -53,21 +53,15 @@
    Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
    Tenant - {% if object.tenant %} - {% if object.tenant.group %} - {{ object.tenant.group }} / - {% endif %} - {{ object.tenant }} - {% else %} - None + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / {% endif %} + {{ object.tenant|linkify|placeholder }}
    Description {{ object.description|placeholder }}
    AS Number - {% if object.asn %} -
    - -
    - {% endif %} - {{ object.asn|placeholder }} -
    Time Zone @@ -134,46 +117,6 @@ {% endif %}
    Contact Name - {% if object.contact_name %} -
    - -
    - {% endif %} - {{ object.contact_name|placeholder }} -
    Contact Phone - {% if object.contact_phone %} -
    - -
    - {{ object.contact_phone }} - {% else %} - - {% endif %} -
    Contact E-Mail - {% if object.contact_email %} -
    - -
    - {{ object.contact_email }} - {% else %} - - {% endif %} -
    @@ -295,7 +238,7 @@ {% for i in location.level|as_range %}{% endfor %} - {{ location }} + {{ location|linkify }} {{ location.rack_count }} @@ -334,7 +277,7 @@ {% for asn in asns %} - {{ asn }} + {{ asn|linkify }} {{ asn.description|placeholder }} {% endfor %} diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 49f91b812..396271147 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -37,13 +37,7 @@ Parent - - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - + {{ object.parent|linkify|placeholder }} Sites diff --git a/netbox/templates/dcim/trace/cable.html b/netbox/templates/dcim/trace/cable.html index 5f8fb01eb..0289e1091 100644 --- a/netbox/templates/dcim/trace/cable.html +++ b/netbox/templates/dcim/trace/cable.html @@ -12,7 +12,7 @@ {% if cable.length %} ({{ cable.length|floatformat }} {{ cable.get_length_unit_display }})
    {% endif %} - {{ cable.get_status_display }}
    + {% badge object.get_status_display bg_color=object.get_status_color %}
    {% for tag in cable.tags.all %} {% tag tag 'dcim:cable_list' %} {% endfor %} diff --git a/netbox/templates/dcim/trace/circuit.html b/netbox/templates/dcim/trace/circuit.html index 70e191dd2..330099235 100644 --- a/netbox/templates/dcim/trace/circuit.html +++ b/netbox/templates/dcim/trace/circuit.html @@ -1,5 +1,5 @@
    - {{ circuit }}
    + {{ circuit|linkify }}
    Circuit
    - {{ circuit.provider }} + {{ circuit.provider|linkify }}
    diff --git a/netbox/templates/dcim/trace/device.html b/netbox/templates/dcim/trace/device.html index b1d37b76c..30d1d7149 100644 --- a/netbox/templates/dcim/trace/device.html +++ b/netbox/templates/dcim/trace/device.html @@ -1,11 +1,11 @@
    - {{ device }}
    + {{ device|linkify }}
    {{ device.device_type.manufacturer }} {{ device.device_type }}
    - {{ device.site }} + {{ device.site|linkify }} {% if device.location %} - / {{ device.location }} + / {{ device.location|linkify }} {% endif %} {% if device.rack %} - / {{ device.rack }} + / {{ device.rack|linkify }} {% endif %}
    diff --git a/netbox/templates/dcim/trace/object.html b/netbox/templates/dcim/trace/object.html index 72e5b5787..fe9fe002b 100644 --- a/netbox/templates/dcim/trace/object.html +++ b/netbox/templates/dcim/trace/object.html @@ -1,3 +1,3 @@
    - {{ object }} + {{ object|linkify }}
    diff --git a/netbox/templates/dcim/trace/powerpanel.html b/netbox/templates/dcim/trace/powerpanel.html index f5b6230a7..d89b408fb 100644 --- a/netbox/templates/dcim/trace/powerpanel.html +++ b/netbox/templates/dcim/trace/powerpanel.html @@ -1,5 +1,5 @@
    - {{ powerpanel }}
    + {{ powerpanel|linkify }}
    Power Panel
    - {{ powerpanel.site }} + {{ powerpanel.site|linkify }}
    diff --git a/netbox/templates/dcim/trace/termination.html b/netbox/templates/dcim/trace/termination.html index f2dbca87f..3a2dbded2 100644 --- a/netbox/templates/dcim/trace/termination.html +++ b/netbox/templates/dcim/trace/termination.html @@ -1,6 +1,6 @@ {% load helpers %}
    - {{ termination }}
    + {{ termination|linkify }}
    {{ termination|meta:"verbose_name"|bettertitle }} {% if termination.type %} {{ termination.get_type_display }} diff --git a/netbox/templates/dcim/virtualchassis.html b/netbox/templates/dcim/virtualchassis.html index 2e1344f71..4683b775b 100644 --- a/netbox/templates/dcim/virtualchassis.html +++ b/netbox/templates/dcim/virtualchassis.html @@ -27,13 +27,7 @@ Master - - {% if object.master %} - {{ object.master }} - {% else %} - - {% endif %} - + {{ object.master|linkify }}
    @@ -58,7 +52,7 @@ {% for vc_member in members %} - {{ vc_member }} + {{ vc_member|linkify }} {% badge vc_member.vc_position show_empty=True %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index a37ff102a..327f20531 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -51,9 +51,7 @@ {% endfor %} {% with device=form.instance virtual_chassis=vc_form.instance %} - - {{ device }} - + {{ device|linkify }} {{ device.pk }} {% if device.rack %} diff --git a/netbox/templates/extras/configcontext.html b/netbox/templates/extras/configcontext.html index 3a9cc2e0c..56ec52c07 100644 --- a/netbox/templates/extras/configcontext.html +++ b/netbox/templates/extras/configcontext.html @@ -56,7 +56,7 @@
      {% for object in objects %} -
    • {{ object }}
    • +
    • {{ object|linkify }}
    • {% empty %}
    • None
    • {% endfor %} diff --git a/netbox/templates/extras/configcontext_edit.html b/netbox/templates/extras/configcontext_edit.html index 4e4506dc6..7b37a69c6 100644 --- a/netbox/templates/extras/configcontext_edit.html +++ b/netbox/templates/extras/configcontext_edit.html @@ -20,6 +20,7 @@ {% render_field form.device_types %} {% render_field form.roles %} {% render_field form.platforms %} + {% render_field form.cluster_types %} {% render_field form.cluster_groups %} {% render_field form.clusters %} {% render_field form.tenant_groups %} diff --git a/netbox/templates/extras/customlink.html b/netbox/templates/extras/customlink.html index ebf50882c..1f3866182 100644 --- a/netbox/templates/extras/customlink.html +++ b/netbox/templates/extras/customlink.html @@ -19,6 +19,10 @@ Content Type {{ object.content_type }} + + Enabled + {% checkmark object.enabled %} + Group Name {{ object.group_name|placeholder }} diff --git a/netbox/templates/extras/htmx/report_result.html b/netbox/templates/extras/htmx/report_result.html index b04f0c78c..9b3e9db5f 100644 --- a/netbox/templates/extras/htmx/report_result.html +++ b/netbox/templates/extras/htmx/report_result.html @@ -60,7 +60,7 @@ {% endif %} - {{ message|render_markdown }} + {{ message|markdown }} {% endfor %} {% endfor %} diff --git a/netbox/templates/extras/htmx/script_result.html b/netbox/templates/extras/htmx/script_result.html index 0336bdfaa..425f35898 100644 --- a/netbox/templates/extras/htmx/script_result.html +++ b/netbox/templates/extras/htmx/script_result.html @@ -22,7 +22,7 @@ {{ forloop.counter }} {% log_level log.status %} - {{ log.message|render_markdown }} + {{ log.message|markdown }} {% empty %} diff --git a/netbox/templates/extras/imageattachment_edit.html b/netbox/templates/extras/imageattachment_edit.html index cd7d40a8e..75b2ce48b 100644 --- a/netbox/templates/extras/imageattachment_edit.html +++ b/netbox/templates/extras/imageattachment_edit.html @@ -1,16 +1,19 @@ {% extends 'generic/object_edit.html' %} {% load helpers %} +{% load form_helpers %} -{% block form_fields %} -
      - -
      -
      - {{ obj.parent }} +{% block form %} +
      +
      + +
      +
      + {{ object.parent|linkify }} +
      + {% render_form form %}
      - {{ block.super }} -{% endblock form_fields %} +{% endblock form %} diff --git a/netbox/templates/extras/inc/configcontext_data.html b/netbox/templates/extras/inc/configcontext_data.html index 48ca18543..a19ec1c96 100644 --- a/netbox/templates/extras/inc/configcontext_data.html +++ b/netbox/templates/extras/inc/configcontext_data.html @@ -1,5 +1,5 @@ {% load helpers %}
      -
      {% if format == 'json' %}{{ data|render_json }}{% elif format == 'yaml' %}{{ data|render_yaml }}{% else %}{{ data }}{% endif %}
      +
      {% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}
      diff --git a/netbox/templates/extras/journalentry.html b/netbox/templates/extras/journalentry.html index 2e7fcbbf5..d8f8114b5 100644 --- a/netbox/templates/extras/journalentry.html +++ b/netbox/templates/extras/journalentry.html @@ -9,7 +9,7 @@ {% block content %}
      -
      +
      Journal Entry @@ -18,33 +18,27 @@ - + - + - + - +
      Object - {{ object.assigned_object }} - {{ object.assigned_object|linkify }}
      Created - {{ object.created|annotated_date }} - {{ object.created|annotated_date }}
      Created By - {{ object.created_by }} - {{ object.created_by }}
      Kind - {{ object.get_kind_display }} - {% badge object.get_kind_display bg_color=object.get_kind_color %}
      + {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %}
      -
      +
      {% include 'inc/panels/comments.html' %}
      diff --git a/netbox/templates/extras/object_configcontext.html b/netbox/templates/extras/object_configcontext.html index 7edeeed27..ab730410e 100644 --- a/netbox/templates/extras/object_configcontext.html +++ b/netbox/templates/extras/object_configcontext.html @@ -43,7 +43,7 @@
      {{ context.weight }}
      - {{ context.name }} + {{ context|linkify:"name" }}"> {% if context.description %}
      {{ context.description }} {% endif %} diff --git a/netbox/templates/extras/object_journal.html b/netbox/templates/extras/object_journal.html index 5f3b991fa..363b067a8 100644 --- a/netbox/templates/extras/object_journal.html +++ b/netbox/templates/extras/object_journal.html @@ -11,11 +11,7 @@

      New Journal Entry

      {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} - {% render_field form.kind %} - {% render_field form.comments %} + {% render_form form %}
      Cancel diff --git a/netbox/templates/extras/objectchange.html b/netbox/templates/extras/objectchange.html index f7d5a8f10..c66f675bc 100644 --- a/netbox/templates/extras/objectchange.html +++ b/netbox/templates/extras/objectchange.html @@ -61,7 +61,7 @@ Object {% if object.changed_object and object.changed_object.get_absolute_url %} - {{ object.changed_object }} + {{ object.changed_object|linkify }} {% else %} {{ object.object_repr }} {% endif %} @@ -102,8 +102,8 @@ {% endif %} {% else %} -
      {{ diff_removed|render_json }}
      -
      {{ diff_added|render_json }}
      +
      {{ diff_removed|json }}
      +
      {{ diff_added|json }}
      {% endif %}
      @@ -118,7 +118,7 @@
      {% if object.prechange_data %}
      {% for k, v in object.prechange_data.items %}{% spaceless %}
      -                    {{ k }}: {{ v|render_json }}
      +                    {{ k }}: {{ v|json }}
                       {% endspaceless %}{% endfor %}
                       
      {% elif non_atomic_change %} @@ -137,7 +137,7 @@
      {% if object.postchange_data %}
      {% for k, v in object.postchange_data.items %}{% spaceless %}
      -                        {{ k }}: {{ v|render_json }}
      +                        {{ k }}: {{ v|json }}
                               {% endspaceless %}{% endfor %}
                           
      {% else %} diff --git a/netbox/templates/extras/report.html b/netbox/templates/extras/report.html index 68e888097..391de6614 100644 --- a/netbox/templates/extras/report.html +++ b/netbox/templates/extras/report.html @@ -15,7 +15,7 @@ {% block subtitle %} {% if report.description %}
      -
      {{ report.description|render_markdown }}
      +
      {{ report.description|markdown }}
      {% endif %} {% endblock subtitle %} diff --git a/netbox/templates/extras/report_list.html b/netbox/templates/extras/report_list.html index 99d6da730..56b92c96d 100644 --- a/netbox/templates/extras/report_list.html +++ b/netbox/templates/extras/report_list.html @@ -40,7 +40,7 @@ {% include 'extras/inc/job_label.html' with result=report.result %} - {{ report.description|render_markdown|placeholder }} + {{ report.description|markdown|placeholder }} {% if report.result %} {{ report.result.created|annotated_date }} diff --git a/netbox/templates/extras/script.html b/netbox/templates/extras/script.html index 7d37b0cbb..99eade0a0 100644 --- a/netbox/templates/extras/script.html +++ b/netbox/templates/extras/script.html @@ -16,7 +16,7 @@ {% block subtitle %}
      -
      {{ script.Meta.description|render_markdown }}
      +
      {{ script.Meta.description|markdown }}
      {% endblock subtitle %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 1c502aacd..8884ff77c 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -40,7 +40,7 @@ {% include 'extras/inc/job_label.html' with result=script.result %} - {{ script.Meta.description|render_markdown|placeholder }} + {{ script.Meta.description|markdown|placeholder }} {% if script.result %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 41368ecad..1c311ff26 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -4,7 +4,7 @@ {% block title %}{{ script }}{% endblock %} {% block subtitle %} - {{ script.Meta.description|render_markdown }} + {{ script.Meta.description|markdown }} {% endblock %} {% block header %} diff --git a/netbox/templates/extras/webhook.html b/netbox/templates/extras/webhook.html index 6891930bc..78aaaa105 100644 --- a/netbox/templates/extras/webhook.html +++ b/netbox/templates/extras/webhook.html @@ -108,7 +108,7 @@
      {% if object.conditions %} -
      {{ object.conditions|render_json }}
      +
      {{ object.conditions|json }}
      {% else %}

      None

      {% endif %} diff --git a/netbox/templates/generic/object_bulk_add_component.html b/netbox/templates/generic/bulk_add_component.html similarity index 100% rename from netbox/templates/generic/object_bulk_add_component.html rename to netbox/templates/generic/bulk_add_component.html diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html new file mode 100644 index 000000000..1bcc2db1d --- /dev/null +++ b/netbox/templates/generic/bulk_delete.html @@ -0,0 +1,45 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% comment %} +Blocks: + message_extra: Supplementary warning message content + +Context: + model: The model class of the objects being deleted + form: The bulk delete form class + table: The table class for rendering list of objects being deleted + return_url: The URL to which the user is redirected after submitting the form +{% endcomment %} + +{% block title %}Delete {{ table.rows|length }} {{ model|meta:"verbose_name_plural"|bettertitle }}?{% endblock %} + +{% block content %} +
      + +
      +
      +
      + {% render_table table 'inc/table.html' %} +
      +
      +
      + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
      + Cancel + +
      +
      +
      +
      +{% endblock content %} diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html new file mode 100644 index 000000000..362644135 --- /dev/null +++ b/netbox/templates/generic/bulk_edit.html @@ -0,0 +1,141 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load form_helpers %} +{% load render_table from django_tables2 %} + +{% comment %} +Context: + model: The model class of the objects being modified + form: The bulk edit form class + table: The table class for rendering list of objects being modified + return_url: The URL to which the user is redirected after submitting the form +{% endcomment %} + +{% block title %}Editing {{ table.rows|length }} {{ model|meta:"verbose_name_plural"|bettertitle }}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content-wrapper %} +
      + {% block content %} + + {# Edit form #} +
      +
      + + {% csrf_token %} + {% if request.POST.return_url %} + + {% endif %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + +
      +
      +
      +
      + {% if form.fieldsets %} + + {# Render grouped fields according to declared fieldsets #} + {% for group, fields in form.fieldsets %} +
      +
      +
      + {% if group %}{{ group }}{% else %}{{ model|meta:"verbose_name"|bettertitle }}{% endif %} +
      +
      + {% for name in fields %} + {% with field=form|getfield:name %} + {% if field.name in form.nullable_fields %} + {% render_field field bulk_nullable=True %} + {% else %} + {% render_field field %} + {% endif %} + {% endwith %} + {% endfor %} +
      + {% endfor %} + + {# Render tag add/remove fields #} + {% if form.add_tags and form.remove_tags %} +
      +
      +
      Tags
      +
      + {% render_field form.add_tags %} + {% render_field form.remove_tags %} +
      + {% endif %} + + {# Render custom fields #} + {% if form.custom_fields %} +
      +
      +
      Custom Fields
      +
      + {% render_custom_fields form %} +
      + {% endif %} + + {# Render comments #} + {% if form.comments %} +
      +
      +
      Comments
      +
      + {% render_field form.comments bulk_nullable=True %} +
      + {% endif %} + + {% else %} + + {# Render all fields #} + {% for field in form.visible_fields %} + {% if field.name in form.nullable_fields %} + {% render_field field bulk_nullable=True %} + {% else %} + {% render_field field %} + {% endif %} + {% endfor %} + + {% endif %} +
      +
      + +
      + Cancel + +
      +
      +
      + +
      +
      + + {# Selected objects list #} +
      +
      +
      + {% render_table table 'inc/table.html' %} +
      +
      +
      + + {% endblock content %} +
      +{% endblock content-wrapper %} diff --git a/netbox/templates/generic/object_bulk_import.html b/netbox/templates/generic/bulk_import.html similarity index 95% rename from netbox/templates/generic/object_bulk_import.html rename to netbox/templates/generic/bulk_import.html index 26ab2edf2..43e078826 100644 --- a/netbox/templates/generic/object_bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -2,7 +2,15 @@ {% load helpers %} {% load form_helpers %} -{% block title %}{{ obj_type|bettertitle }} Bulk Import{% endblock %} +{% comment %} +Context: + model: The model class being imported + form: The bulk import form + fields: A dictionary of form fields, to display import options (optional) + return_url: The URL to which the user is redirected after submitting the form +{% endcomment %} + +{% block title %}{{ model|meta:"verbose_name"|bettertitle }} Bulk Import{% endblock %} {% block tabs %} -{% endblock %} +{% endblock tabs %} {% block content-wrapper %}
      @@ -145,4 +153,4 @@
      {% endblock content %}
      -{% endblock %} +{% endblock content-wrapper %} diff --git a/netbox/templates/generic/object_bulk_remove.html b/netbox/templates/generic/bulk_remove.html similarity index 100% rename from netbox/templates/generic/object_bulk_remove.html rename to netbox/templates/generic/bulk_remove.html diff --git a/netbox/templates/generic/object_bulk_rename.html b/netbox/templates/generic/bulk_rename.html similarity index 100% rename from netbox/templates/generic/object_bulk_rename.html rename to netbox/templates/generic/bulk_rename.html diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 4d616f944..451c530e1 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -5,6 +5,18 @@ {% load perms %} {% load plugins %} +{% comment %} +Blocks: + breadcrumbs: Breadcrumb list items (HTML
    • elements) + object_identifier: Unique identifier for the object + extra_controls: Additional action buttons to display + extra_tabs: Additional tabs to include + content: Page content + +Context: + object: The object instance being viewed +{% endcomment %} + {% block header %}
      {# Breadcrumbs #} @@ -66,11 +78,15 @@ {% block tabs %}
      {% endif %} + {{ block.super }} {% endblock %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index 6fcaf2a3e..635654f86 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -21,9 +21,7 @@ - + @@ -41,23 +39,15 @@ - + - + + + + + diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html index 687c6c090..496960a64 100644 --- a/netbox/templates/virtualization/vminterface_edit.html +++ b/netbox/templates/virtualization/vminterface_edit.html @@ -2,21 +2,27 @@ {% load form_helpers %} {% block form %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
      Interface
      {% if form.instance.virtual_machine %} -
      - -
      - -
      +
      + +
      +
      +
      {% endif %} {% render_field form.name %} {% render_field form.description %} {% render_field form.mac_address %} + {% render_field form.vrf %} {% render_field form.mtu %} {% render_field form.tags %} {% render_field form.enabled %} @@ -52,8 +58,8 @@ {% block buttons %} Cancel - {% if obj.pk %} - + {% if object.pk %} + {% else %} diff --git a/netbox/templates/wireless/inc/wirelesslink_interface.html b/netbox/templates/wireless/inc/wirelesslink_interface.html index e33047539..db4f84f0a 100644 --- a/netbox/templates/wireless/inc/wirelesslink_interface.html +++ b/netbox/templates/wireless/inc/wirelesslink_interface.html @@ -3,15 +3,11 @@
      Virtual Machine - {{ object.virtual_machine }} - {{ object.virtual_machine|linkify }}
      Name
      Parent - {% if object.parent %} - {{ object.parent }} - {% else %} - None - {% endif %} - {{ object.parent|linkify|placeholder }}
      Bridge - {% if object.bridge %} - {{ object.bridge }} - {% else %} - None - {% endif %} - {{ object.bridge|linkify|placeholder }}
      VRF{{ object.vrf|linkify|placeholder }}
      Description
      - + - + diff --git a/netbox/templates/wireless/wirelesslan.html b/netbox/templates/wireless/wirelesslan.html index a55d22974..185a44904 100644 --- a/netbox/templates/wireless/wirelesslan.html +++ b/netbox/templates/wireless/wirelesslan.html @@ -16,13 +16,7 @@ - + @@ -30,13 +24,7 @@ - +
      Device - {{ interface.device }} - {{ interface.device|linkify }}
      Interface - {{ interface }} - {{ interface|linkify }}
      Type
      Group - {% if object.group %} - {{ object.group }} - {% else %} - None - {% endif %} - {{ object.group|linkify|placeholder }}
      Description
      VLAN - {% if object.vlan %} - {{ object.vlan }} - {% else %} - None - {% endif %} - {{ object.vlan|linkify|placeholder }}
    • diff --git a/netbox/templates/wireless/wirelesslangroup.html b/netbox/templates/wireless/wirelesslangroup.html index 226b39b1b..2b8de0fb8 100644 --- a/netbox/templates/wireless/wirelesslangroup.html +++ b/netbox/templates/wireless/wirelesslangroup.html @@ -35,13 +35,7 @@ Parent - - {% if object.parent %} - {{ object.parent }} - {% else %} - - {% endif %} - + {{ object.parent|linkify|placeholder }} Wireless LANs diff --git a/netbox/templates/wireless/wirelesslink.html b/netbox/templates/wireless/wirelesslink.html index 6ad88729d..4795dcdde 100644 --- a/netbox/templates/wireless/wirelesslink.html +++ b/netbox/templates/wireless/wirelesslink.html @@ -17,9 +17,7 @@ - + diff --git a/netbox/tenancy/api/serializers.py b/netbox/tenancy/api/serializers.py index a0482aa1d..8749dc63f 100644 --- a/netbox/tenancy/api/serializers.py +++ b/netbox/tenancy/api/serializers.py @@ -3,7 +3,7 @@ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers from netbox.api import ChoiceField, ContentTypeField -from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from tenancy.choices import ContactPriorityChoices from tenancy.models import * from utilities.api import get_serializer_for_model @@ -27,7 +27,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer): ] -class TenantSerializer(PrimaryModelSerializer): +class TenantSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') group = NestedTenantGroupSerializer(required=False, allow_null=True) circuit_count = serializers.IntegerField(read_only=True) @@ -67,7 +67,7 @@ class ContactGroupSerializer(NestedGroupModelSerializer): ] -class ContactRoleSerializer(PrimaryModelSerializer): +class ContactRoleSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactrole-detail') class Meta: @@ -77,19 +77,19 @@ class ContactRoleSerializer(PrimaryModelSerializer): ] -class ContactSerializer(PrimaryModelSerializer): +class ContactSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contact-detail') group = NestedContactGroupSerializer(required=False, allow_null=True, default=None) class Meta: model = Contact fields = [ - 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', + 'id', 'url', 'display', 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] -class ContactAssignmentSerializer(PrimaryModelSerializer): +class ContactAssignmentSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') content_type = ContentTypeField( queryset=ContentType.objects.all() diff --git a/netbox/tenancy/api/urls.py b/netbox/tenancy/api/urls.py index 00e1a6469..7dbe59ea4 100644 --- a/netbox/tenancy/api/urls.py +++ b/netbox/tenancy/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.TenancyRootView # Tenants diff --git a/netbox/tenancy/api/views.py b/netbox/tenancy/api/views.py index fbc166bdb..39c86d80e 100644 --- a/netbox/tenancy/api/views.py +++ b/netbox/tenancy/api/views.py @@ -1,9 +1,9 @@ from rest_framework.routers import APIRootView from circuits.models import Circuit -from dcim.models import Device, Rack, Site, Cable -from extras.api.views import CustomFieldModelViewSet +from dcim.models import Device, Rack, Site from ipam.models import IPAddress, Prefix, VLAN, VRF +from netbox.api.viewsets import NetBoxModelViewSet from tenancy import filtersets from tenancy.models import * from utilities.utils import count_related @@ -23,7 +23,7 @@ class TenancyRootView(APIRootView): # Tenants # -class TenantGroupViewSet(CustomFieldModelViewSet): +class TenantGroupViewSet(NetBoxModelViewSet): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), Tenant, @@ -35,7 +35,7 @@ class TenantGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.TenantGroupFilterSet -class TenantViewSet(CustomFieldModelViewSet): +class TenantViewSet(NetBoxModelViewSet): queryset = Tenant.objects.prefetch_related( 'group', 'tags' ).annotate( @@ -58,7 +58,7 @@ class TenantViewSet(CustomFieldModelViewSet): # Contacts # -class ContactGroupViewSet(CustomFieldModelViewSet): +class ContactGroupViewSet(NetBoxModelViewSet): queryset = ContactGroup.objects.add_related_count( ContactGroup.objects.all(), Contact, @@ -70,19 +70,19 @@ class ContactGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.ContactGroupFilterSet -class ContactRoleViewSet(CustomFieldModelViewSet): +class ContactRoleViewSet(NetBoxModelViewSet): queryset = ContactRole.objects.prefetch_related('tags') serializer_class = serializers.ContactRoleSerializer filterset_class = filtersets.ContactRoleFilterSet -class ContactViewSet(CustomFieldModelViewSet): +class ContactViewSet(NetBoxModelViewSet): queryset = Contact.objects.prefetch_related('group', 'tags') serializer_class = serializers.ContactSerializer filterset_class = filtersets.ContactFilterSet -class ContactAssignmentViewSet(CustomFieldModelViewSet): +class ContactAssignmentViewSet(NetBoxModelViewSet): queryset = ContactAssignment.objects.prefetch_related('object', 'contact', 'role') serializer_class = serializers.ContactAssignmentSerializer filterset_class = filtersets.ContactAssignmentFilterSet diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 3ff45ab5c..8ca4ae29c 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -1,8 +1,7 @@ import django_filters from django.db.models import Q -from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import ContentTypeFilter, TreeNodeMultipleChoiceFilter from .models import * @@ -34,7 +33,6 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = ContactGroup @@ -42,18 +40,13 @@ class ContactGroupFilterSet(OrganizationalModelFilterSet): class ContactRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ContactRole fields = ['id', 'name', 'slug', 'description'] -class ContactFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ContactFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=ContactGroup.objects.all(), field_name='group', @@ -67,11 +60,10 @@ class ContactFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Contact group (slug)', ) - tag = TagFilter() class Meta: model = Contact - fields = ['id', 'name', 'title', 'phone', 'email', 'address'] + fields = ['id', 'name', 'title', 'phone', 'email', 'address', 'link'] def search(self, queryset, name, value): if not value.strip(): @@ -82,6 +74,7 @@ class ContactFilterSet(PrimaryModelFilterSet): Q(phone__icontains=value) | Q(email__icontains=value) | Q(address__icontains=value) | + Q(link__icontains=value) | Q(comments__icontains=value) ) @@ -136,18 +129,13 @@ class TenantGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = TenantGroup fields = ['id', 'name', 'slug', 'description'] -class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class TenantFilterSet(NetBoxModelFilterSet, ContactModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=TenantGroup.objects.all(), field_name='group', @@ -161,7 +149,6 @@ class TenantFilterSet(PrimaryModelFilterSet, ContactModelFilterSet): to_field_name='slug', label='Tenant group (slug)', ) - tag = TagFilter() class Meta: model = Tenant diff --git a/netbox/tenancy/forms/bulk_edit.py b/netbox/tenancy/forms/bulk_edit.py index 9dc1b8ec5..4c1f03757 100644 --- a/netbox/tenancy/forms/bulk_edit.py +++ b/netbox/tenancy/forms/bulk_edit.py @@ -1,6 +1,6 @@ from django import forms -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import * from utilities.forms import DynamicModelChoiceField @@ -17,11 +17,7 @@ __all__ = ( # Tenants # -class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=TenantGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) +class TenantGroupBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False @@ -31,35 +27,28 @@ class TenantGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] + model = TenantGroup + nullable_fields = ('parent', 'description') -class TenantBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Tenant.objects.all(), - widget=forms.MultipleHiddenInput() - ) +class TenantBulkEditForm(NetBoxModelBulkEditForm): group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False ) - class Meta: - nullable_fields = [ - 'group', - ] + model = Tenant + fieldsets = ( + (None, ('group',)), + ) + nullable_fields = ('group',) # # Contacts # -class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ContactGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) +class ContactGroupBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False @@ -69,29 +58,27 @@ class ContactGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class ContactRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ContactRole.objects.all(), - widget=forms.MultipleHiddenInput + model = ContactGroup + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class ContactRoleBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Contact.objects.all(), - widget=forms.MultipleHiddenInput() + model = ContactRole + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class ContactBulkEditForm(NetBoxModelBulkEditForm): group = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False @@ -111,6 +98,12 @@ class ContactBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): max_length=200, required=False ) + link = forms.URLField( + required=False + ) - class Meta: - nullable_fields = ['group', 'title', 'phone', 'email', 'address', 'comments'] + model = Contact + fieldsets = ( + (None, ('group', 'title', 'phone', 'email', 'address', 'link')), + ) + nullable_fields = ('group', 'title', 'phone', 'email', 'address', 'link', 'comments') diff --git a/netbox/tenancy/forms/bulk_import.py b/netbox/tenancy/forms/bulk_import.py index 51b863cac..d617a27b5 100644 --- a/netbox/tenancy/forms/bulk_import.py +++ b/netbox/tenancy/forms/bulk_import.py @@ -1,4 +1,4 @@ -from extras.forms import CustomFieldModelCSVForm +from netbox.forms import NetBoxModelCSVForm from tenancy.models import * from utilities.forms import CSVModelChoiceField, SlugField @@ -15,7 +15,7 @@ __all__ = ( # Tenants # -class TenantGroupCSVForm(CustomFieldModelCSVForm): +class TenantGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=TenantGroup.objects.all(), required=False, @@ -29,7 +29,7 @@ class TenantGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class TenantCSVForm(CustomFieldModelCSVForm): +class TenantCSVForm(NetBoxModelCSVForm): slug = SlugField() group = CSVModelChoiceField( queryset=TenantGroup.objects.all(), @@ -47,7 +47,7 @@ class TenantCSVForm(CustomFieldModelCSVForm): # Contacts # -class ContactGroupCSVForm(CustomFieldModelCSVForm): +class ContactGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -61,7 +61,7 @@ class ContactGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class ContactRoleCSVForm(CustomFieldModelCSVForm): +class ContactRoleCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -69,7 +69,7 @@ class ContactRoleCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ContactCSVForm(CustomFieldModelCSVForm): +class ContactCSVForm(NetBoxModelCSVForm): group = CSVModelChoiceField( queryset=ContactGroup.objects.all(), required=False, @@ -79,4 +79,4 @@ class ContactCSVForm(CustomFieldModelCSVForm): class Meta: model = Contact - fields = ('name', 'title', 'phone', 'email', 'address', 'group', 'comments') + fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'comments') diff --git a/netbox/tenancy/forms/filtersets.py b/netbox/tenancy/forms/filtersets.py index ada279d9d..15d7773b7 100644 --- a/netbox/tenancy/forms/filtersets.py +++ b/netbox/tenancy/forms/filtersets.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext as _ -from extras.forms import CustomFieldModelFilterForm +from netbox.forms import NetBoxModelFilterSetForm from tenancy.models import * from tenancy.forms import ContactModelFilterForm from utilities.forms import DynamicModelMultipleChoiceField, TagFilterField @@ -18,7 +18,7 @@ __all__ = ( # Tenants # -class TenantGroupFilterForm(CustomFieldModelFilterForm): +class TenantGroupFilterForm(NetBoxModelFilterSetForm): model = TenantGroup parent_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), @@ -28,12 +28,11 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class TenantFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = Tenant - field_groups = ( - ('q', 'tag'), - ('group_id',), - ('contact', 'contact_role') + fieldsets = ( + (None, ('q', 'tag', 'group_id')), + ('Contacts', ('contact', 'contact_role')) ) group_id = DynamicModelMultipleChoiceField( queryset=TenantGroup.objects.all(), @@ -48,7 +47,7 @@ class TenantFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): # Contacts # -class ContactGroupFilterForm(CustomFieldModelFilterForm): +class ContactGroupFilterForm(NetBoxModelFilterSetForm): model = ContactGroup parent_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), @@ -58,17 +57,13 @@ class ContactGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class ContactRoleFilterForm(CustomFieldModelFilterForm): +class ContactRoleFilterForm(NetBoxModelFilterSetForm): model = ContactRole tag = TagFilterField(model) -class ContactFilterForm(CustomFieldModelFilterForm): +class ContactFilterForm(NetBoxModelFilterSetForm): model = Contact - field_groups = ( - ('q', 'tag'), - ('group_id',), - ) group_id = DynamicModelMultipleChoiceField( queryset=ContactGroup.objects.all(), required=False, diff --git a/netbox/tenancy/forms/models.py b/netbox/tenancy/forms/models.py index 398a44c9b..021e36a5b 100644 --- a/netbox/tenancy/forms/models.py +++ b/netbox/tenancy/forms/models.py @@ -1,11 +1,9 @@ from django import forms -from extras.forms import CustomFieldModelForm -from extras.models import Tag +from netbox.forms import NetBoxModelForm from tenancy.models import * from utilities.forms import ( - BootstrapMixin, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, SmallTextarea, - StaticSelect, + BootstrapMixin, CommentField, DynamicModelChoiceField, SlugField, SmallTextarea, StaticSelect, ) __all__ = ( @@ -22,16 +20,12 @@ __all__ = ( # Tenants # -class TenantGroupForm(CustomFieldModelForm): +class TenantGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = TenantGroup @@ -40,16 +34,16 @@ class TenantGroupForm(CustomFieldModelForm): ] -class TenantForm(CustomFieldModelForm): +class TenantForm(NetBoxModelForm): slug = SlugField() group = DynamicModelChoiceField( queryset=TenantGroup.objects.all(), required=False ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), ) class Meta: @@ -57,61 +51,47 @@ class TenantForm(CustomFieldModelForm): fields = ( 'name', 'slug', 'group', 'description', 'comments', 'tags', ) - fieldsets = ( - ('Tenant', ('name', 'slug', 'group', 'description', 'tags')), - ) # # Contacts # -class ContactGroupForm(CustomFieldModelForm): +class ContactGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = ContactGroup fields = ('parent', 'name', 'slug', 'description', 'tags') -class ContactRoleForm(CustomFieldModelForm): +class ContactRoleForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = ContactRole fields = ('name', 'slug', 'description', 'tags') -class ContactForm(CustomFieldModelForm): +class ContactForm(NetBoxModelForm): group = DynamicModelChoiceField( queryset=ContactGroup.objects.all(), required=False ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'link', 'tags')), ) class Meta: model = Contact fields = ( - 'group', 'name', 'title', 'phone', 'email', 'address', 'comments', 'tags', - ) - fieldsets = ( - ('Contact', ('group', 'name', 'title', 'phone', 'email', 'address', 'tags')), + 'group', 'name', 'title', 'phone', 'email', 'address', 'link', 'comments', 'tags', ) widgets = { 'address': SmallTextarea(attrs={'rows': 3}), diff --git a/netbox/tenancy/graphql/types.py b/netbox/tenancy/graphql/types.py index ce00eafa3..e0b99c2eb 100644 --- a/netbox/tenancy/graphql/types.py +++ b/netbox/tenancy/graphql/types.py @@ -1,7 +1,7 @@ import graphene from tenancy import filtersets, models -from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType __all__ = ( 'ContactAssignmentType', @@ -24,7 +24,7 @@ class ContactAssignmentsMixin: # Tenants # -class TenantType(PrimaryObjectType): +class TenantType(NetBoxObjectType): class Meta: model = models.Tenant @@ -44,7 +44,7 @@ class TenantGroupType(OrganizationalObjectType): # Contacts # -class ContactType(ContactAssignmentsMixin, PrimaryObjectType): +class ContactType(ContactAssignmentsMixin, NetBoxObjectType): class Meta: model = models.Contact diff --git a/netbox/tenancy/migrations/0005_standardize_id_fields.py b/netbox/tenancy/migrations/0005_standardize_id_fields.py new file mode 100644 index 000000000..514478f17 --- /dev/null +++ b/netbox/tenancy/migrations/0005_standardize_id_fields.py @@ -0,0 +1,49 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0004_extend_tag_support'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='contact', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='contactrole', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenant', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='tenantgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='contactassignment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + ] diff --git a/netbox/tenancy/migrations/0006_created_datetimefield.py b/netbox/tenancy/migrations/0006_created_datetimefield.py new file mode 100644 index 000000000..a972809e2 --- /dev/null +++ b/netbox/tenancy/migrations/0006_created_datetimefield.py @@ -0,0 +1,43 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0005_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='contact', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='contactassignment', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='contactgroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='contactrole', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='tenant', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='tenantgroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/tenancy/migrations/0007_contact_link.py b/netbox/tenancy/migrations/0007_contact_link.py new file mode 100644 index 000000000..43b7495e5 --- /dev/null +++ b/netbox/tenancy/migrations/0007_contact_link.py @@ -0,0 +1,17 @@ +from django.db import migrations, models +import utilities.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenancy', '0006_created_datetimefield'), + ] + + operations = [ + migrations.AddField( + model_name='contact', + name='link', + field=models.URLField(blank=True), + ), + ] diff --git a/netbox/tenancy/models/contacts.py b/netbox/tenancy/models/contacts.py index 49e690fd3..75ec9f69c 100644 --- a/netbox/tenancy/models/contacts.py +++ b/netbox/tenancy/models/contacts.py @@ -4,8 +4,8 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features -from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NestedGroupModel, OrganizationalModel, NetBoxModel +from netbox.models.features import WebhooksMixin from tenancy.choices import * __all__ = ( @@ -16,7 +16,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactGroup(NestedGroupModel): """ An arbitrary collection of Contacts. @@ -50,7 +49,6 @@ class ContactGroup(NestedGroupModel): return reverse('tenancy:contactgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ContactRole(OrganizationalModel): """ Functional role for a Contact assigned to an object. @@ -78,8 +76,7 @@ class ContactRole(OrganizationalModel): return reverse('tenancy:contactrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Contact(PrimaryModel): +class Contact(NetBoxModel): """ Contact information for a particular object(s) in NetBox. """ @@ -108,6 +105,9 @@ class Contact(PrimaryModel): max_length=200, blank=True ) + link = models.URLField( + blank=True + ) comments = models.TextField( blank=True ) @@ -129,13 +129,12 @@ class Contact(PrimaryModel): return reverse('tenancy:contact', args=[self.pk]) -@extras_features('webhooks') -class ContactAssignment(ChangeLoggedModel): +class ContactAssignment(WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() object = GenericForeignKey( ct_field='content_type', fk_field='object_id' diff --git a/netbox/tenancy/models/tenants.py b/netbox/tenancy/models/tenants.py index d480f9112..88d8d52f1 100644 --- a/netbox/tenancy/models/tenants.py +++ b/netbox/tenancy/models/tenants.py @@ -3,8 +3,7 @@ from django.db import models from django.urls import reverse from mptt.models import TreeForeignKey -from extras.utils import extras_features -from netbox.models import NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel __all__ = ( 'Tenant', @@ -12,7 +11,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class TenantGroup(NestedGroupModel): """ An arbitrary collection of Tenants. @@ -45,8 +43,7 @@ class TenantGroup(NestedGroupModel): return reverse('tenancy:tenantgroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Tenant(PrimaryModel): +class Tenant(NetBoxModel): """ A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal department. diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py deleted file mode 100644 index 6b0d9fc9e..000000000 --- a/netbox/tenancy/tables.py +++ /dev/null @@ -1,183 +0,0 @@ -import django_tables2 as tables - -from utilities.tables import ( - BaseTable, ButtonsColumn, ContentTypeColumn, LinkedCountColumn, linkify_phone, MarkdownColumn, MPTTColumn, - TagColumn, ToggleColumn, -) -from .models import * - -__all__ = ( - 'ContactAssignmentTable', - 'ContactGroupTable', - 'ContactRoleTable', - 'ContactTable', - 'TenantColumn', - 'TenantGroupTable', - 'TenantTable', -) - - -# -# Table columns -# - -class TenantColumn(tables.TemplateColumn): - """ - Include the tenant description. - """ - 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) if value else None - - -# -# Tenants -# - -class TenantGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( - linkify=True - ) - tenant_count = LinkedCountColumn( - viewname='tenancy:tenant_list', - url_params={'group_id': 'pk'}, - verbose_name='Tenants' - ) - tags = TagColumn( - url_name='tenancy:tenantgroup_list' - ) - actions = ButtonsColumn(TenantGroup) - - class Meta(BaseTable.Meta): - model = TenantGroup - fields = ( - 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions') - - -class TenantTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - group = tables.Column( - linkify=True - ) - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='tenancy:tenant_list' - ) - - class Meta(BaseTable.Meta): - model = Tenant - fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'group', 'description') - - -# -# Contacts -# - -class ContactGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( - linkify=True - ) - contact_count = LinkedCountColumn( - viewname='tenancy:contact_list', - url_params={'role_id': 'pk'}, - verbose_name='Contacts' - ) - tags = TagColumn( - url_name='tenancy:contactgroup_list' - ) - actions = ButtonsColumn(ContactGroup) - - class Meta(BaseTable.Meta): - model = ContactGroup - fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'contact_count', 'description', 'actions') - - -class ContactRoleTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - actions = ButtonsColumn(ContactRole) - - class Meta(BaseTable.Meta): - model = ContactRole - fields = ('pk', 'name', 'description', 'slug', 'actions', 'created', 'last_updated',) - default_columns = ('pk', 'name', 'description', 'actions') - - -class ContactTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - group = tables.Column( - linkify=True - ) - phone = tables.Column( - linkify=linkify_phone, - ) - comments = MarkdownColumn() - assignment_count = tables.Column( - verbose_name='Assignments' - ) - tags = TagColumn( - url_name='tenancy:tenant_list' - ) - - class Meta(BaseTable.Meta): - model = Contact - fields = ( - 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags', - 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') - - -class ContactAssignmentTable(BaseTable): - pk = ToggleColumn() - content_type = ContentTypeColumn( - verbose_name='Object Type' - ) - object = tables.Column( - linkify=True, - orderable=False - ) - contact = tables.Column( - linkify=True - ) - role = tables.Column( - linkify=True - ) - actions = ButtonsColumn( - model=ContactAssignment, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = ContactAssignment - fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') - default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') diff --git a/netbox/tenancy/tables/__init__.py b/netbox/tenancy/tables/__init__.py new file mode 100644 index 000000000..f74617c36 --- /dev/null +++ b/netbox/tenancy/tables/__init__.py @@ -0,0 +1,3 @@ +from .columns import * +from .contacts import * +from .tenants import * diff --git a/netbox/tenancy/tables/columns.py b/netbox/tenancy/tables/columns.py new file mode 100644 index 000000000..bb5aba5de --- /dev/null +++ b/netbox/tenancy/tables/columns.py @@ -0,0 +1,26 @@ +import django_tables2 as tables + +__all__ = ( + 'TenantColumn', +) + + +class TenantColumn(tables.TemplateColumn): + """ + Include the tenant description. + """ + 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) if value else None diff --git a/netbox/tenancy/tables/contacts.py b/netbox/tenancy/tables/contacts.py new file mode 100644 index 000000000..17abc5a5b --- /dev/null +++ b/netbox/tenancy/tables/contacts.py @@ -0,0 +1,95 @@ +import django_tables2 as tables + +from netbox.tables import NetBoxTable, columns +from tenancy.models import * +from utilities.tables import linkify_phone + +__all__ = ( + 'ContactAssignmentTable', + 'ContactGroupTable', + 'ContactRoleTable', + 'ContactTable', +) + + +class ContactGroupTable(NetBoxTable): + name = columns.MPTTColumn( + linkify=True + ) + contact_count = columns.LinkedCountColumn( + viewname='tenancy:contact_list', + url_params={'role_id': 'pk'}, + verbose_name='Contacts' + ) + tags = columns.TagColumn( + url_name='tenancy:contactgroup_list' + ) + + class Meta(NetBoxTable.Meta): + model = ContactGroup + fields = ( + 'pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'contact_count', 'description') + + +class ContactRoleTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = ContactRole + fields = ('pk', 'name', 'description', 'slug', 'created', 'last_updated', 'actions') + default_columns = ('pk', 'name', 'description') + + +class ContactTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + phone = tables.Column( + linkify=linkify_phone, + ) + comments = columns.MarkdownColumn() + assignment_count = tables.Column( + verbose_name='Assignments' + ) + tags = columns.TagColumn( + url_name='tenancy:tenant_list' + ) + + class Meta(NetBoxTable.Meta): + model = Contact + fields = ( + 'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'link', 'comments', 'assignment_count', 'tags', + 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email') + + +class ContactAssignmentTable(NetBoxTable): + content_type = columns.ContentTypeColumn( + verbose_name='Object Type' + ) + object = tables.Column( + linkify=True, + orderable=False + ) + contact = tables.Column( + linkify=True + ) + role = tables.Column( + linkify=True + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete') + ) + + class Meta(NetBoxTable.Meta): + model = ContactAssignment + fields = ('pk', 'content_type', 'object', 'contact', 'role', 'priority', 'actions') + default_columns = ('pk', 'content_type', 'object', 'contact', 'role', 'priority') diff --git a/netbox/tenancy/tables/tenants.py b/netbox/tenancy/tables/tenants.py new file mode 100644 index 000000000..5577d90e0 --- /dev/null +++ b/netbox/tenancy/tables/tenants.py @@ -0,0 +1,54 @@ +import django_tables2 as tables + +from netbox.tables import NetBoxTable, columns +from tenancy.models import * + +__all__ = ( + 'TenantGroupTable', + 'TenantTable', +) + + +class TenantGroupTable(NetBoxTable): + name = columns.MPTTColumn( + linkify=True + ) + tenant_count = columns.LinkedCountColumn( + viewname='tenancy:tenant_list', + url_params={'group_id': 'pk'}, + verbose_name='Tenants' + ) + tags = columns.TagColumn( + url_name='tenancy:tenantgroup_list' + ) + + class Meta(NetBoxTable.Meta): + model = TenantGroup + fields = ( + 'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'tenant_count', 'description') + + +class TenantTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='tenancy:tenant_list' + ) + + class Meta(NetBoxTable.Meta): + model = Tenant + fields = ( + 'pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'contacts', 'tags', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'group', 'description') diff --git a/netbox/tenancy/urls.py b/netbox/tenancy/urls.py index b20e1c3d1..214100275 100644 --- a/netbox/tenancy/urls.py +++ b/netbox/tenancy/urls.py @@ -1,7 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView, ObjectJournalView -from utilities.views import SlugRedirectView +from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * @@ -26,7 +25,6 @@ urlpatterns = [ path('tenants/edit/', views.TenantBulkEditView.as_view(), name='tenant_bulk_edit'), path('tenants/delete/', views.TenantBulkDeleteView.as_view(), name='tenant_bulk_delete'), path('tenants//', views.TenantView.as_view(), name='tenant'), - path('tenants//', SlugRedirectView.as_view(), kwargs={'model': Tenant}), 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}), diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index 4c9fffa1a..195871813 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -5,7 +5,6 @@ from circuits.models import Circuit from dcim.models import Cable, Device, Location, Rack, RackReservation, Site from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF, ASN from netbox.views import generic -from utilities.tables import paginate_table from utilities.utils import count_related from virtualization.models import VirtualMachine, Cluster from . import filtersets, forms, tables @@ -37,7 +36,7 @@ class TenantGroupView(generic.ObjectView): group=instance ) tenants_table = tables.TenantTable(tenants, exclude=('group',)) - paginate_table(tenants_table, request) + tenants_table.configure(request) return { 'tenants_table': tenants_table, @@ -46,7 +45,7 @@ class TenantGroupView(generic.ObjectView): class TenantGroupEditView(generic.ObjectEditView): queryset = TenantGroup.objects.all() - model_form = forms.TenantGroupForm + form = forms.TenantGroupForm class TenantGroupDeleteView(generic.ObjectDeleteView): @@ -123,7 +122,7 @@ class TenantView(generic.ObjectView): class TenantEditView(generic.ObjectEditView): queryset = Tenant.objects.all() - model_form = forms.TenantForm + form = forms.TenantForm class TenantDeleteView(generic.ObjectDeleteView): @@ -186,7 +185,7 @@ class ContactGroupView(generic.ObjectView): group=instance ) contacts_table = tables.ContactTable(contacts, exclude=('group',)) - paginate_table(contacts_table, request) + contacts_table.configure(request) return { 'child_groups_table': child_groups_table, @@ -196,7 +195,7 @@ class ContactGroupView(generic.ObjectView): class ContactGroupEditView(generic.ObjectEditView): queryset = ContactGroup.objects.all() - model_form = forms.ContactGroupForm + form = forms.ContactGroupForm class ContactGroupDeleteView(generic.ObjectDeleteView): @@ -253,7 +252,7 @@ class ContactRoleView(generic.ObjectView): ) contacts_table = tables.ContactAssignmentTable(contact_assignments) contacts_table.columns.hide('role') - paginate_table(contacts_table, request) + contacts_table.configure(request) return { 'contacts_table': contacts_table, @@ -263,7 +262,7 @@ class ContactRoleView(generic.ObjectView): class ContactRoleEditView(generic.ObjectEditView): queryset = ContactRole.objects.all() - model_form = forms.ContactRoleForm + form = forms.ContactRoleForm class ContactRoleDeleteView(generic.ObjectDeleteView): @@ -310,7 +309,7 @@ class ContactView(generic.ObjectView): ) assignments_table = tables.ContactAssignmentTable(contact_assignments) assignments_table.columns.hide('contact') - paginate_table(assignments_table, request) + assignments_table.configure(request) return { 'assignments_table': assignments_table, @@ -320,7 +319,7 @@ class ContactView(generic.ObjectView): class ContactEditView(generic.ObjectEditView): queryset = Contact.objects.all() - model_form = forms.ContactForm + form = forms.ContactForm class ContactDeleteView(generic.ObjectDeleteView): @@ -352,10 +351,10 @@ class ContactBulkDeleteView(generic.BulkDeleteView): class ContactAssignmentEditView(generic.ObjectEditView): queryset = ContactAssignment.objects.all() - model_form = forms.ContactAssignmentForm + form = forms.ContactAssignmentForm template_name = 'tenancy/contactassignment_edit.html' - def alter_obj(self, instance, request, args, kwargs): + def alter_object(self, instance, request, args, kwargs): if not instance.pk: # Assign the object based on URL kwargs content_type = get_object_or_404(ContentType, pk=request.GET.get('content_type')) diff --git a/netbox/users/api/urls.py b/netbox/users/api/urls.py index 15e4d1530..f46cc1680 100644 --- a/netbox/users/api/urls.py +++ b/netbox/users/api/urls.py @@ -1,10 +1,10 @@ from django.urls import include, path -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.UsersRootView # Users and groups diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index a8896e0ba..c3495afdf 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -9,7 +9,7 @@ from rest_framework.status import HTTP_201_CREATED from rest_framework.views import APIView from rest_framework.viewsets import ViewSet -from netbox.api.views import ModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from users import filtersets from users.models import ObjectPermission, Token, UserConfig from utilities.querysets import RestrictedQuerySet @@ -29,13 +29,13 @@ class UsersRootView(APIRootView): # Users and groups # -class UserViewSet(ModelViewSet): +class UserViewSet(NetBoxModelViewSet): queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') serializer_class = serializers.UserSerializer filterset_class = filtersets.UserFilterSet -class GroupViewSet(ModelViewSet): +class GroupViewSet(NetBoxModelViewSet): queryset = RestrictedQuerySet(model=Group).annotate(user_count=Count('user')).order_by('name') serializer_class = serializers.GroupSerializer filterset_class = filtersets.GroupFilterSet @@ -45,7 +45,7 @@ class GroupViewSet(ModelViewSet): # REST API tokens # -class TokenViewSet(ModelViewSet): +class TokenViewSet(NetBoxModelViewSet): queryset = RestrictedQuerySet(model=Token).prefetch_related('user') serializer_class = serializers.TokenSerializer filterset_class = filtersets.TokenFilterSet @@ -94,7 +94,7 @@ class TokenProvisionView(APIView): # ObjectPermissions # -class ObjectPermissionViewSet(ModelViewSet): +class ObjectPermissionViewSet(NetBoxModelViewSet): queryset = ObjectPermission.objects.prefetch_related('object_types', 'groups', 'users') serializer_class = serializers.ObjectPermissionSerializer filterset_class = filtersets.ObjectPermissionFilterSet diff --git a/netbox/users/forms.py b/netbox/users/forms.py index 8bd54cb66..d5e6218e5 100644 --- a/netbox/users/forms.py +++ b/netbox/users/forms.py @@ -1,8 +1,11 @@ from django import forms from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm +from django.utils.html import mark_safe -from utilities.forms import BootstrapMixin, DateTimePicker -from .models import Token +from netbox.preferences import PREFERENCES +from utilities.forms import BootstrapMixin, DateTimePicker, StaticSelect +from utilities.utils import flatten_dict +from .models import Token, UserConfig class LoginForm(BootstrapMixin, AuthenticationForm): @@ -13,6 +16,84 @@ class PasswordChangeForm(BootstrapMixin, DjangoPasswordChangeForm): pass +class UserConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported user preference + preference_fields = {} + for field_name, preference in PREFERENCES.items(): + description = f'{preference.description}
      ' if preference.description else '' + help_text = f'{description}{field_name}' + field_kwargs = { + 'label': preference.label, + 'choices': preference.choices, + 'help_text': mark_safe(help_text), + 'coerce': preference.coerce, + 'required': False, + 'widget': StaticSelect, + } + preference_fields[field_name] = forms.TypedChoiceField(**field_kwargs) + attrs.update(preference_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class UserConfigForm(BootstrapMixin, forms.ModelForm, metaclass=UserConfigFormMetaclass): + fieldsets = ( + ('User Interface', ( + 'pagination.per_page', + 'pagination.placement', + 'ui.colormode', + )), + ('Miscellaneous', ( + 'data_format', + )), + ) + # List of clearable preferences + pk = forms.MultipleChoiceField( + choices=[], + required=False + ) + + class Meta: + model = UserConfig + fields = () + + def __init__(self, *args, instance=None, **kwargs): + + # Get initial data from UserConfig instance + initial_data = flatten_dict(instance.data) + kwargs['initial'] = initial_data + + super().__init__(*args, instance=instance, **kwargs) + + # Compile clearable preference choices + self.fields['pk'].choices = ( + (f'tables.{table_name}', '') for table_name in instance.data.get('tables', []) + ) + + def save(self, *args, **kwargs): + + # Set UserConfig data + for pref_name, value in self.cleaned_data.items(): + if pref_name == 'pk': + continue + self.instance.set(pref_name, value, commit=False) + + # Clear selected preferences + for preference in self.cleaned_data['pk']: + self.instance.clear(preference) + + return super().save(*args, **kwargs) + + @property + def plugin_fields(self): + return [ + name for name in self.fields.keys() if name.startswith('plugins.') + ] + + class TokenForm(BootstrapMixin, forms.ModelForm): key = forms.CharField( required=False, diff --git a/netbox/users/migrations/0002_standardize_id_fields.py b/netbox/users/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..60191d916 --- /dev/null +++ b/netbox/users/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_squashed_0011'), + ] + + operations = [ + migrations.AlterField( + model_name='objectpermission', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='token', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='userconfig', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/users/models.py b/netbox/users/models.py index 64b6432a7..722ec5ba6 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -10,7 +10,7 @@ from django.db.models.signals import post_save from django.dispatch import receiver from django.utils import timezone -from netbox.models import BigIDModel +from netbox.config import get_config from utilities.querysets import RestrictedQuerySet from utilities.utils import flatten_dict from .constants import * @@ -79,13 +79,25 @@ class UserConfig(models.Model): keys = path.split('.') # Iterate down the hierarchy, returning the default value if any invalid key is encountered - for key in keys: - if type(d) is dict and key in d: - d = d.get(key) - else: - return default + try: + for key in keys: + d = d[key] + return d + except (TypeError, KeyError): + pass - return d + # If the key is not found in the user's config, check for an application-wide default + config = get_config() + d = config.DEFAULT_USER_PREFERENCES + try: + for key in keys: + d = d[key] + return d + except (TypeError, KeyError): + pass + + # Finally, return the specified default value (if any) + return default def all(self): """ @@ -166,14 +178,15 @@ def create_userconfig(instance, created, **kwargs): Automatically create a new UserConfig when a new User is created. """ if created: - UserConfig(user=instance).save() + config = get_config() + UserConfig(user=instance, data=config.DEFAULT_USER_PREFERENCES).save() # # REST API # -class Token(BigIDModel): +class Token(models.Model): """ An API token used for user authentication. This extends the stock model to allow each user to have multiple tokens. It also supports setting an expiration time and toggling write ability. @@ -232,7 +245,7 @@ class Token(BigIDModel): # Permissions # -class ObjectPermission(BigIDModel): +class ObjectPermission(models.Model): """ A mapping of view, add, change, and/or delete permission for users and/or groups to an arbitrary set of objects identified by ORM query parameters. diff --git a/netbox/users/preferences.py b/netbox/users/preferences.py new file mode 100644 index 000000000..cff6a3c9b --- /dev/null +++ b/netbox/users/preferences.py @@ -0,0 +1,10 @@ +class UserPreference: + """ + Represents a configurable user preference. + """ + def __init__(self, label, choices, default=None, description='', coerce=lambda x: x): + self.label = label + self.choices = choices + self.default = default if default is not None else choices[0] + self.description = description + self.coerce = coerce diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 8047796c4..48d440278 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,8 +1,6 @@ from django.contrib.auth.models import User from django.test import TestCase -from users.models import UserConfig - class UserConfigTest(TestCase): diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py new file mode 100644 index 000000000..f1e947d67 --- /dev/null +++ b/netbox/users/tests/test_preferences.py @@ -0,0 +1,63 @@ +from django.contrib.auth.models import User +from django.test import override_settings +from django.test.client import RequestFactory +from django.urls import reverse + +from dcim.models import Site +from dcim.tables import SiteTable +from users.preferences import UserPreference +from utilities.testing import TestCase + + +DEFAULT_USER_PREFERENCES = { + 'pagination': { + 'per_page': 250, + } +} + + +class UserPreferencesTest(TestCase): + user_permissions = ['dcim.view_site'] + + def test_userpreference(self): + CHOICES = ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ) + kwargs = { + 'label': 'Test Preference', + 'choices': CHOICES, + 'default': CHOICES[0][0], + 'description': 'Description', + } + userpref = UserPreference(**kwargs) + + self.assertEqual(userpref.label, kwargs['label']) + self.assertEqual(userpref.choices, kwargs['choices']) + self.assertEqual(userpref.default, kwargs['default']) + self.assertEqual(userpref.description, kwargs['description']) + + @override_settings(DEFAULT_USER_PREFERENCES=DEFAULT_USER_PREFERENCES) + def test_default_preferences(self): + user = User.objects.create(username='User 1') + userconfig = user.config + + self.assertEqual(userconfig.data, DEFAULT_USER_PREFERENCES) + + def test_table_ordering(self): + url = reverse('dcim:site_list') + response = self.client.get(f"{url}?sort=status") + self.assertEqual(response.status_code, 200) + + # Check that table ordering preference has been recorded + self.user.refresh_from_db() + ordering = self.user.config.get(f'tables.SiteTable.ordering') + self.assertEqual(ordering, ['status']) + + # Check that a recorded preference is honored by default + self.user.config.set(f'tables.SiteTable.ordering', ['-status'], commit=True) + table = SiteTable(Site.objects.all()) + request = RequestFactory().get(url) + request.user = self.user + table.configure(request) + self.assertEqual(table.order_by, ('-status',)) diff --git a/netbox/users/views.py b/netbox/users/views.py index ecf3295b5..04c0c5155 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -10,7 +10,6 @@ from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.decorators import method_decorator -from django.utils.http import is_safe_url from django.views.decorators.debug import sensitive_post_parameters from django.views.generic import View from social_core.backends.utils import load_backends @@ -19,7 +18,7 @@ from extras.models import ObjectChange from extras.tables import ObjectChangeTable from netbox.config import get_config from utilities.forms import ConfirmationForm -from .forms import LoginForm, PasswordChangeForm, TokenForm +from .forms import LoginForm, PasswordChangeForm, TokenForm, UserConfigForm from .models import Token @@ -78,17 +77,17 @@ class LoginView(View): }) def redirect_to_next(self, request, logger): - if request.method == "POST": - redirect_to = request.POST.get('next', settings.LOGIN_REDIRECT_URL) + data = request.POST if request.method == "POST" else request.GET + redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL) + + if redirect_url and redirect_url.startswith('/'): + logger.debug(f"Redirecting user to {redirect_url}") else: - redirect_to = request.GET.get('next', settings.LOGIN_REDIRECT_URL) + if redirect_url: + logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}") + redirect_url = reverse('home') - if redirect_to and not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): - logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_to}") - redirect_to = reverse('home') - - logger.debug(f"Redirecting user to {redirect_to}") - return HttpResponseRedirect(redirect_to) + return HttpResponseRedirect(redirect_url) class LogoutView(View): @@ -137,32 +136,28 @@ class UserConfigView(LoginRequiredMixin, View): template_name = 'users/preferences.html' def get(self, request): + userconfig = request.user.config + form = UserConfigForm(instance=userconfig) return render(request, self.template_name, { - 'preferences': request.user.config.all(), + 'form': form, 'active_tab': 'preferences', }) def post(self, request): userconfig = request.user.config - data = userconfig.all() + form = UserConfigForm(request.POST, instance=userconfig) - # Delete selected preferences - if "_delete" in request.POST: - for key in request.POST.getlist('pk'): - if key in data: - userconfig.clear(key) - # Update specific values - elif "_update" in request.POST: - for key in request.POST: - if not key.startswith('_') and not key.startswith('csrf'): - for value in request.POST.getlist(key): - userconfig.set(key, value) + if form.is_valid(): + form.save() - userconfig.save() - messages.success(request, "Your preferences have been updated.") + messages.success(request, "Your preferences have been updated.") + return redirect('user:preferences') - return redirect('user:preferences') + return render(request, self.template_name, { + 'form': form, + 'active_tab': 'preferences', + }) class ChangePasswordView(LoginRequiredMixin, View): @@ -223,8 +218,7 @@ class TokenEditView(LoginRequiredMixin, View): form = TokenForm(instance=token) return render(request, 'generic/object_edit.html', { - 'obj': token, - 'obj_type': token._meta.verbose_name, + 'object': token, 'form': form, 'return_url': reverse('user:token_list'), }) @@ -252,8 +246,7 @@ class TokenEditView(LoginRequiredMixin, View): return redirect('user:token_list') return render(request, 'generic/object_edit.html', { - 'obj': token, - 'obj_type': token._meta.verbose_name, + 'object': token, 'form': form, 'return_url': reverse('user:token_list'), }) @@ -270,8 +263,7 @@ class TokenDeleteView(LoginRequiredMixin, View): form = ConfirmationForm(initial=initial_data) return render(request, 'generic/object_delete.html', { - 'obj': token, - 'obj_type': token._meta.verbose_name, + 'object': token, 'form': form, 'return_url': reverse('user:token_list'), }) @@ -286,8 +278,7 @@ class TokenDeleteView(LoginRequiredMixin, View): return redirect('user:token_list') return render(request, 'generic/object_delete.html', { - 'obj': token, - 'obj_type': token._meta.verbose_name, + 'object': token, 'form': form, 'return_url': reverse('user:token_list'), }) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b831b3490..c5b5bafb9 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,28 +1,61 @@ +from django.conf import settings + + class ChoiceSetMeta(type): """ Metaclass for ChoiceSet """ + def __new__(mcs, name, bases, attrs): + + # Extend static choices with any configured choices + if key := attrs.get('key'): + assert type(attrs['CHOICES']) is list, f"{name} has a key defined but CHOICES is not a list" + app = attrs['__module__'].split('.', 1)[0] + replace_key = f'{app}.{key}' + extend_key = f'{replace_key}+' if replace_key else None + if replace_key and replace_key in settings.FIELD_CHOICES: + # Replace the stock choices + attrs['CHOICES'] = settings.FIELD_CHOICES[replace_key] + elif extend_key and extend_key in settings.FIELD_CHOICES: + # Extend the stock choices + attrs['CHOICES'].extend(settings.FIELD_CHOICES[extend_key]) + + # Define choice tuples and color maps + attrs['_choices'] = [] + attrs['colors'] = {} + for choice in attrs['CHOICES']: + if isinstance(choice[1], (list, tuple)): + grouped_choices = [] + for c in choice[1]: + grouped_choices.append((c[0], c[1])) + if len(c) == 3: + attrs['colors'][c[0]] = c[2] + attrs['_choices'].append((choice[0], grouped_choices)) + else: + attrs['_choices'].append((choice[0], choice[1])) + if len(choice) == 3: + attrs['colors'][choice[0]] = choice[2] + + return super().__new__(mcs, name, bases, attrs) + def __call__(cls, *args, **kwargs): - # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable - return getattr(cls, 'CHOICES', ()) + # django-filters will check if a 'choices' value is callable, and if so assume that it returns an iterable + return getattr(cls, '_choices', ()) def __iter__(cls): - choices = getattr(cls, 'CHOICES', ()) - return iter(choices) + return iter(getattr(cls, '_choices', ())) class ChoiceSet(metaclass=ChoiceSetMeta): - + """ + Holds an iterable of choice tuples suitable for passing to a Django model or form field. Choices can be defined + statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter. + """ CHOICES = list() @classmethod def values(cls): - return [c[0] for c in unpack_grouped_choices(cls.CHOICES)] - - @classmethod - def as_dict(cls): - # Unpack grouped choices before casting as a dict - return dict(unpack_grouped_choices(cls.CHOICES)) + return [c[0] for c in unpack_grouped_choices(cls._choices)] def unpack_grouped_choices(choices): @@ -133,21 +166,34 @@ class ButtonColorChoices(ChoiceSet): Map standard button color choices to Bootstrap 3 button classes """ DEFAULT = 'outline-dark' - BLUE = 'primary' - CYAN = 'info' - GREEN = 'success' - RED = 'danger' - YELLOW = 'warning' - GREY = 'secondary' - BLACK = 'dark' + BLUE = 'blue' + INDIGO = 'indigo' + PURPLE = 'purple' + PINK = 'pink' + RED = 'red' + ORANGE = 'orange' + YELLOW = 'yellow' + GREEN = 'green' + TEAL = 'teal' + CYAN = 'cyan' + GRAY = 'gray' + GREY = 'gray' # Backward compatability for <3.2 + BLACK = 'black' + WHITE = 'white' CHOICES = ( (DEFAULT, 'Default'), (BLUE, 'Blue'), - (CYAN, 'Cyan'), - (GREEN, 'Green'), + (INDIGO, 'Indigo'), + (PURPLE, 'Purple'), + (PINK, 'Pink'), (RED, 'Red'), + (ORANGE, 'Orange'), (YELLOW, 'Yellow'), - (GREY, 'Grey'), - (BLACK, 'Black') + (GREEN, 'Green'), + (TEAL, 'Teal'), + (CYAN, 'Cyan'), + (GRAY, 'Gray'), + (BLACK, 'Black'), + (WHITE, 'White'), ) diff --git a/netbox/utilities/constants.py b/netbox/utilities/constants.py index 08e9dd9cf..9303e5f3a 100644 --- a/netbox/utilities/constants.py +++ b/netbox/utilities/constants.py @@ -42,6 +42,7 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict( ADVISORY_LOCK_KEYS = { 'available-prefixes': 100100, 'available-ips': 100200, + 'available-vlans': 100300, } # diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py deleted file mode 100644 index 71c14a6f0..000000000 --- a/netbox/utilities/forms/fields.py +++ /dev/null @@ -1,530 +0,0 @@ -import csv -import json -import re -from io import StringIO -from netaddr import AddrFormatError, EUI - -import django_filters -from django import forms -from django.conf import settings -from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist -from django.db.models import Count, Q -from django.forms import BoundField -from django.forms.fields import JSONField as _JSONField, InvalidJSONInput -from django.urls import reverse - -from utilities.choices import unpack_grouped_choices -from utilities.utils import content_type_identifier, content_type_name -from utilities.validators import EnhancedURLValidator -from . import widgets -from .constants import * -from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv - -__all__ = ( - 'ColorField', - 'CommentField', - 'ContentTypeChoiceField', - 'ContentTypeMultipleChoiceField', - 'CSVChoiceField', - 'CSVContentTypeField', - 'CSVDataField', - 'CSVFileField', - 'CSVModelChoiceField', - 'CSVMultipleChoiceField', - 'CSVMultipleContentTypeField', - 'CSVTypedChoiceField', - 'DynamicModelChoiceField', - 'DynamicModelMultipleChoiceField', - 'ExpandableIPAddressField', - 'ExpandableNameField', - 'JSONField', - 'LaxURLField', - 'MACAddressField', - 'SlugField', - 'TagFilterField', -) - - -class CommentField(forms.CharField): - """ - A textarea with support for Markdown rendering. Exists mostly just to add a standard help_text. - """ - widget = forms.Textarea - default_label = '' - # TODO: Port Markdown cheat sheet to internal documentation - default_helptext = ' '\ - ''\ - 'Markdown syntax is supported' - - def __init__(self, *args, **kwargs): - required = kwargs.pop('required', False) - label = kwargs.pop('label', self.default_label) - help_text = kwargs.pop('help_text', self.default_helptext) - super().__init__(required=required, label=label, help_text=help_text, *args, **kwargs) - - -class SlugField(forms.SlugField): - """ - Extend the built-in SlugField to automatically populate from a field called `name` unless otherwise specified. - """ - - def __init__(self, slug_source='name', *args, **kwargs): - label = kwargs.pop('label', "Slug") - help_text = kwargs.pop('help_text', "URL-friendly unique shorthand") - widget = kwargs.pop('widget', widgets.SlugWidget) - super().__init__(label=label, help_text=help_text, widget=widget, *args, **kwargs) - self.widget.attrs['slug-source'] = slug_source - - -class ColorField(forms.CharField): - """ - A field which represents a color in hexadecimal RRGGBB format. - """ - widget = widgets.ColorSelect - - -class TagFilterField(forms.MultipleChoiceField): - """ - A filter field for the tags of a model. Only the tags used by a model are displayed. - - :param model: The model of the filter - """ - widget = widgets.StaticSelectMultiple - - def __init__(self, model, *args, **kwargs): - def get_choices(): - tags = model.tags.annotate( - count=Count('extras_taggeditem_items') - ).order_by('name') - return [ - (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags - ] - - # Choices are fetched each time the form is initialized - super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) - - -class LaxURLField(forms.URLField): - """ - Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names - (e.g. http://myserver/ is valid) - """ - default_validators = [EnhancedURLValidator()] - - -class JSONField(_JSONField): - """ - Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Enter context data in JSON format.' - self.widget.attrs['placeholder'] = '' - - def prepare_value(self, value): - if isinstance(value, InvalidJSONInput): - return value - if value is None: - return '' - return json.dumps(value, sort_keys=True, indent=4) - - -class MACAddressField(forms.Field): - widget = forms.CharField - default_error_messages = { - 'invalid': 'MAC address must be in EUI-48 format', - } - - def to_python(self, value): - value = super().to_python(value) - - # Validate MAC address format - try: - value = EUI(value.strip()) - except AddrFormatError: - raise forms.ValidationError(self.error_messages['invalid'], code='invalid') - - return value - - -# -# Content type fields -# - -class ContentTypeChoiceMixin: - - def __init__(self, queryset, *args, **kwargs): - # Order ContentTypes by app_label - queryset = queryset.order_by('app_label', 'model') - super().__init__(queryset, *args, **kwargs) - - def label_from_instance(self, obj): - try: - return content_type_name(obj) - except AttributeError: - return super().label_from_instance(obj) - - -class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): - widget = widgets.StaticSelect - - -class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): - widget = widgets.StaticSelectMultiple - - -# -# CSV fields -# - -class CSVDataField(forms.CharField): - """ - A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first - item is a dictionary of column headers, mapping field names to the attribute by which they match a related object - (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - widget = forms.Textarea - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - self.strip = False - if not self.label: - self.label = '' - if not self.initial: - self.initial = ','.join(self.required_fields) + '\n' - if not self.help_text: - self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ - 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ - 'in double quotes.' - - def to_python(self, value): - reader = csv.reader(StringIO(value.strip())) - - return parse_csv(reader) - - def validate(self, value): - headers, records = value - validate_csv(headers, self.fields, self.required_fields) - - return value - - -class CSVFileField(forms.FileField): - """ - A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns - data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute - by which they match a related object (where applicable). The second item is a list of dictionaries, each - representing a discrete row of CSV data. - - :param from_form: The form from which the field derives its validation rules. - """ - - def __init__(self, from_form, *args, **kwargs): - - form = from_form() - self.model = form.Meta.model - self.fields = form.fields - self.required_fields = [ - name for name, field in form.fields.items() if field.required - ] - - super().__init__(*args, **kwargs) - - def to_python(self, file): - if file is None: - return None - - csv_str = file.read().decode('utf-8').strip() - reader = csv.reader(StringIO(csv_str)) - headers, records = parse_csv(reader) - - return headers, records - - def validate(self, value): - if value is None: - return None - - headers, records = value - validate_csv(headers, self.fields, self.required_fields) - - return value - - -class CSVChoicesMixin: - STATIC_CHOICES = True - - def __init__(self, *, choices=(), **kwargs): - super().__init__(choices=choices, **kwargs) - self.choices = unpack_grouped_choices(choices) - - -class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField): - """ - A CSV field which accepts a single selection value. - """ - pass - - -class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField): - """ - A CSV field which accepts multiple selection values. - """ - def to_python(self, value): - if not value: - return [] - if not isinstance(value, str): - raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}") - return value.split(',') - - -class CSVTypedChoiceField(forms.TypedChoiceField): - STATIC_CHOICES = True - - -class CSVModelChoiceField(forms.ModelChoiceField): - """ - Provides additional validation for model choices entered as CSV data. - """ - default_error_messages = { - 'invalid_choice': 'Object not found.', - } - - def to_python(self, value): - try: - return super().to_python(value) - except MultipleObjectsReturned: - raise forms.ValidationError( - f'"{value}" is not a unique value for this field; multiple objects were found' - ) - - -class CSVContentTypeField(CSVModelChoiceField): - """ - Reference a ContentType in the form . - """ - STATIC_CHOICES = True - - def prepare_value(self, value): - return content_type_identifier(value) - - def to_python(self, value): - if not value: - return None - try: - app_label, model = value.split('.') - except ValueError: - raise forms.ValidationError(f'Object type must be specified as "."') - try: - return self.queryset.get(app_label=app_label, model=model) - except ObjectDoesNotExist: - raise forms.ValidationError(f'Invalid object type') - - -class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): - STATIC_CHOICES = True - - # TODO: Improve validation of selected ContentTypes - def prepare_value(self, value): - if type(value) is str: - ct_filter = Q() - for name in value.split(','): - app_label, model = name.split('.') - ct_filter |= Q(app_label=app_label, model=model) - return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) - return content_type_identifier(value) - - -# -# Expansion fields -# - -class ExpandableNameField(forms.CharField): - """ - A field which allows for numeric range expansion - Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = """ - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Examples: -
        -
      • [ge,xe]-0/0/[0-9]
      • -
      • e[0-3][a-d,f]
      • -
      - """ - - def to_python(self, value): - if not value: - return '' - if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): - return list(expand_alphanumeric_pattern(value)) - return [value] - - -class ExpandableIPAddressField(forms.CharField): - """ - A field which allows for expansion of IP address ranges - Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if not self.help_text: - self.help_text = 'Specify a numeric range to create multiple IPs.
      '\ - 'Example: 192.0.2.[1,5,100-254]/24' - - def to_python(self, value): - # Hackish address family detection but it's all we have to work with - if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 4)) - elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): - return list(expand_ipaddress_pattern(value, 6)) - return [value] - - -# -# Dynamic fields -# - -class DynamicModelChoiceMixin: - """ - :param query_params: A dictionary of additional key/value pairs to attach to the API request - :param initial_params: A dictionary of child field references to use for selecting a parent field's initial value - :param null_option: The string used to represent a null selection (if any) - :param disabled_indicator: The name of the field which, if populated, will disable selection of the - choice (optional) - :param str fetch_trigger: The event type which will cause the select element to - fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional) - """ - filter = django_filters.ModelChoiceFilter - widget = widgets.APISelect - - def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, - fetch_trigger=None, empty_label=None, *args, **kwargs): - self.query_params = query_params or {} - self.initial_params = initial_params or {} - self.null_option = null_option - self.disabled_indicator = disabled_indicator - self.fetch_trigger = fetch_trigger - - # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference - # by widget_attrs() - self.to_field_name = kwargs.get('to_field_name') - self.empty_option = empty_label or "" - - super().__init__(*args, **kwargs) - - def widget_attrs(self, widget): - attrs = { - 'data-empty-option': self.empty_option - } - - # Set value-field attribute if the field specifies to_field_name - if self.to_field_name: - attrs['value-field'] = self.to_field_name - - # Set the string used to represent a null option - if self.null_option is not None: - attrs['data-null-option'] = self.null_option - - # Set the disabled indicator, if any - if self.disabled_indicator is not None: - attrs['disabled-indicator'] = self.disabled_indicator - - # Set the fetch trigger, if any. - if self.fetch_trigger is not None: - attrs['data-fetch-trigger'] = self.fetch_trigger - - # Attach any static query parameters - if (len(self.query_params) > 0): - widget.add_query_params(self.query_params) - - return attrs - - def get_bound_field(self, form, field_name): - bound_field = BoundField(form, self, field_name) - - # Set initial value based on prescribed child fields (if not already set) - if not self.initial and self.initial_params: - filter_kwargs = {} - for kwarg, child_field in self.initial_params.items(): - value = form.initial.get(child_field.lstrip('$')) - if value: - filter_kwargs[kwarg] = value - if filter_kwargs: - self.initial = self.queryset.filter(**filter_kwargs).first() - - # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options - # will be populated on-demand via the APISelect widget. - data = bound_field.value() - if data: - field_name = getattr(self, 'to_field_name') or 'pk' - filter = self.filter(field_name=field_name) - try: - self.queryset = filter.filter(self.queryset, data) - except (TypeError, ValueError): - # Catch any error caused by invalid initial data passed from the user - self.queryset = self.queryset.none() - else: - self.queryset = self.queryset.none() - - # Set the data URL on the APISelect widget (if not already set) - widget = bound_field.field.widget - if not widget.attrs.get('data-url'): - app_label = self.queryset.model._meta.app_label - model_name = self.queryset.model._meta.model_name - data_url = reverse('{}-api:{}-list'.format(app_label, model_name)) - widget.attrs['data-url'] = data_url - - return bound_field - - -class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): - """ - Override get_bound_field() to avoid pre-populating field choices with a SQL query. The field will be - rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. - """ - - def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ - if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE: - return None - return super().clean(value) - - -class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): - """ - A multiple-choice version of DynamicModelChoiceField. - """ - filter = django_filters.ModelMultipleChoiceFilter - widget = widgets.APISelectMultiple - - def clean(self, value): - """ - When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the - string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. - """ - if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: - value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] - return [None, *value] - return super().clean(value) diff --git a/netbox/utilities/forms/fields/__init__.py b/netbox/utilities/forms/fields/__init__.py new file mode 100644 index 000000000..eacde0040 --- /dev/null +++ b/netbox/utilities/forms/fields/__init__.py @@ -0,0 +1,5 @@ +from .content_types import * +from .csv import * +from .dynamic import * +from .expandable import * +from .fields import * diff --git a/netbox/utilities/forms/fields/content_types.py b/netbox/utilities/forms/fields/content_types.py new file mode 100644 index 000000000..80861166c --- /dev/null +++ b/netbox/utilities/forms/fields/content_types.py @@ -0,0 +1,37 @@ +from django import forms + +from utilities.forms import widgets +from utilities.utils import content_type_name + +__all__ = ( + 'ContentTypeChoiceField', + 'ContentTypeMultipleChoiceField', +) + + +class ContentTypeChoiceMixin: + + def __init__(self, queryset, *args, **kwargs): + # Order ContentTypes by app_label + queryset = queryset.order_by('app_label', 'model') + super().__init__(queryset, *args, **kwargs) + + def label_from_instance(self, obj): + try: + return content_type_name(obj) + except AttributeError: + return super().label_from_instance(obj) + + +class ContentTypeChoiceField(ContentTypeChoiceMixin, forms.ModelChoiceField): + """ + Selection field for a single content type. + """ + widget = widgets.StaticSelect + + +class ContentTypeMultipleChoiceField(ContentTypeChoiceMixin, forms.ModelMultipleChoiceField): + """ + Selection field for one or more content types. + """ + widget = widgets.StaticSelectMultiple diff --git a/netbox/utilities/forms/fields/csv.py b/netbox/utilities/forms/fields/csv.py new file mode 100644 index 000000000..275c8084c --- /dev/null +++ b/netbox/utilities/forms/fields/csv.py @@ -0,0 +1,193 @@ +import csv +from io import StringIO + +from django import forms +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist +from django.db.models import Q + +from utilities.choices import unpack_grouped_choices +from utilities.forms.utils import parse_csv, validate_csv +from utilities.utils import content_type_identifier + +__all__ = ( + 'CSVChoiceField', + 'CSVContentTypeField', + 'CSVDataField', + 'CSVFileField', + 'CSVModelChoiceField', + 'CSVMultipleChoiceField', + 'CSVMultipleContentTypeField', + 'CSVTypedChoiceField', +) + + +class CSVDataField(forms.CharField): + """ + A CharField (rendered as a Textarea) which accepts CSV-formatted data. It returns data as a two-tuple: The first + item is a dictionary of column headers, mapping field names to the attribute by which they match a related object + (where applicable). The second item is a list of dictionaries, each representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + widget = forms.Textarea + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + self.strip = False + if not self.label: + self.label = '' + if not self.initial: + self.initial = ','.join(self.required_fields) + '\n' + if not self.help_text: + self.help_text = 'Enter the list of column headers followed by one line per record to be imported, using ' \ + 'commas to separate values. Multi-line data and values containing commas may be wrapped ' \ + 'in double quotes.' + + def to_python(self, value): + reader = csv.reader(StringIO(value.strip())) + + return parse_csv(reader) + + def validate(self, value): + headers, records = value + validate_csv(headers, self.fields, self.required_fields) + + return value + + +class CSVFileField(forms.FileField): + """ + A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns + data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute + by which they match a related object (where applicable). The second item is a list of dictionaries, each + representing a discrete row of CSV data. + + :param from_form: The form from which the field derives its validation rules. + """ + + def __init__(self, from_form, *args, **kwargs): + + form = from_form() + self.model = form.Meta.model + self.fields = form.fields + self.required_fields = [ + name for name, field in form.fields.items() if field.required + ] + + super().__init__(*args, **kwargs) + + def to_python(self, file): + if file is None: + return None + + csv_str = file.read().decode('utf-8').strip() + reader = csv.reader(StringIO(csv_str)) + headers, records = parse_csv(reader) + + return headers, records + + def validate(self, value): + if value is None: + return None + + headers, records = value + validate_csv(headers, self.fields, self.required_fields) + + return value + + +class CSVChoicesMixin: + STATIC_CHOICES = True + + def __init__(self, *, choices=(), **kwargs): + super().__init__(choices=choices, **kwargs) + self.choices = unpack_grouped_choices(choices) + + +class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField): + """ + A CSV field which accepts a single selection value. + """ + pass + + +class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField): + """ + A CSV field which accepts multiple selection values. + """ + def to_python(self, value): + if not value: + return [] + if not isinstance(value, str): + raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}") + return value.split(',') + + +class CSVTypedChoiceField(forms.TypedChoiceField): + STATIC_CHOICES = True + + +class CSVModelChoiceField(forms.ModelChoiceField): + """ + Extends Django's `ModelChoiceField` to provide additional validation for CSV values. + """ + default_error_messages = { + 'invalid_choice': 'Object not found.', + } + + def to_python(self, value): + try: + return super().to_python(value) + except MultipleObjectsReturned: + raise forms.ValidationError( + f'"{value}" is not a unique value for this field; multiple objects were found' + ) + + +class CSVContentTypeField(CSVModelChoiceField): + """ + CSV field for referencing a single content type, in the form `.`. + """ + STATIC_CHOICES = True + + def prepare_value(self, value): + return content_type_identifier(value) + + def to_python(self, value): + if not value: + return None + try: + app_label, model = value.split('.') + except ValueError: + raise forms.ValidationError(f'Object type must be specified as "."') + try: + return self.queryset.get(app_label=app_label, model=model) + except ObjectDoesNotExist: + raise forms.ValidationError(f'Invalid object type') + + +class CSVMultipleContentTypeField(forms.ModelMultipleChoiceField): + """ + CSV field for referencing one or more content types, in the form `.`. + """ + STATIC_CHOICES = True + + # TODO: Improve validation of selected ContentTypes + def prepare_value(self, value): + if type(value) is str: + ct_filter = Q() + for name in value.split(','): + app_label, model = name.split('.') + ct_filter |= Q(app_label=app_label, model=model) + return list(ContentType.objects.filter(ct_filter).values_list('pk', flat=True)) + return content_type_identifier(value) diff --git a/netbox/utilities/forms/fields/dynamic.py b/netbox/utilities/forms/fields/dynamic.py new file mode 100644 index 000000000..f83fc6a7c --- /dev/null +++ b/netbox/utilities/forms/fields/dynamic.py @@ -0,0 +1,140 @@ +import django_filters +from django import forms +from django.conf import settings +from django.forms import BoundField +from django.urls import reverse + +from utilities.forms import widgets +from utilities.utils import get_viewname + +__all__ = ( + 'DynamicModelChoiceField', + 'DynamicModelMultipleChoiceField', +) + + +class DynamicModelChoiceMixin: + """ + Override `get_bound_field()` to avoid pre-populating field choices with a SQL query. The field will be + rendered only with choices set via bound data. Choices are populated on-demand via the APISelect widget. + + Attributes: + query_params: A dictionary of additional key/value pairs to attach to the API request + initial_params: A dictionary of child field references to use for selecting a parent field's initial value + null_option: The string used to represent a null selection (if any) + disabled_indicator: The name of the field which, if populated, will disable selection of the + choice (optional) + fetch_trigger: The event type which will cause the select element to + fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional) + """ + filter = django_filters.ModelChoiceFilter + widget = widgets.APISelect + + def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, + fetch_trigger=None, empty_label=None, *args, **kwargs): + self.query_params = query_params or {} + self.initial_params = initial_params or {} + self.null_option = null_option + self.disabled_indicator = disabled_indicator + self.fetch_trigger = fetch_trigger + + # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference + # by widget_attrs() + self.to_field_name = kwargs.get('to_field_name') + self.empty_option = empty_label or "" + + super().__init__(*args, **kwargs) + + def widget_attrs(self, widget): + attrs = { + 'data-empty-option': self.empty_option + } + + # Set value-field attribute if the field specifies to_field_name + if self.to_field_name: + attrs['value-field'] = self.to_field_name + + # Set the string used to represent a null option + if self.null_option is not None: + attrs['data-null-option'] = self.null_option + + # Set the disabled indicator, if any + if self.disabled_indicator is not None: + attrs['disabled-indicator'] = self.disabled_indicator + + # Set the fetch trigger, if any. + if self.fetch_trigger is not None: + attrs['data-fetch-trigger'] = self.fetch_trigger + + # Attach any static query parameters + if (len(self.query_params) > 0): + widget.add_query_params(self.query_params) + + return attrs + + def get_bound_field(self, form, field_name): + bound_field = BoundField(form, self, field_name) + + # Set initial value based on prescribed child fields (if not already set) + if not self.initial and self.initial_params: + filter_kwargs = {} + for kwarg, child_field in self.initial_params.items(): + value = form.initial.get(child_field.lstrip('$')) + if value: + filter_kwargs[kwarg] = value + if filter_kwargs: + self.initial = self.queryset.filter(**filter_kwargs).first() + + # Modify the QuerySet of the field before we return it. Limit choices to any data already bound: Options + # will be populated on-demand via the APISelect widget. + data = bound_field.value() + if data: + field_name = getattr(self, 'to_field_name') or 'pk' + filter = self.filter(field_name=field_name) + try: + self.queryset = filter.filter(self.queryset, data) + except (TypeError, ValueError): + # Catch any error caused by invalid initial data passed from the user + self.queryset = self.queryset.none() + else: + self.queryset = self.queryset.none() + + # Set the data URL on the APISelect widget (if not already set) + widget = bound_field.field.widget + if not widget.attrs.get('data-url'): + viewname = get_viewname(self.queryset.model, action='list', rest_api=True) + widget.attrs['data-url'] = reverse(viewname) + + return bound_field + + +class DynamicModelChoiceField(DynamicModelChoiceMixin, forms.ModelChoiceField): + """ + Dynamic selection field for a single object, backed by NetBox's REST API. + """ + def clean(self, value): + """ + When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. + """ + if self.null_option is not None and value == settings.FILTERS_NULL_CHOICE_VALUE: + return None + return super().clean(value) + + +class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultipleChoiceField): + """ + A multiple-choice version of `DynamicModelChoiceField`. + """ + filter = django_filters.ModelMultipleChoiceFilter + widget = widgets.APISelectMultiple + + def clean(self, value): + """ + When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the + string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType. + """ + if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value: + value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE] + return [None, *value] + return super().clean(value) diff --git a/netbox/utilities/forms/fields/expandable.py b/netbox/utilities/forms/fields/expandable.py new file mode 100644 index 000000000..214775f03 --- /dev/null +++ b/netbox/utilities/forms/fields/expandable.py @@ -0,0 +1,54 @@ +import re + +from django import forms + +from utilities.forms.constants import * +from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipaddress_pattern + +__all__ = ( + 'ExpandableIPAddressField', + 'ExpandableNameField', +) + + +class ExpandableNameField(forms.CharField): + """ + A field which allows for numeric range expansion + Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = """ + Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range + are not supported. Example: [ge,xe]-0/0/[0-9] + """ + + def to_python(self, value): + if not value: + return '' + if re.search(ALPHANUMERIC_EXPANSION_PATTERN, value): + return list(expand_alphanumeric_pattern(value)) + return [value] + + +class ExpandableIPAddressField(forms.CharField): + """ + A field which allows for expansion of IP address ranges + Example: '192.0.2.[1-254]/24' => ['192.0.2.1/24', '192.0.2.2/24', '192.0.2.3/24' ... '192.0.2.254/24'] + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Specify a numeric range to create multiple IPs.
      '\ + 'Example: 192.0.2.[1,5,100-254]/24' + + def to_python(self, value): + # Hackish address family detection but it's all we have to work with + if '.' in value and re.search(IP4_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 4)) + elif ':' in value and re.search(IP6_EXPANSION_PATTERN, value): + return list(expand_ipaddress_pattern(value, 6)) + return [value] diff --git a/netbox/utilities/forms/fields/fields.py b/netbox/utilities/forms/fields/fields.py new file mode 100644 index 000000000..0d09d2ac7 --- /dev/null +++ b/netbox/utilities/forms/fields/fields.py @@ -0,0 +1,147 @@ +import json + +from django import forms +from django.db.models import Count +from django.forms.fields import JSONField as _JSONField, InvalidJSONInput +from netaddr import AddrFormatError, EUI + +from utilities.forms import widgets +from utilities.validators import EnhancedURLValidator + +__all__ = ( + 'ChoiceField', + 'ColorField', + 'CommentField', + 'JSONField', + 'LaxURLField', + 'MACAddressField', + 'MultipleChoiceField', + 'SlugField', + 'TagFilterField', +) + + +class CommentField(forms.CharField): + """ + A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`. + """ + widget = forms.Textarea + # TODO: Port Markdown cheat sheet to internal documentation + help_text = """ + + + Markdown syntax is supported + """ + + def __init__(self, *, label='', help_text=help_text, required=False, **kwargs): + super().__init__(label=label, help_text=help_text, required=required, **kwargs) + + +class SlugField(forms.SlugField): + """ + Extend Django's built-in SlugField to automatically populate from a field called `name` unless otherwise specified. + + Parameters: + slug_source: Name of the form field from which the slug value will be derived + """ + widget = widgets.SlugWidget + help_text = "URL-friendly unique shorthand" + + def __init__(self, *, slug_source='name', help_text=help_text, **kwargs): + super().__init__(help_text=help_text, **kwargs) + + self.widget.attrs['slug-source'] = slug_source + + +class ColorField(forms.CharField): + """ + A field which represents a color value in hexadecimal `RRGGBB` format. Utilizes NetBox's `ColorSelect` widget to + render choices. + """ + widget = widgets.ColorSelect + + +class TagFilterField(forms.MultipleChoiceField): + """ + A filter field for the tags of a model. Only the tags used by a model are displayed. + + :param model: The model of the filter + """ + widget = widgets.StaticSelectMultiple + + def __init__(self, model, *args, **kwargs): + def get_choices(): + tags = model.tags.annotate( + count=Count('extras_taggeditem_items') + ).order_by('name') + return [ + (str(tag.slug), '{} ({})'.format(tag.name, tag.count)) for tag in tags + ] + + # Choices are fetched each time the form is initialized + super().__init__(label='Tags', choices=get_choices, required=False, *args, **kwargs) + + +class LaxURLField(forms.URLField): + """ + Modifies Django's built-in URLField to remove the requirement for fully-qualified domain names + (e.g. http://myserver/ is valid) + """ + default_validators = [EnhancedURLValidator()] + + +class JSONField(_JSONField): + """ + Custom wrapper around Django's built-in JSONField to avoid presenting "null" as the default text. + """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Enter context data in JSON format.' + self.widget.attrs['placeholder'] = '' + + def prepare_value(self, value): + if isinstance(value, InvalidJSONInput): + return value + if value is None: + return '' + return json.dumps(value, sort_keys=True, indent=4) + + +class MACAddressField(forms.Field): + """ + Validates a 48-bit MAC address. + """ + widget = forms.CharField + default_error_messages = { + 'invalid': 'MAC address must be in EUI-48 format', + } + + def to_python(self, value): + value = super().to_python(value) + + # Validate MAC address format + try: + value = EUI(value.strip()) + except AddrFormatError: + raise forms.ValidationError(self.error_messages['invalid'], code='invalid') + + return value + + +# +# Choice fields +# + +class ChoiceField(forms.ChoiceField): + """ + Overrides Django's built-in `ChoiceField` to use NetBox's `StaticSelect` widget + """ + widget = widgets.StaticSelect + + +class MultipleChoiceField(forms.MultipleChoiceField): + """ + Overrides Django's built-in `MultipleChoiceField` to use NetBox's `StaticSelectMultiple` widget + """ + widget = widgets.StaticSelectMultiple diff --git a/netbox/utilities/forms/forms.py b/netbox/utilities/forms/forms.py index b69472e6f..3b5cd8308 100644 --- a/netbox/utilities/forms/forms.py +++ b/netbox/utilities/forms/forms.py @@ -10,7 +10,6 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel __all__ = ( 'BootstrapMixin', 'BulkEditForm', - 'BulkEditBaseForm', 'BulkRenameForm', 'ConfirmationForm', 'CSVModelForm', @@ -21,6 +20,10 @@ __all__ = ( ) +# +# Mixins +# + class BootstrapMixin: """ Add the base Bootstrap CSS classes to form elements. @@ -61,6 +64,10 @@ class BootstrapMixin: field.widget.attrs['class'] = ' '.join((css, 'form-select')).strip() +# +# Form classes +# + class ReturnURLForm(forms.Form): """ Provides a hidden return URL field to control where the user is directed after the form is submitted. @@ -75,22 +82,11 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm): confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True) -class BulkEditBaseForm(forms.Form): +class BulkEditForm(BootstrapMixin, forms.Form): """ - Base form for editing multiple objects in bulk + Provides bulk edit support for objects. """ - def __init__(self, model, *args, **kwargs): - super().__init__(*args, **kwargs) - self.model = model - self.nullable_fields = [] - - # Copy any nullable fields defined in Meta - if hasattr(self.Meta, 'nullable_fields'): - self.nullable_fields = self.Meta.nullable_fields - - -class BulkEditForm(BootstrapMixin, BulkEditBaseForm): - pass + nullable_fields = () class BulkRenameForm(BootstrapMixin, forms.Form): diff --git a/netbox/utilities/forms/widgets.py b/netbox/utilities/forms/widgets.py index 6e227bb81..c27f3c646 100644 --- a/netbox/utilities/forms/widgets.py +++ b/netbox/utilities/forms/widgets.py @@ -18,7 +18,6 @@ __all__ = ( 'DateTimePicker', 'NumericArrayField', 'SelectSpeedWidget', - 'SelectWithDisabled', 'SelectWithPK', 'SlugWidget', 'SmallTextarea', @@ -76,18 +75,12 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect): self.attrs['class'] = 'netbox-static-select' -class SelectWithDisabled(forms.Select): - """ - Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include - 'label' (string) and 'disabled' (boolean). - """ - option_template_name = 'widgets/selectwithdisabled_option.html' - - -class StaticSelect(SelectWithDisabled): +class StaticSelect(forms.Select): """ A static ') - - -class BooleanColumn(tables.Column): - """ - Custom implementation of BooleanColumn to render a nicely-formatted checkmark or X icon instead of a Unicode - character. - """ - def render(self, value): - if value: - rendered = '' - elif value is None: - rendered = '' - else: - rendered = '' - return mark_safe(rendered) - - def value(self, value): - return str(value) - - -class TemplateColumn(tables.TemplateColumn): - """ - Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string. - """ - PLACEHOLDER = mark_safe('—') - - def render(self, *args, **kwargs): - ret = super().render(*args, **kwargs) - if not ret.strip(): - return self.PLACEHOLDER - return ret - - def value(self, **kwargs): - ret = super().value(**kwargs) - if ret == self.PLACEHOLDER: - return '' - return ret - - -@library.register -class DateColumn(tables.DateColumn): - """ - Overrides the default implementation of DateColumn to better handle null values, returning a default value for - tables and null when exporting data. It is registered in the tables library to use this class instead of the - default, making this behavior consistent in all fields of type DateField. - """ - - def value(self, value): - return value - - @classmethod - def from_field(cls, field, **kwargs): - if isinstance(field, DateField): - return cls(**kwargs) - - -@library.register -class DateTimeColumn(tables.DateTimeColumn): - """ - Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for - tables and null when exporting data. It is registered in the tables library to use this class instead of the - default, making this behavior consistent in all fields of type DateTimeField. - """ - - def value(self, value): - if value: - return date_format(value, format="SHORT_DATETIME_FORMAT") - return None - - @classmethod - def from_field(cls, field, **kwargs): - if isinstance(field, DateTimeField): - return cls(**kwargs) - - -class ButtonsColumn(tables.TemplateColumn): - """ - Render edit, delete, and changelog buttons for an object. - - :param model: Model class to use for calculating URL view names - :param prepend_content: Additional template content to render in the column (optional) - """ - buttons = ('changelog', 'edit', 'delete') - attrs = {'td': {'class': 'text-end text-nowrap noprint'}} - # Note that braces are escaped to allow for string formatting prior to template rendering - template_code = """ - {{% if "changelog" in buttons %}} - - - - {{% endif %}} - {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}} - - - - {{% endif %}} - {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}} - - - - {{% endif %}} - """ - - def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs): - if prepend_template: - prepend_template = prepend_template.replace('{', '{{') - prepend_template = prepend_template.replace('}', '}}') - self.template_code = prepend_template + self.template_code - - template_code = self.template_code.format( - app_label=model._meta.app_label, - model_name=model._meta.model_name, - buttons=buttons - ) - - 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, - }) - - def header(self): - return '' - - -class ChoiceFieldColumn(tables.Column): - """ - Render a ChoiceField value inside a indicating a particular CSS class. This is useful for displaying colored - choices. The CSS class is derived by calling .get_FOO_class() on the row record. - """ - def render(self, record, bound_column, value): - if value: - name = bound_column.name - css_class = getattr(record, f'get_{name}_class')() - label = getattr(record, f'get_{name}_display')() - return mark_safe( - f'{label}' - ) - return self.default - - def value(self, value): - return value - - -class ContentTypeColumn(tables.Column): - """ - Display a ContentType instance. - """ - def render(self, value): - if value is None: - return None - return content_type_name(value) - - def value(self, value): - if value is None: - return None - return content_type_identifier(value) - - -class ContentTypesColumn(tables.ManyToManyColumn): - """ - Display a list of ContentType instances. - """ - def __init__(self, separator=None, *args, **kwargs): - # Use a line break as the default separator - if separator is None: - separator = mark_safe('
      ') - super().__init__(separator=separator, *args, **kwargs) - - def transform(self, obj): - return content_type_name(obj) - - def value(self, value): - return ','.join([ - content_type_identifier(ct) for ct in self.filter(value) - ]) - - -class ColorColumn(tables.Column): - """ - Display a color (#RRGGBB). - """ - def render(self, value): - return mark_safe( - f' ' - ) - - def value(self, value): - return f'#{value}' - - -class ColoredLabelColumn(tables.TemplateColumn): - """ - Render a colored label (e.g. for DeviceRoles). - """ - template_code = """ -{% load helpers %} - {% if value %} - - {{ value }} - -{% else %} - — -{% endif %} -""" - - 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): - """ - Render a count of related objects linked to a filtered URL. - - :param viewname: The view name to use for URL resolution - :param view_kwargs: Additional kwargs to pass for URL resolution (optional) - :param url_params: A dict of query parameters to append to the URL (e.g. ?foo=bar) (optional) - """ - def __init__(self, viewname, *args, view_kwargs=None, url_params=None, default=0, **kwargs): - self.viewname = viewname - self.view_kwargs = view_kwargs or {} - self.url_params = url_params - super().__init__(*args, default=default, **kwargs) - - def render(self, record, value): - if value: - url = reverse(self.viewname, kwargs=self.view_kwargs) - if self.url_params: - url += '?' + '&'.join([ - f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}' - for k, v in self.url_params.items() - ]) - return mark_safe(f'{value}') - return value - - def value(self, value): - return value - - -class TagColumn(tables.TemplateColumn): - """ - Display a list of tags assigned to the object. - """ - template_code = """ - {% load helpers %} - {% for tag in value.all %} - {% tag tag url_name=url_name %} - {% empty %} - - {% endfor %} - """ - - def __init__(self, url_name=None): - super().__init__( - orderable=False, - 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 CustomFieldColumn(tables.Column): - """ - Display custom fields in the appropriate format. - """ - def __init__(self, customfield, *args, **kwargs): - self.customfield = customfield - kwargs['accessor'] = Accessor(f'custom_field_data__{customfield.name}') - if 'verbose_name' not in kwargs: - kwargs['verbose_name'] = customfield.label or customfield.name - - super().__init__(*args, **kwargs) - - def render(self, value): - if isinstance(value, list): - return ', '.join(v for v in value) - elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is True: - return mark_safe('') - elif self.customfield.type == CustomFieldTypeChoices.TYPE_BOOLEAN and value is False: - return mark_safe('') - elif self.customfield.type == CustomFieldTypeChoices.TYPE_URL: - return mark_safe(f'{value}') - if value is not None: - return value - return self.default - - def value(self, value): - if isinstance(value, list): - return ','.join(v for v in value) - if value is not None: - return value - return self.default - - -class CustomLinkColumn(tables.Column): - """ - Render a custom links as a table column. - """ - def __init__(self, customlink, *args, **kwargs): - self.customlink = customlink - kwargs['accessor'] = Accessor('pk') - if 'verbose_name' not in kwargs: - kwargs['verbose_name'] = customlink.name - - super().__init__(*args, **kwargs) - - def render(self, record): - try: - rendered = self.customlink.render({'obj': record}) - if rendered: - return mark_safe(f'{rendered["text"]}') - except Exception as e: - return mark_safe(f' Error') - return '' - - def value(self, record): - try: - rendered = self.customlink.render({'obj': record}) - if rendered: - return rendered['link'] - except Exception: - pass - return None - - -class MPTTColumn(tables.TemplateColumn): - """ - Display a nested hierarchy for MPTT-enabled models. - """ - template_code = """ - {% load helpers %} - {% for i in record.level|as_range %}{% 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}%' - - -class MarkdownColumn(tables.TemplateColumn): - """ - Render a Markdown string. - """ - template_code = """ - {% load helpers %} - {% if value %} - {{ value|render_markdown }} - {% else %} - — - {% endif %} - """ - - def __init__(self): - super().__init__( - template_code=self.template_code - ) - - def value(self, value): - return value - - -# -# Pagination -# - -def paginate_table(table, request): - """ - Paginate a table given a request context. - """ - paginate = { - 'paginator_class': EnhancedPaginator, - 'per_page': get_paginate_count(request) - } - RequestConfig(request, paginate).configure(table) - - -# -# Callables -# - -def linkify_email(value): - if value is None: - return None - return f"mailto:{value}" - - def linkify_phone(value): + """ + Render a telephone number as a hyperlink. + """ if value is None: return None return f"tel:{value}" diff --git a/netbox/utilities/templates/builtins/badge.html b/netbox/utilities/templates/builtins/badge.html new file mode 100644 index 000000000..d12c6108e --- /dev/null +++ b/netbox/utilities/templates/builtins/badge.html @@ -0,0 +1 @@ +{% if value or show_empty %}{{ value }}{% endif %} diff --git a/netbox/utilities/templates/helpers/checkmark.html b/netbox/utilities/templates/builtins/checkmark.html similarity index 100% rename from netbox/utilities/templates/helpers/checkmark.html rename to netbox/utilities/templates/builtins/checkmark.html diff --git a/netbox/utilities/templates/helpers/tag.html b/netbox/utilities/templates/builtins/tag.html similarity index 61% rename from netbox/utilities/templates/helpers/tag.html rename to netbox/utilities/templates/builtins/tag.html index addb2380b..d63b6afa6 100644 --- a/netbox/utilities/templates/helpers/tag.html +++ b/netbox/utilities/templates/builtins/tag.html @@ -1,3 +1,3 @@ {% load helpers %} -{% if url_name %}{% endif %}{{ tag }}{% if url_name %}{% endif %} +{% if viewname %}{% endif %}{{ tag }}{% if viewname %}{% endif %} diff --git a/netbox/utilities/templates/buttons/add.html b/netbox/utilities/templates/buttons/add.html index 60767951a..ab74dd7b9 100644 --- a/netbox/utilities/templates/buttons/add.html +++ b/netbox/utilities/templates/buttons/add.html @@ -1,9 +1,5 @@ -{% comment %} {% endcomment %} - - Add - +{% endif %} diff --git a/netbox/utilities/templates/buttons/bulk_delete.html b/netbox/utilities/templates/buttons/bulk_delete.html new file mode 100644 index 000000000..55e477743 --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_delete.html @@ -0,0 +1,5 @@ +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/bulk_edit.html b/netbox/utilities/templates/buttons/bulk_edit.html new file mode 100644 index 000000000..cdac49b46 --- /dev/null +++ b/netbox/utilities/templates/buttons/bulk_edit.html @@ -0,0 +1,5 @@ +{% if url %} + +{% endif %} diff --git a/netbox/utilities/templates/buttons/import.html b/netbox/utilities/templates/buttons/import.html index 9df619607..8dab911c4 100644 --- a/netbox/utilities/templates/buttons/import.html +++ b/netbox/utilities/templates/buttons/import.html @@ -1,3 +1,5 @@ - -  Import - +{% if url %} + + Import + +{% endif %} diff --git a/netbox/utilities/templates/helpers/badge.html b/netbox/utilities/templates/helpers/badge.html deleted file mode 100644 index 4057eddc1..000000000 --- a/netbox/utilities/templates/helpers/badge.html +++ /dev/null @@ -1 +0,0 @@ -{% if value or show_empty %}{{ value }}{% endif %} diff --git a/netbox/utilities/templates/widgets/selectwithdisabled_option.html b/netbox/utilities/templates/widgets/select_option.html similarity index 100% rename from netbox/utilities/templates/widgets/selectwithdisabled_option.html rename to netbox/utilities/templates/widgets/select_option.html diff --git a/netbox/utilities/templatetags/builtins/__init__.py b/netbox/utilities/templatetags/builtins/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/templatetags/builtins/filters.py b/netbox/utilities/templatetags/builtins/filters.py new file mode 100644 index 000000000..4a3db0a3c --- /dev/null +++ b/netbox/utilities/templatetags/builtins/filters.py @@ -0,0 +1,187 @@ +import datetime +import json +import re + +import yaml +from django import template +from django.contrib.contenttypes.models import ContentType +from django.utils.html import strip_tags +from django.utils.safestring import mark_safe +from markdown import markdown + +from netbox.config import get_config +from utilities.markdown import StrikethroughExtension +from utilities.utils import foreground_color + +register = template.Library() + + +# +# General +# + +@register.filter() +def linkify(instance, attr=None): + """ + Render a hyperlink for an object with a `get_absolute_url()` method, optionally specifying the name of an + attribute to use for the link text. If no attribute is given, the object's string representation will be + used. + + If the object has no `get_absolute_url()` method, return the text without a hyperlink element. + """ + if instance is None: + return '' + + text = getattr(instance, attr) if attr is not None else str(instance) + try: + url = instance.get_absolute_url() + return mark_safe(f'{text}') + except (AttributeError, TypeError): + return text + + +@register.filter() +def bettertitle(value): + """ + Alternative to the builtin title(). Ensures that the first letter of each word is uppercase but retains the + original case of all others. + """ + return ' '.join([w[0].upper() + w[1:] for w in value.split()]) + + +@register.filter() +def fgcolor(value, dark='000000', light='ffffff'): + """ + Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format. The foreground + color with the better contrast is returned. + + Args: + value: The background color + dark: The foreground color to use for light backgrounds + light: The foreground color to use for dark backgrounds + """ + value = value.lower().strip('#') + if not re.match('^[0-9a-f]{6}$', value): + return '' + return f'#{foreground_color(value, dark, light)}' + + +@register.filter() +def meta(model, attr): + """ + Return the specified Meta attribute of a model. This is needed because Django does not permit templates + to access attributes which begin with an underscore (e.g. _meta). + + Args: + model: A Django model class or instance + attr: The attribute name + """ + return getattr(model._meta, attr, '') + + +@register.filter() +def placeholder(value): + """ + Render a muted placeholder if the value equates to False. + """ + if value not in ('', None): + return value + placeholder = '' + return mark_safe(placeholder) + + +@register.filter() +def split(value, separator=','): + """ + Wrapper for Python's `split()` string method. + + Args: + value: A string + separator: String on which the value will be split + """ + return value.split(separator) + + +@register.filter() +def tzoffset(value): + """ + Returns the hour offset of a given time zone using the current time. + """ + return datetime.datetime.now(value).strftime('%z') + + +# +# Content types +# + +@register.filter() +def content_type(model): + """ + Return the ContentType for the given object. + """ + return ContentType.objects.get_for_model(model) + + +@register.filter() +def content_type_id(model): + """ + Return the ContentType ID for the given object. + """ + content_type = ContentType.objects.get_for_model(model) + if content_type: + return content_type.pk + return None + + +# +# Rendering +# + +@register.filter('markdown', is_safe=True) +def render_markdown(value): + """ + Render a string as Markdown. This filter is invoked as "markdown": + + {{ md_source_text|markdown }} + """ + schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) + + # Strip HTML tags + value = strip_tags(value) + + # Sanitize Markdown links + pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' + value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) + + # Sanitize Markdown reference links + pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' + value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) + + # Render Markdown + html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()]) + + # If the string is not empty wrap it in rendered-markdown to style tables + if html: + html = f'
      {html}
      ' + + return mark_safe(html) + + +@register.filter('json') +def render_json(value): + """ + Render a dictionary as formatted JSON. This filter is invoked as "json": + + {{ data_dict|json }} + """ + return json.dumps(value, ensure_ascii=False, indent=4, sort_keys=True) + + +@register.filter('yaml') +def render_yaml(value): + """ + Render a dictionary as formatted YAML. This filter is invoked as "yaml": + + {{ data_dict|yaml }} + """ + return yaml.dump(json.loads(json.dumps(value))) diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py new file mode 100644 index 000000000..666b6a31c --- /dev/null +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -0,0 +1,54 @@ +from django import template + +register = template.Library() + + +@register.inclusion_tag('builtins/tag.html') +def tag(value, viewname=None): + """ + Display a tag, optionally linked to a filtered list of objects. + + Args: + value: A Tag instance + viewname: If provided, the tag will be a hyperlink to the specified view's URL + """ + return { + 'tag': value, + 'viewname': viewname, + } + + +@register.inclusion_tag('builtins/badge.html') +def badge(value, bg_color=None, show_empty=False): + """ + Display the specified number as a badge. + + Args: + value: The value to be displayed within the badge + bg_color: Background color CSS name + show_empty: If true, display the badge even if value is None or zero + """ + return { + 'value': value, + 'bg_color': bg_color or 'secondary', + 'show_empty': show_empty, + } + + +@register.inclusion_tag('builtins/checkmark.html') +def checkmark(value, show_false=True, true='Yes', false='No'): + """ + Display either a green checkmark or red X to indicate a boolean value. + + Args: + value: True or False + show_false: Show false values + true: Text label for true values + false: Text label for false values + """ + return { + 'value': bool(value), + 'show_false': show_false, + 'true_label': true, + 'false_label': false, + } diff --git a/netbox/utilities/templatetags/buttons.py b/netbox/utilities/templatetags/buttons.py index d8b4987ba..4b8178405 100644 --- a/netbox/utilities/templatetags/buttons.py +++ b/netbox/utilities/templatetags/buttons.py @@ -1,33 +1,20 @@ from django import template -from django.urls import reverse +from django.contrib.contenttypes.models import ContentType +from django.urls import NoReverseMatch, reverse from extras.models import ExportTemplate -from utilities.utils import prepare_cloned_fields +from utilities.utils import get_viewname, prepare_cloned_fields register = template.Library() -def _get_viewname(instance, action): - """ - Return the appropriate viewname for adding, editing, or deleting an instance. - """ - - # Validate action - assert action in ('add', 'edit', 'delete') - viewname = "{}:{}_{}".format( - instance._meta.app_label, instance._meta.model_name, action - ) - - return viewname - - # # Instance buttons # @register.inclusion_tag('buttons/clone.html') def clone_button(instance): - url = reverse(_get_viewname(instance, 'add')) + url = reverse(get_viewname(instance, 'add')) # Populate cloned field values param_string = prepare_cloned_fields(instance).urlencode() @@ -41,7 +28,7 @@ def clone_button(instance): @register.inclusion_tag('buttons/edit.html') def edit_button(instance): - viewname = _get_viewname(instance, 'edit') + viewname = get_viewname(instance, 'edit') url = reverse(viewname, kwargs={'pk': instance.pk}) return { @@ -51,7 +38,7 @@ def edit_button(instance): @register.inclusion_tag('buttons/delete.html') def delete_button(instance): - viewname = _get_viewname(instance, 'delete') + viewname = get_viewname(instance, 'delete') url = reverse(viewname, kwargs={'pk': instance.pk}) return { @@ -64,24 +51,32 @@ def delete_button(instance): # @register.inclusion_tag('buttons/add.html') -def add_button(url): - url = reverse(url) +def add_button(model, action='add'): + try: + url = reverse(get_viewname(model, action)) + except NoReverseMatch: + url = None return { - 'add_url': url, + 'url': url, } @register.inclusion_tag('buttons/import.html') -def import_button(url): +def import_button(model, action='import'): + try: + url = reverse(get_viewname(model, action)) + except NoReverseMatch: + url = None return { - 'import_url': url, + 'url': url, } @register.inclusion_tag('buttons/export.html', takes_context=True) -def export_button(context, content_type): +def export_button(context, model): + content_type = ContentType.objects.get_for_model(model) user = context['request'].user # Determine if the "all data" export returns CSV or YAML @@ -97,3 +92,31 @@ def export_button(context, content_type): 'export_templates': export_templates, 'data_format': data_format, } + + +@register.inclusion_tag('buttons/bulk_edit.html') +def bulk_edit_button(model, action='bulk_edit', query_params=None): + try: + url = reverse(get_viewname(model, action)) + if query_params: + url = f'{url}?{query_params.urlencode()}' + except NoReverseMatch: + url = None + + return { + 'url': url, + } + + +@register.inclusion_tag('buttons/bulk_delete.html') +def bulk_delete_button(model, action='bulk_delete', query_params=None): + try: + url = reverse(get_viewname(model, action)) + if query_params: + url = f'{url}?{query_params.urlencode()}' + except NoReverseMatch: + url = None + + return { + 'url': url, + } diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py index cff3428c0..db4d14c24 100644 --- a/netbox/utilities/templatetags/helpers.py +++ b/netbox/utilities/templatetags/helpers.py @@ -1,24 +1,16 @@ import datetime import decimal -import json -import re from typing import Dict, Any -import yaml from django import template from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.template.defaultfilters import date from django.urls import NoReverseMatch, reverse from django.utils import timezone -from django.utils.html import strip_tags from django.utils.safestring import mark_safe -from markdown import markdown -from netbox.config import get_config from utilities.forms import get_selected_values, TableConfigForm -from utilities.markdown import StrikethroughExtension -from utilities.utils import foreground_color +from utilities.utils import get_viewname register = template.Library() @@ -27,95 +19,13 @@ register = template.Library() # Filters # -@register.filter() -def placeholder(value): - """ - Render a muted placeholder if value equates to False. - """ - if value not in ('', None): - return value - placeholder = '' - return mark_safe(placeholder) - - -@register.filter(is_safe=True) -def render_markdown(value): - """ - Render text as Markdown - """ - schemes = '|'.join(get_config().ALLOWED_URL_SCHEMES) - - # Strip HTML tags - value = strip_tags(value) - - # Sanitize Markdown links - pattern = fr'\[([^\]]+)\]\((?!({schemes})).*:(.+)\)' - value = re.sub(pattern, '[\\1](\\3)', value, flags=re.IGNORECASE) - - # Sanitize Markdown reference links - pattern = fr'\[(.+)\]:\s*(?!({schemes}))\w*:(.+)' - value = re.sub(pattern, '[\\1]: \\3', value, flags=re.IGNORECASE) - - # Render Markdown - html = markdown(value, extensions=['fenced_code', 'tables', StrikethroughExtension()]) - - # If the string is not empty wrap it in rendered-markdown to style tables - if html: - html = f'
      {html}
      ' - - return mark_safe(html) - - -@register.filter() -def render_json(value): - """ - Render a dictionary as formatted JSON. - """ - return json.dumps(value, ensure_ascii=False, indent=4, sort_keys=True) - - -@register.filter() -def render_yaml(value): - """ - Render a dictionary as formatted YAML. - """ - return yaml.dump(json.loads(json.dumps(value))) - - -@register.filter() -def meta(obj, attr): - """ - Return the specified Meta attribute of a model. This is needed because Django does not permit templates - to access attributes which begin with an underscore (e.g. _meta). - """ - return getattr(obj._meta, attr, '') - - -@register.filter() -def content_type(obj): - """ - Return the ContentType for the given object. - """ - return ContentType.objects.get_for_model(obj) - - -@register.filter() -def content_type_id(obj): - """ - Return the ContentType ID for the given object. - """ - content_type = ContentType.objects.get_for_model(obj) - if content_type: - return content_type.pk - return None - @register.filter() def viewname(model, action): """ Return the view name for the given model and action. Does not perform any validation. """ - return f'{model._meta.app_label}:{model._meta.model_name}_{action}' + return get_viewname(model, action) @register.filter() @@ -123,24 +33,16 @@ def validated_viewname(model, action): """ Return the view name for the given model and action if valid, or None if invalid. """ - viewname = f'{model._meta.app_label}:{model._meta.model_name}_{action}' + viewname = get_viewname(model, action) + + # Validate the view name try: - # Validate and return the view name. We don't return the actual URL yet because many of the templates - # are written to pass a name to {% url %}. reverse(viewname) return viewname except NoReverseMatch: return None -@register.filter() -def bettertitle(value): - """ - Alternative to the builtin title(); uppercases words without replacing letters that are already uppercase. - """ - return ' '.join([w[0].upper() + w[1:] for w in value.split()]) - - @register.filter() def humanize_speed(speed): """ @@ -191,14 +93,6 @@ def simplify_decimal(value): return str(value).rstrip('0').rstrip('.') -@register.filter() -def tzoffset(value): - """ - Returns the hour offset of a given time zone using the current time. - """ - return datetime.datetime.now(value).strftime('%z') - - @register.filter(expects_localtime=True) def annotated_date(date_value): """ @@ -229,17 +123,6 @@ def annotated_now(): return annotated_date(datetime.datetime.now(tz=tzinfo)) -@register.filter() -def fgcolor(value): - """ - Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format. - """ - value = value.lower().strip('#') - if not re.match('^[0-9a-f]{6}$', value): - return '' - return f'#{foreground_color(value)}' - - @register.filter() def divide(x, y): """ @@ -276,14 +159,6 @@ def has_perms(user, permissions_list): return user.has_perms(permissions_list) -@register.filter() -def split(string, sep=','): - """ - Split a string by the given value (default: comma) - """ - return string.split(sep) - - @register.filter() def as_range(n): """ @@ -405,46 +280,6 @@ def utilization_graph(utilization, warning_threshold=75, danger_threshold=90): } -@register.inclusion_tag('helpers/tag.html') -def tag(tag, url_name=None): - """ - Display a tag, optionally linked to a filtered list of objects. - """ - return { - 'tag': tag, - 'url_name': url_name, - } - - -@register.inclusion_tag('helpers/badge.html') -def badge(value, bg_class='secondary', show_empty=False): - """ - Display the specified number as a badge. - """ - return { - 'value': value, - 'bg_class': bg_class, - 'show_empty': show_empty, - } - - -@register.inclusion_tag('helpers/checkmark.html') -def checkmark(value, show_false=True, true='Yes', false='No'): - """ - Display either a green checkmark or red X to indicate a boolean value. - - :param show_false: Display a red X if the value is False - :param true: Text label for true value - :param false: Text label for false value - """ - return { - 'value': bool(value), - 'show_false': show_false, - 'true_label': true, - 'false_label': false, - } - - @register.inclusion_tag('helpers/table_config_form.html') def table_config_form(table, table_name=None): return { diff --git a/netbox/utilities/testing/filtersets.py b/netbox/utilities/testing/filtersets.py index 98bb0d1ac..9c90f5530 100644 --- a/netbox/utilities/testing/filtersets.py +++ b/netbox/utilities/testing/filtersets.py @@ -24,12 +24,12 @@ class ChangeLoggedFilterSetTests(BaseFilterSetTests): def test_created(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] - self.queryset.filter(pk__in=pk_list).update(created=date(2021, 1, 1)) - params = {'created': '2021-01-01'} + self.queryset.filter(pk__in=pk_list).update(created=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) + params = {'created': '2021-01-01T00:00:00'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_last_updated(self): pk_list = self.queryset.values_list('pk', flat=True)[:2] - self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 1, 0, 0, 0, tzinfo=timezone.utc)) - params = {'last_updated': '2021-01-01T00:00:00'} + self.queryset.filter(pk__in=pk_list).update(last_updated=datetime(2021, 1, 2, 0, 0, 0, tzinfo=timezone.utc)) + params = {'last_updated': '2021-01-02T00:00:00'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py index bbf75e40e..8dbf5d602 100644 --- a/netbox/utilities/tests/test_choices.py +++ b/netbox/utilities/tests/test_choices.py @@ -30,8 +30,3 @@ class ChoiceSetTestCase(TestCase): def test_values(self): self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) - - def test_as_dict(self): - self.assertEqual(ExampleChoices.as_dict(), { - 'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three' - }) diff --git a/netbox/utilities/tests/test_filters.py b/netbox/utilities/tests/test_filters.py index 2616dbf36..5182722d1 100644 --- a/netbox/utilities/tests/test_filters.py +++ b/netbox/utilities/tests/test_filters.py @@ -5,9 +5,8 @@ from django.test import TestCase from mptt.fields import TreeForeignKey from taggit.managers import TaggableManager -from circuits.choices import CircuitStatusChoices -from circuits.filtersets import CircuitFilterSet -from circuits.models import Circuit, Provider, CircuitType +from circuits.filtersets import CircuitFilterSet, ProviderFilterSet +from circuits.models import Circuit, Provider from dcim.choices import * from dcim.fields import MACAddressField from dcim.filtersets import DeviceFilterSet, SiteFilterSet @@ -337,16 +336,16 @@ class DynamicFilterLookupExpressionTest(TestCase): """ Validate function of automatically generated filters using the Device model as an example. """ - device_queryset = Device.objects.all() - device_filterset = DeviceFilterSet - site_queryset = Site.objects.all() - site_filterset = SiteFilterSet - circuit_queryset = Circuit.objects.all() - circuit_filterset = CircuitFilterSet - @classmethod def setUpTestData(cls): + providers = ( + Provider(name='Provider 1', slug='provider-1', asn=65001), + Provider(name='Provider 2', slug='provider-2', asn=65101), + Provider(name='Provider 3', slug='provider-3', asn=65201), + ) + Provider.objects.bulk_create(providers) + manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -384,9 +383,9 @@ class DynamicFilterLookupExpressionTest(TestCase): region.save() sites = ( - Site(name='Site 1', slug='abc-site-1', region=regions[0], asn=65001), - Site(name='Site 2', slug='def-site-2', region=regions[1], asn=65101), - Site(name='Site 3', slug='ghi-site-3', region=regions[2], asn=65201), + Site(name='Site 1', slug='abc-site-1', region=regions[0]), + Site(name='Site 2', slug='def-site-2', region=regions[1]), + Site(name='Site 3', slug='ghi-site-3', region=regions[2]), ) Site.objects.bulk_create(sites) @@ -429,112 +428,112 @@ class DynamicFilterLookupExpressionTest(TestCase): def test_site_name_negation(self): params = {'name__n': ['Site 1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_icontains(self): params = {'slug__ic': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_icontains_negation(self): params = {'slug__nic': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_startswith(self): params = {'slug__isw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_startswith_negation(self): params = {'slug__nisw': ['abc']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_slug_endswith(self): params = {'slug__iew': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 1) def test_site_slug_endswith_negation(self): params = {'slug__niew': ['-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) - def test_site_asn_lt(self): + def test_provider_asn_lt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) - def test_site_asn_lte(self): + def test_provider_asn_lte(self): params = {'asn__lte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) - def test_site_asn_gt(self): + def test_provider_asn_gt(self): params = {'asn__lt': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 1) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 1) - def test_site_asn_gte(self): + def test_provider_asn_gte(self): params = {'asn__gte': [65101]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(ProviderFilterSet(params, Provider.objects.all()).qs.count(), 2) def test_site_region_negation(self): params = {'region__n': ['region-1']} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_site_region_id_negation(self): params = {'region_id__n': [Region.objects.first().pk]} - self.assertEqual(SiteFilterSet(params, self.site_queryset).qs.count(), 2) + self.assertEqual(SiteFilterSet(params, Site.objects.all()).qs.count(), 2) def test_device_name_eq(self): params = {'name': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_negation(self): params = {'name__n': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_startswith(self): params = {'name__isw': ['Device']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 3) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 3) def test_device_name_startswith_negation(self): params = {'name__nisw': ['Device 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_endswith(self): params = {'name__iew': [' 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_endswith_negation(self): params = {'name__niew': [' 1']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_name_icontains(self): params = {'name__ic': [' 2']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_name_icontains_negation(self): params = {'name__nic': [' ']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 0) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 0) def test_device_mac_address_negation(self): params = {'mac_address__n': ['00-00-00-00-00-01', 'aa-00-00-00-00-01']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_startswith(self): params = {'mac_address__isw': ['aa:']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_mac_address_startswith_negation(self): params = {'mac_address__nisw': ['aa:']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_endswith(self): params = {'mac_address__iew': [':02']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) def test_device_mac_address_endswith_negation(self): params = {'mac_address__niew': [':02']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_icontains(self): params = {'mac_address__ic': ['aa:', 'bb']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 2) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 2) def test_device_mac_address_icontains_negation(self): params = {'mac_address__nic': ['aa:', 'bb']} - self.assertEqual(DeviceFilterSet(params, self.device_queryset).qs.count(), 1) + self.assertEqual(DeviceFilterSet(params, Device.objects.all()).qs.count(), 1) diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index b1817b568..7b37c0b70 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -12,10 +12,44 @@ from jinja2.sandbox import SandboxedEnvironment from mptt.models import MPTTModel from dcim.choices import CableLengthUnitChoices +from extras.plugins import PluginConfig from extras.utils import is_taggable from utilities.constants import HTTP_REQUEST_META_SAFE_COPY +def get_viewname(model, action=None, rest_api=False): + """ + Return the view name for the given model and action, if valid. + + :param model: The model or instance to which the view applies + :param action: A string indicating the desired action (if any); e.g. "add" or "list" + :param rest_api: A boolean indicating whether this is a REST API view + """ + is_plugin = isinstance(model._meta.app_config, PluginConfig) + app_label = model._meta.app_label + model_name = model._meta.model_name + + if rest_api: + if is_plugin: + viewname = f'plugins-api:{app_label}-api:{model_name}' + else: + viewname = f'{app_label}-api:{model_name}' + # Append the action, if any + if action: + viewname = f'{viewname}-{action}' + + else: + viewname = f'{app_label}:{model_name}' + # Prepend the plugins namespace if this is a plugin model + if is_plugin: + viewname = f'plugins:{viewname}' + # Append the action, if any + if action: + viewname = f'{viewname}_{action}' + + return viewname + + def csv_format(data): """ Encapsulate any data which contains a comma within double quotes. @@ -281,7 +315,7 @@ def flatten_dict(d, prefix='', separator='.'): for k, v in d.items(): key = separator.join([prefix, k]) if prefix else k if type(v) is dict: - ret.update(flatten_dict(v, prefix=key)) + ret.update(flatten_dict(v, prefix=key, separator=separator)) else: ret[key] = v return ret diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index a3afcb1c6..858e7b491 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -1,10 +1,7 @@ from django.contrib.auth.mixins import AccessMixin from django.core.exceptions import ImproperlyConfigured -from django.shortcuts import get_object_or_404, redirect from django.urls import reverse from django.urls.exceptions import NoReverseMatch -from django.utils.http import is_safe_url -from django.views.generic import View from .permissions import resolve_permission @@ -103,9 +100,9 @@ class GetReturnURLMixin: # First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's # considered safe. - query_param = request.GET.get('return_url') or request.POST.get('return_url') - if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()): - return query_param + return_url = request.GET.get('return_url') or request.POST.get('return_url') + if return_url and return_url.startswith('/'): + return return_url # Next, check if the object being modified (if any) has an absolute URL. if obj is not None and obj.pk and hasattr(obj, 'get_absolute_url'): @@ -125,14 +122,3 @@ class GetReturnURLMixin: # If all else fails, return home. Ideally this should never happen. return reverse('home') - - -# -# Views -# - -class SlugRedirectView(View): - - def get(self, request, model, slug): - obj = get_object_or_404(model.objects.restrict(request.user, 'view'), slug=slug) - return redirect(obj.get_absolute_url()) diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 866b8f9bb..afdf50b96 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -3,10 +3,10 @@ from rest_framework import serializers from dcim.api.nested_serializers import NestedDeviceRoleSerializer, NestedPlatformSerializer, NestedSiteSerializer from dcim.choices import InterfaceModeChoices -from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer +from ipam.api.nested_serializers import NestedIPAddressSerializer, NestedVLANSerializer, NestedVRFSerializer from ipam.models import VLAN from netbox.api import ChoiceField, SerializedPKRelatedField -from netbox.api.serializers import PrimaryModelSerializer +from netbox.api.serializers import NetBoxModelSerializer from tenancy.api.nested_serializers import NestedTenantSerializer from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -17,7 +17,7 @@ from .nested_serializers import * # Clusters # -class ClusterTypeSerializer(PrimaryModelSerializer): +class ClusterTypeSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustertype-detail') cluster_count = serializers.IntegerField(read_only=True) @@ -29,7 +29,7 @@ class ClusterTypeSerializer(PrimaryModelSerializer): ] -class ClusterGroupSerializer(PrimaryModelSerializer): +class ClusterGroupSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:clustergroup-detail') cluster_count = serializers.IntegerField(read_only=True) @@ -41,7 +41,7 @@ class ClusterGroupSerializer(PrimaryModelSerializer): ] -class ClusterSerializer(PrimaryModelSerializer): +class ClusterSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:cluster-detail') type = NestedClusterTypeSerializer() group = NestedClusterGroupSerializer(required=False, allow_null=True, default=None) @@ -62,7 +62,7 @@ class ClusterSerializer(PrimaryModelSerializer): # Virtual machines # -class VirtualMachineSerializer(PrimaryModelSerializer): +class VirtualMachineSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) site = NestedSiteSerializer(read_only=True) @@ -103,7 +103,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): # VM interfaces # -class VMInterfaceSerializer(PrimaryModelSerializer): +class VMInterfaceSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail') virtual_machine = NestedVirtualMachineSerializer() parent = NestedVMInterfaceSerializer(required=False, allow_null=True) @@ -116,6 +116,7 @@ class VMInterfaceSerializer(PrimaryModelSerializer): required=False, many=True ) + vrf = NestedVRFSerializer(required=False, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) @@ -123,8 +124,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer): model = VMInterface fields = [ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', - 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated', - 'count_ipaddresses', 'count_fhrp_groups', + 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', 'custom_fields', 'created', + 'last_updated', 'count_ipaddresses', 'count_fhrp_groups', ] def validate(self, data): diff --git a/netbox/virtualization/api/urls.py b/netbox/virtualization/api/urls.py index d9df2fcfe..07b20bfd7 100644 --- a/netbox/virtualization/api/urls.py +++ b/netbox/virtualization/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.VirtualizationRootView # Clusters diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py index 894045c1a..665114881 100644 --- a/netbox/virtualization/api/views.py +++ b/netbox/virtualization/api/views.py @@ -1,7 +1,8 @@ from rest_framework.routers import APIRootView from dcim.models import Device -from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet, ModelViewSet +from extras.api.views import ConfigContextQuerySetMixin +from netbox.api.viewsets import NetBoxModelViewSet from utilities.utils import count_related from virtualization import filtersets from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -20,7 +21,7 @@ class VirtualizationRootView(APIRootView): # Clusters # -class ClusterTypeViewSet(CustomFieldModelViewSet): +class ClusterTypeViewSet(NetBoxModelViewSet): queryset = ClusterType.objects.annotate( cluster_count=count_related(Cluster, 'type') ).prefetch_related('tags') @@ -28,7 +29,7 @@ class ClusterTypeViewSet(CustomFieldModelViewSet): filterset_class = filtersets.ClusterTypeFilterSet -class ClusterGroupViewSet(CustomFieldModelViewSet): +class ClusterGroupViewSet(NetBoxModelViewSet): queryset = ClusterGroup.objects.annotate( cluster_count=count_related(Cluster, 'group') ).prefetch_related('tags') @@ -36,7 +37,7 @@ class ClusterGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.ClusterGroupFilterSet -class ClusterViewSet(CustomFieldModelViewSet): +class ClusterViewSet(NetBoxModelViewSet): queryset = Cluster.objects.prefetch_related( 'type', 'group', 'tenant', 'site', 'tags' ).annotate( @@ -51,7 +52,7 @@ class ClusterViewSet(CustomFieldModelViewSet): # Virtual machines # -class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): +class VirtualMachineViewSet(ConfigContextQuerySetMixin, NetBoxModelViewSet): queryset = VirtualMachine.objects.prefetch_related( 'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'tags' ) @@ -78,9 +79,10 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet) return serializers.VirtualMachineWithConfigContextSerializer -class VMInterfaceViewSet(ModelViewSet): +class VMInterfaceViewSet(NetBoxModelViewSet): queryset = VMInterface.objects.prefetch_related( - 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', + 'virtual_machine', 'parent', 'tags', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', + 'fhrp_group_assignments', ) serializer_class = serializers.VMInterfaceSerializer filterset_class = filtersets.VMInterfaceFilterSet diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 9c4eb6cd5..693e53df6 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class VirtualMachineStatusChoices(ChoiceSet): + key = 'VirtualMachine.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -14,20 +15,11 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_DECOMMISSIONING: 'warning', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'gray'), + (STATUS_ACTIVE, 'Active', 'green'), + (STATUS_PLANNED, 'Planned', 'cyan'), + (STATUS_STAGED, 'Staged', 'blue'), + (STATUS_FAILED, 'Failed', 'red'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'yellow'), + ] diff --git a/netbox/virtualization/filtersets.py b/netbox/virtualization/filtersets.py index d7fc28f6c..5a2aa8b42 100644 --- a/netbox/virtualization/filtersets.py +++ b/netbox/virtualization/filtersets.py @@ -2,9 +2,9 @@ import django_filters from django.db.models import Q from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.filters import TagFilter from extras.filtersets import LocalConfigContextFilterSet -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from ipam.models import VRF +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from utilities.filters import MultiValueMACAddressFilter, TreeNodeMultipleChoiceFilter from .choices import * @@ -20,7 +20,6 @@ __all__ = ( class ClusterTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = ClusterType @@ -28,18 +27,13 @@ class ClusterTypeFilterSet(OrganizationalModelFilterSet): class ClusterGroupFilterSet(OrganizationalModelFilterSet, ContactModelFilterSet): - tag = TagFilter() class Meta: model = ClusterGroup fields = ['id', 'name', 'slug', 'description'] -class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class ClusterFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet): region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), field_name='site__region', @@ -96,7 +90,6 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt to_field_name='slug', label='Cluster type (slug)', ) - tag = TagFilter() class Meta: model = Cluster @@ -111,11 +104,12 @@ class ClusterFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilt ) -class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFilterSet, LocalConfigContextFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class VirtualMachineFilterSet( + NetBoxModelFilterSet, + TenancyFilterSet, + ContactModelFilterSet, + LocalConfigContextFilterSet +): status = django_filters.MultipleChoiceFilter( choices=VirtualMachineStatusChoices, null_value=None @@ -217,7 +211,6 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactMo method='_has_primary_ip', label='Has a primary IP', ) - tag = TagFilter() class Meta: model = VirtualMachine @@ -238,11 +231,7 @@ class VirtualMachineFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactMo return queryset.exclude(params) -class VMInterfaceFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class VMInterfaceFilterSet(NetBoxModelFilterSet): cluster_id = django_filters.ModelMultipleChoiceFilter( field_name='virtual_machine__cluster', queryset=Cluster.objects.all(), @@ -278,7 +267,17 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet): mac_address = MultiValueMACAddressFilter( label='MAC address', ) - tag = TagFilter() + vrf_id = django_filters.ModelMultipleChoiceFilter( + field_name='vrf', + queryset=VRF.objects.all(), + label='VRF', + ) + vrf = django_filters.ModelMultipleChoiceFilter( + field_name='vrf__rd', + queryset=VRF.objects.all(), + to_field_name='rd', + label='VRF (RD)', + ) class Meta: model = VMInterface diff --git a/netbox/virtualization/forms/bulk_edit.py b/netbox/virtualization/forms/bulk_edit.py index 1e80e88e5..d5d33df2a 100644 --- a/netbox/virtualization/forms/bulk_edit.py +++ b/netbox/virtualization/forms/bulk_edit.py @@ -3,8 +3,8 @@ from django import forms from dcim.choices import InterfaceModeChoices from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.models import VLAN +from ipam.models import VLAN, VRF +from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField, @@ -23,39 +23,33 @@ __all__ = ( ) -class ClusterTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterType.objects.all(), - widget=forms.MultipleHiddenInput - ) +class ClusterTypeBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class ClusterGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=ClusterGroup.objects.all(), - widget=forms.MultipleHiddenInput + model = ClusterType + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class ClusterGroupBulkEditForm(NetBoxModelBulkEditForm): description = forms.CharField( max_length=200, required=False ) - class Meta: - nullable_fields = ['description'] - - -class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=Cluster.objects.all(), - widget=forms.MultipleHiddenInput() + model = ClusterGroup + fieldsets = ( + (None, ('description',)), ) + nullable_fields = ('description',) + + +class ClusterBulkEditForm(NetBoxModelBulkEditForm): type = DynamicModelChoiceField( queryset=ClusterType.objects.all(), required=False @@ -89,17 +83,17 @@ class ClusterBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Comments' ) - class Meta: - nullable_fields = [ - 'group', 'site', 'comments', 'tenant', - ] - - -class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VirtualMachine.objects.all(), - widget=forms.MultipleHiddenInput() + model = Cluster + fieldsets = ( + (None, ('type', 'group', 'tenant',)), + ('Site', ('region', 'site_group', 'site',)), ) + nullable_fields = ( + 'group', 'site', 'comments', 'tenant', + ) + + +class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(VirtualMachineStatusChoices), required=False, @@ -144,17 +138,17 @@ class VirtualMachineBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm label='Comments' ) - class Meta: - nullable_fields = [ - 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', - ] - - -class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=VMInterface.objects.all(), - widget=forms.MultipleHiddenInput() + model = VirtualMachine + fieldsets = ( + (None, ('cluster', 'status', 'role', 'tenant', 'platform')), + ('Resources', ('vcpus', 'memory', 'disk')) ) + nullable_fields = ( + 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'comments', + ) + + +class VMInterfaceBulkEditForm(NetBoxModelBulkEditForm): virtual_machine = forms.ModelChoiceField( queryset=VirtualMachine.objects.all(), required=False, @@ -196,11 +190,21 @@ class VMInterfaceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): queryset=VLAN.objects.all(), required=False ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) - class Meta: - nullable_fields = [ - 'parent', 'bridge', 'mtu', 'description', - ] + model = VMInterface + fieldsets = ( + (None, ('mtu', 'enabled', 'vrf', 'description')), + ('Related Interfaces', ('parent', 'bridge')), + ('802.1Q Switching', ('mode', 'untagged_vlan', 'tagged_vlans')), + ) + nullable_fields = ( + 'parent', 'bridge', 'mtu', 'vrf', 'description', + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index 797e13fee..b0315dd95 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -1,6 +1,7 @@ from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site -from extras.forms import CustomFieldModelCSVForm +from ipam.models import VRF +from netbox.forms import NetBoxModelCSVForm from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from virtualization.choices import * @@ -15,7 +16,7 @@ __all__ = ( ) -class ClusterTypeCSVForm(CustomFieldModelCSVForm): +class ClusterTypeCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -23,7 +24,7 @@ class ClusterTypeCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ClusterGroupCSVForm(CustomFieldModelCSVForm): +class ClusterGroupCSVForm(NetBoxModelCSVForm): slug = SlugField() class Meta: @@ -31,7 +32,7 @@ class ClusterGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'description') -class ClusterCSVForm(CustomFieldModelCSVForm): +class ClusterCSVForm(NetBoxModelCSVForm): type = CSVModelChoiceField( queryset=ClusterType.objects.all(), to_field_name='name', @@ -61,7 +62,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm): fields = ('name', 'type', 'group', 'site', 'comments') -class VirtualMachineCSVForm(CustomFieldModelCSVForm): +class VirtualMachineCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=VirtualMachineStatusChoices, help_text='Operational status' @@ -99,7 +100,7 @@ class VirtualMachineCSVForm(CustomFieldModelCSVForm): ) -class VMInterfaceCSVForm(CustomFieldModelCSVForm): +class VMInterfaceCSVForm(NetBoxModelCSVForm): virtual_machine = CSVModelChoiceField( queryset=VirtualMachine.objects.all(), to_field_name='name' @@ -121,11 +122,18 @@ class VMInterfaceCSVForm(CustomFieldModelCSVForm): required=False, help_text='IEEE 802.1Q operational mode (for L2 interfaces)' ) + vrf = CSVModelChoiceField( + queryset=VRF.objects.all(), + required=False, + to_field_name='rd', + help_text='Assigned VRF' + ) class Meta: model = VMInterface fields = ( 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', + 'vrf', ) def clean_enabled(self): diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index 908fa17c8..2f386e889 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -2,10 +2,12 @@ from django import forms from django.utils.translation import gettext as _ from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm -from tenancy.forms import TenancyFilterForm, ContactModelFilterForm +from extras.forms import LocalConfigContextFilterForm +from ipam.models import VRF +from netbox.forms import NetBoxModelFilterSetForm +from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from utilities.forms import ( - DynamicModelMultipleChoiceField, StaticSelect, StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, MultipleChoiceField, StaticSelect, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.choices import * from virtualization.models import * @@ -19,25 +21,25 @@ __all__ = ( ) -class ClusterTypeFilterForm(CustomFieldModelFilterForm): +class ClusterTypeFilterForm(NetBoxModelFilterSetForm): model = ClusterType tag = TagFilterField(model) -class ClusterGroupFilterForm(ContactModelFilterForm, CustomFieldModelFilterForm): +class ClusterGroupFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): model = ClusterGroup tag = TagFilterField(model) -class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm): model = Cluster - field_groups = [ - ['q', 'tag'], - ['group_id', 'type_id'], - ['region_id', 'site_group_id', 'site_id'], - ['tenant_group_id', 'tenant_id'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('group_id', 'type_id')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), required=False, @@ -72,16 +74,21 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, CustomFieldMo tag = TagFilterField(model) -class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, ContactModelFilterForm, CustomFieldModelFilterForm): +class VirtualMachineFilterForm( + LocalConfigContextFilterForm, + TenancyFilterForm, + ContactModelFilterForm, + NetBoxModelFilterSetForm +): model = VirtualMachine - field_groups = [ - ['q', 'tag'], - ['cluster_group_id', 'cluster_type_id', 'cluster_id'], - ['region_id', 'site_group_id', 'site_id'], - ['status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data'], - ['tenant_group_id', 'tenant_id'], - ['contact', 'contact_role'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Cluster', ('cluster_group_id', 'cluster_type_id', 'cluster_id')), + ('Location', ('region_id', 'site_group_id', 'site_id')), + ('Attriubtes', ('status', 'role_id', 'platform_id', 'mac_address', 'has_primary_ip', 'local_context_data')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role')), + ) cluster_group_id = DynamicModelMultipleChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -128,10 +135,9 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, }, label=_('Role') ) - status = forms.MultipleChoiceField( + status = MultipleChoiceField( choices=VirtualMachineStatusChoices, - required=False, - widget=StaticSelectMultiple() + required=False ) platform_id = DynamicModelMultipleChoiceField( queryset=Platform.objects.all(), @@ -153,13 +159,13 @@ class VirtualMachineFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, tag = TagFilterField(model) -class VMInterfaceFilterForm(CustomFieldModelFilterForm): +class VMInterfaceFilterForm(NetBoxModelFilterSetForm): model = VMInterface - field_groups = [ - ['q', 'tag'], - ['cluster_id', 'virtual_machine_id'], - ['enabled', 'mac_address'], - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Virtual Machine', ('cluster_id', 'virtual_machine_id')), + ('Attributes', ('enabled', 'mac_address', 'vrf_id')), + ) cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, @@ -183,4 +189,9 @@ class VMInterfaceFilterForm(CustomFieldModelFilterForm): required=False, label='MAC address' ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 6fa90ea65..314b0bddf 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -5,9 +5,9 @@ from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.forms import CustomFieldModelForm from extras.models import Tag -from ipam.models import IPAddress, VLAN, VLANGroup +from ipam.models import IPAddress, VLAN, VLANGroup, VRF +from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm from utilities.forms import ( BootstrapMixin, CommentField, ConfirmationForm, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -26,12 +26,8 @@ __all__ = ( ) -class ClusterTypeForm(CustomFieldModelForm): +class ClusterTypeForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = ClusterType @@ -40,12 +36,8 @@ class ClusterTypeForm(CustomFieldModelForm): ) -class ClusterGroupForm(CustomFieldModelForm): +class ClusterGroupForm(NetBoxModelForm): slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = ClusterGroup @@ -54,7 +46,7 @@ class ClusterGroupForm(CustomFieldModelForm): ) -class ClusterForm(TenancyForm, CustomFieldModelForm): +class ClusterForm(TenancyForm, NetBoxModelForm): type = DynamicModelChoiceField( queryset=ClusterType.objects.all() ) @@ -85,9 +77,10 @@ class ClusterForm(TenancyForm, CustomFieldModelForm): } ) comments = CommentField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), + ('Tenancy', ('tenant_group', 'tenant')), ) class Meta: @@ -95,10 +88,6 @@ class ClusterForm(TenancyForm, CustomFieldModelForm): fields = ( 'name', 'type', 'group', 'tenant', 'region', 'site_group', 'site', 'comments', 'tags', ) - fieldsets = ( - ('Cluster', ('name', 'type', 'group', 'region', 'site_group', 'site', 'tags')), - ('Tenancy', ('tenant_group', 'tenant')), - ) class ClusterAddDevicesForm(BootstrapMixin, forms.Form): @@ -171,7 +160,7 @@ class ClusterRemoveDevicesForm(ConfirmationForm): ) -class VirtualMachineForm(TenancyForm, CustomFieldModelForm): +class VirtualMachineForm(TenancyForm, NetBoxModelForm): cluster_group = DynamicModelChoiceField( queryset=ClusterGroup.objects.all(), required=False, @@ -201,9 +190,14 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm): required=False, label='' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Virtual Machine', ('name', 'role', 'status', 'tags')), + ('Cluster', ('cluster_group', 'cluster')), + ('Tenancy', ('tenant_group', 'tenant')), + ('Management', ('platform', 'primary_ip4', 'primary_ip6')), + ('Resources', ('vcpus', 'memory', 'disk')), + ('Config Context', ('local_context_data',)), ) class Meta: @@ -212,14 +206,6 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm): 'name', 'status', 'cluster_group', 'cluster', 'role', 'tenant_group', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'local_context_data', ] - fieldsets = ( - ('Virtual Machine', ('name', 'role', 'status', 'tags')), - ('Cluster', ('cluster_group', 'cluster')), - ('Tenancy', ('tenant_group', 'tenant')), - ('Management', ('platform', 'primary_ip4', 'primary_ip6')), - ('Resources', ('vcpus', 'memory', 'disk')), - ('Config Context', ('local_context_data',)), - ) help_texts = { 'local_context_data': "Local config context data overwrites all sources contexts in the final rendered " "config context", @@ -271,16 +257,22 @@ class VirtualMachineForm(TenancyForm, CustomFieldModelForm): self.fields['primary_ip6'].widget.attrs['readonly'] = True -class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): +class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) bridge = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -293,6 +285,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -301,18 +294,20 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' ) class Meta: model = VMInterface fields = [ 'virtual_machine', 'name', 'parent', 'bridge', 'enabled', 'mac_address', 'mtu', 'description', 'mode', - 'tags', 'untagged_vlan', 'tagged_vlans', + 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { 'virtual_machine': forms.HiddenInput(), @@ -324,15 +319,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Restrict parent interface assignment by VM - self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id) - self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id) - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 332334594..feab3bb3a 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,81 +1,17 @@ from django import forms -from dcim.choices import InterfaceModeChoices -from dcim.forms.common import InterfaceCommonForm -from extras.forms import CustomFieldsMixin -from extras.models import Tag -from ipam.models import VLAN -from utilities.forms import ( - add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, - StaticSelect, -) -from virtualization.models import VMInterface, VirtualMachine +from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField +from .models import VirtualMachine __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): - model = VMInterface +class VMInterfaceCreateForm(BootstrapMixin, forms.Form): virtual_machine = DynamicModelChoiceField( queryset=VirtualMachine.objects.all() ) name_pattern = ExpandableNameField( label='Name' ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - parent = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine', - } - ) - bridge = DynamicModelChoiceField( - queryset=VMInterface.objects.all(), - required=False, - query_params={ - 'virtual_machine_id': '$virtual_machine', - } - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - description = forms.CharField( - max_length=200, - required=False - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect(), - ) - untagged_vlan = DynamicModelChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tagged_vlans = DynamicModelMultipleChoiceField( - queryset=VLAN.objects.all(), - required=False - ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) - field_order = ( - 'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode', - 'untagged_vlan', 'tagged_vlans', 'tags' - ) - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine') - - # Limit VLAN choices by virtual machine - self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id) - self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id) diff --git a/netbox/virtualization/graphql/types.py b/netbox/virtualization/graphql/types.py index c21b6f9a1..96b0fc875 100644 --- a/netbox/virtualization/graphql/types.py +++ b/netbox/virtualization/graphql/types.py @@ -1,7 +1,7 @@ from dcim.graphql.types import ComponentObjectType from extras.graphql.mixins import ConfigContextMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType from virtualization import filtersets, models __all__ = ( @@ -13,7 +13,7 @@ __all__ = ( ) -class ClusterType(VLANGroupsMixin, PrimaryObjectType): +class ClusterType(VLANGroupsMixin, NetBoxObjectType): class Meta: model = models.Cluster @@ -37,7 +37,7 @@ class ClusterTypeType(OrganizationalObjectType): filterset_class = filtersets.ClusterTypeFilterSet -class VirtualMachineType(ConfigContextMixin, PrimaryObjectType): +class VirtualMachineType(ConfigContextMixin, NetBoxObjectType): class Meta: model = models.VirtualMachine diff --git a/netbox/virtualization/migrations/0027_standardize_id_fields.py b/netbox/virtualization/migrations/0027_standardize_id_fields.py new file mode 100644 index 000000000..01d7e8af1 --- /dev/null +++ b/netbox/virtualization/migrations/0027_standardize_id_fields.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0026_vminterface_bridge'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustergroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='clustertype', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='virtualmachine', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vminterface', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/virtualization/migrations/0028_vminterface_vrf.py b/netbox/virtualization/migrations/0028_vminterface_vrf.py new file mode 100644 index 000000000..a188e1c60 --- /dev/null +++ b/netbox/virtualization/migrations/0028_vminterface_vrf.py @@ -0,0 +1,20 @@ +# Generated by Django 3.2.12 on 2022-02-07 14:39 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0056_standardize_id_fields'), + ('virtualization', '0027_standardize_id_fields'), + ] + + operations = [ + migrations.AddField( + model_name='vminterface', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vminterfaces', to='ipam.vrf'), + ), + ] diff --git a/netbox/virtualization/migrations/0029_created_datetimefield.py b/netbox/virtualization/migrations/0029_created_datetimefield.py new file mode 100644 index 000000000..d22547a25 --- /dev/null +++ b/netbox/virtualization/migrations/0029_created_datetimefield.py @@ -0,0 +1,38 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('virtualization', '0028_vminterface_vrf'), + ] + + operations = [ + migrations.AlterField( + model_name='cluster', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='clustergroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='clustertype', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='virtualmachine', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='vminterface', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5a1bcd42f..586bb8a9e 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -7,15 +7,13 @@ from django.urls import reverse from dcim.models import BaseInterface, Device from extras.models import ConfigContextModel from extras.querysets import ConfigContextModelQuerySet -from extras.utils import extras_features from netbox.config import get_config -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface from utilities.query_functions import CollateAsChar from .choices import * - __all__ = ( 'Cluster', 'ClusterGroup', @@ -29,7 +27,6 @@ __all__ = ( # Cluster types # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterType(OrganizationalModel): """ A type of Cluster. @@ -61,7 +58,6 @@ class ClusterType(OrganizationalModel): # Cluster groups # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ClusterGroup(OrganizationalModel): """ An organizational group of Clusters. @@ -104,8 +100,7 @@ class ClusterGroup(OrganizationalModel): # Clusters # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Cluster(PrimaryModel): +class Cluster(NetBoxModel): """ A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices. """ @@ -188,8 +183,7 @@ class Cluster(PrimaryModel): # Virtual machines # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VirtualMachine(PrimaryModel, ConfigContextModel): +class VirtualMachine(NetBoxModel, ConfigContextModel): """ A virtual machine which runs inside a Cluster. """ @@ -328,8 +322,8 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): field: f"The specified IP address ({ip}) is not assigned to this VM.", }) - def get_status_class(self): - return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return VirtualMachineStatusChoices.colors.get(self.status) @property def primary_ip(self): @@ -351,8 +345,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): # Interfaces # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VMInterface(PrimaryModel, BaseInterface): +class VMInterface(NetBoxModel, BaseInterface): virtual_machine = models.ForeignKey( to='virtualization.VirtualMachine', on_delete=models.CASCADE, @@ -391,6 +384,14 @@ class VMInterface(PrimaryModel, BaseInterface): object_id_field='assigned_object_id', related_query_name='vminterface' ) + vrf = models.ForeignKey( + to='ipam.VRF', + on_delete=models.SET_NULL, + related_name='vminterfaces', + null=True, + blank=True, + verbose_name='VRF' + ) fhrp_group_assignments = GenericRelation( to='ipam.FHRPGroupAssignment', content_type_field='interface_type', @@ -448,8 +449,9 @@ class VMInterface(PrimaryModel, BaseInterface): }) def to_objectchange(self, action): - # Annotate the parent VirtualMachine - return super().to_objectchange(action, related_object=self.virtual_machine) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.virtual_machine + return objectchange @property def parent_object(self): diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py deleted file mode 100644 index afc1d038b..000000000 --- a/netbox/virtualization/tables.py +++ /dev/null @@ -1,226 +0,0 @@ -import django_tables2 as tables -from dcim.tables.devices import BaseInterfaceTable -from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn, - ToggleColumn, -) -from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface - -__all__ = ( - 'ClusterTable', - 'ClusterGroupTable', - 'ClusterTypeTable', - 'VirtualMachineTable', - 'VirtualMachineVMInterfaceTable', - 'VMInterfaceTable', -) - -VMINTERFACE_BUTTONS = """ -{% if perms.ipam.add_ipaddress %} - - - -{% endif %} -""" - - -# -# Cluster types -# - -class ClusterTypeTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - cluster_count = tables.Column( - verbose_name='Clusters' - ) - tags = TagColumn( - url_name='virtualization:clustertype_list' - ) - actions = ButtonsColumn(ClusterType) - - class Meta(BaseTable.Meta): - model = ClusterType - fields = ( - 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') - - -# -# Cluster groups -# - -class ClusterGroupTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - cluster_count = tables.Column( - verbose_name='Clusters' - ) - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - tags = TagColumn( - url_name='virtualization:clustergroup_list' - ) - actions = ButtonsColumn(ClusterGroup) - - class Meta(BaseTable.Meta): - model = ClusterGroup - fields = ( - 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'actions', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions') - - -# -# Clusters -# - -class ClusterTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - linkify=True - ) - type = tables.Column( - linkify=True - ) - group = tables.Column( - linkify=True - ) - tenant = tables.Column( - linkify=True - ) - site = tables.Column( - linkify=True - ) - device_count = LinkedCountColumn( - viewname='dcim:device_list', - url_params={'cluster_id': 'pk'}, - verbose_name='Devices' - ) - vm_count = LinkedCountColumn( - viewname='virtualization:virtualmachine_list', - url_params={'cluster_id': 'pk'}, - verbose_name='VMs' - ) - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - comments = MarkdownColumn() - tags = TagColumn( - url_name='virtualization:cluster_list' - ) - - class Meta(BaseTable.Meta): - model = Cluster - fields = ( - 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', 'tags', - 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') - - -# -# Virtual machines -# - -class VirtualMachineTable(BaseTable): - pk = ToggleColumn() - name = tables.Column( - order_by=('_name',), - linkify=True - ) - status = ChoiceFieldColumn() - cluster = tables.Column( - linkify=True - ) - role = ColoredLabelColumn() - tenant = TenantColumn() - comments = MarkdownColumn() - primary_ip4 = tables.Column( - linkify=True, - verbose_name='IPv4 Address' - ) - primary_ip6 = tables.Column( - linkify=True, - verbose_name='IPv6 Address' - ) - primary_ip = tables.Column( - linkify=True, - order_by=('primary_ip4', 'primary_ip6'), - verbose_name='IP Address' - ) - contacts = tables.ManyToManyColumn( - linkify_item=True - ) - tags = TagColumn( - url_name='virtualization:virtualmachine_list' - ) - - class Meta(BaseTable.Meta): - model = VirtualMachine - fields = ( - 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', - 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'contacts', 'tags', 'created', 'last_updated', - ) - default_columns = ( - 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', - ) - - -# -# VM components -# - -class VMInterfaceTable(BaseInterfaceTable): - pk = ToggleColumn() - virtual_machine = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True - ) - tags = TagColumn( - url_name='virtualization:vminterface_list' - ) - - class Meta(BaseTable.Meta): - model = VMInterface - fields = ( - 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') - - -class VirtualMachineVMInterfaceTable(VMInterfaceTable): - parent = tables.Column( - linkify=True - ) - bridge = tables.Column( - linkify=True - ) - actions = ButtonsColumn( - model=VMInterface, - buttons=('edit', 'delete'), - prepend_template=VMINTERFACE_BUTTONS - ) - - class Meta(BaseTable.Meta): - model = VMInterface - fields = ( - 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', - ) - default_columns = ( - 'pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses', 'actions', - ) - row_attrs = { - 'data-name': lambda record: record.name, - } diff --git a/netbox/virtualization/tables/__init__.py b/netbox/virtualization/tables/__init__.py new file mode 100644 index 000000000..dc1e7eb20 --- /dev/null +++ b/netbox/virtualization/tables/__init__.py @@ -0,0 +1,2 @@ +from .clusters import * +from .virtualmachines import * diff --git a/netbox/virtualization/tables/clusters.py b/netbox/virtualization/tables/clusters.py new file mode 100644 index 000000000..893d3c641 --- /dev/null +++ b/netbox/virtualization/tables/clusters.py @@ -0,0 +1,95 @@ +import django_tables2 as tables + +from netbox.tables import NetBoxTable, columns +from virtualization.models import Cluster, ClusterGroup, ClusterType + +__all__ = ( + 'ClusterTable', + 'ClusterGroupTable', + 'ClusterTypeTable', +) + + +class ClusterTypeTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + cluster_count = tables.Column( + verbose_name='Clusters' + ) + tags = columns.TagColumn( + url_name='virtualization:clustertype_list' + ) + + class Meta(NetBoxTable.Meta): + model = ClusterType + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'created', 'last_updated', 'tags', 'actions', + ) + default_columns = ('pk', 'name', 'cluster_count', 'description') + + +class ClusterGroupTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + cluster_count = tables.Column( + verbose_name='Clusters' + ) + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='virtualization:clustergroup_list' + ) + + class Meta(NetBoxTable.Meta): + model = ClusterGroup + fields = ( + 'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'actions', + ) + default_columns = ('pk', 'name', 'cluster_count', 'description') + + +class ClusterTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + type = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + tenant = tables.Column( + linkify=True + ) + site = tables.Column( + linkify=True + ) + device_count = columns.LinkedCountColumn( + viewname='dcim:device_list', + url_params={'cluster_id': 'pk'}, + verbose_name='Devices' + ) + vm_count = columns.LinkedCountColumn( + viewname='virtualization:virtualmachine_list', + url_params={'cluster_id': 'pk'}, + verbose_name='VMs' + ) + comments = columns.MarkdownColumn() + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='virtualization:cluster_list' + ) + + class Meta(NetBoxTable.Meta): + model = Cluster + fields = ( + 'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'contacts', + 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count') diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py new file mode 100644 index 000000000..d5017eb53 --- /dev/null +++ b/netbox/virtualization/tables/virtualmachines.py @@ -0,0 +1,119 @@ +import django_tables2 as tables + +from dcim.tables.devices import BaseInterfaceTable +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn +from virtualization.models import VirtualMachine, VMInterface + +__all__ = ( + 'VirtualMachineTable', + 'VirtualMachineVMInterfaceTable', + 'VMInterfaceTable', +) + +VMINTERFACE_BUTTONS = """ +{% if perms.ipam.add_ipaddress %} + + + +{% endif %} +""" + + +# +# Virtual machines +# + +class VirtualMachineTable(NetBoxTable): + name = tables.Column( + order_by=('_name',), + linkify=True + ) + status = columns.ChoiceFieldColumn() + cluster = tables.Column( + linkify=True + ) + role = columns.ColoredLabelColumn() + tenant = TenantColumn() + comments = columns.MarkdownColumn() + primary_ip4 = tables.Column( + linkify=True, + verbose_name='IPv4 Address' + ) + primary_ip6 = tables.Column( + linkify=True, + verbose_name='IPv6 Address' + ) + primary_ip = tables.Column( + linkify=True, + order_by=('primary_ip4', 'primary_ip6'), + verbose_name='IP Address' + ) + tags = columns.TagColumn( + url_name='virtualization:virtualmachine_list' + ) + + class Meta(NetBoxTable.Meta): + model = VirtualMachine + fields = ( + 'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', + 'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip', + ) + + +# +# VM components +# + +class VMInterfaceTable(BaseInterfaceTable): + virtual_machine = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True + ) + vrf = tables.Column( + linkify=True + ) + contacts = tables.ManyToManyColumn( + linkify_item=True + ) + tags = columns.TagColumn( + url_name='virtualization:vminterface_list' + ) + + class Meta(NetBoxTable.Meta): + model = VMInterface + fields = ( + 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'contacts', 'created', + 'last_updated', + ) + default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') + + +class VirtualMachineVMInterfaceTable(VMInterfaceTable): + parent = tables.Column( + linkify=True + ) + bridge = tables.Column( + linkify=True + ) + actions = columns.ActionsColumn( + actions=('edit', 'delete'), + extra_buttons=VMINTERFACE_BUTTONS + ) + + class Meta(NetBoxTable.Meta): + model = VMInterface + fields = ( + 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + ) + default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') + row_attrs = { + 'data-name': lambda record: record.name, + } diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 4a9b67bf0..f6c07fa54 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -2,7 +2,7 @@ from django.urls import reverse from rest_framework import status from dcim.choices import InterfaceModeChoices -from ipam.models import VLAN +from ipam.models import VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -234,6 +234,13 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): ) VLAN.objects.bulk_create(vlans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + cls.create_data = [ { 'virtual_machine': virtualmachine.pk, @@ -241,6 +248,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[0].pk, }, { 'virtual_machine': virtualmachine.pk, @@ -249,6 +257,7 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'bridge': interfaces[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[1].pk, }, { 'virtual_machine': virtualmachine.pk, @@ -257,5 +266,6 @@ class VMInterfaceTest(APIViewTestCases.APIViewTestCase): 'parent': interfaces[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, + 'vrf': vrfs[2].pk, }, ] diff --git a/netbox/virtualization/tests/test_filtersets.py b/netbox/virtualization/tests/test_filtersets.py index 0dfc483f3..9e264ac5c 100644 --- a/netbox/virtualization/tests/test_filtersets.py +++ b/netbox/virtualization/tests/test_filtersets.py @@ -1,7 +1,7 @@ from django.test import TestCase from dcim.models import DeviceRole, Platform, Region, Site, SiteGroup -from ipam.models import IPAddress +from ipam.models import IPAddress, VRF from tenancy.models import Tenant, TenantGroup from utilities.testing import ChangeLoggedFilterSetTests from virtualization.choices import * @@ -414,6 +414,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Cluster.objects.bulk_create(clusters) + vrfs = ( + VRF(name='VRF 1', rd='65000:1'), + VRF(name='VRF 2', rd='65000:2'), + VRF(name='VRF 3', rd='65000:3'), + ) + VRF.objects.bulk_create(vrfs) + vms = ( VirtualMachine(name='Virtual Machine 1', cluster=clusters[0]), VirtualMachine(name='Virtual Machine 2', cluster=clusters[1]), @@ -422,9 +429,9 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): VirtualMachine.objects.bulk_create(vms) interfaces = ( - VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', description='foobar1'), - VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', description='foobar2'), - VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), + VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', vrf=vrfs[0], description='foobar1'), + VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', vrf=vrfs[1], description='foobar2'), + VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03', vrf=vrfs[2]), ) VMInterface.objects.bulk_create(interfaces) @@ -479,6 +486,13 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vrf(self): + vrfs = VRF.objects.all()[:2] + params = {'vrf_id': [vrfs[0].pk, vrfs[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'vrf': [vrfs[0].rd, vrfs[1].rd]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_description(self): params = {'description': ['foobar1', 'foobar2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 7dc5660fd..8edc14f00 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -4,7 +4,7 @@ from netaddr import EUI from dcim.choices import InterfaceModeChoices from dcim.models import DeviceRole, Platform, Site -from ipam.models import VLAN +from ipam.models import VLAN, VRF from utilities.testing import ViewTestCases, create_tags from virtualization.choices import * from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -263,6 +263,13 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) VLAN.objects.bulk_create(vlans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { @@ -276,6 +283,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -290,14 +298,15 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } cls.csv_data = ( - "virtual_machine,name", - "Virtual Machine 2,Interface 4", - "Virtual Machine 2,Interface 5", - "Virtual Machine 2,Interface 6", + f"virtual_machine,name,vrf.pk", + f"Virtual Machine 2,Interface 4,{vrfs[0].pk}", + f"Virtual Machine 2,Interface 5,{vrfs[0].pk}", + f"Virtual Machine 2,Interface 6,{vrfs[0].pk}", ) cls.bulk_edit_data = { diff --git a/netbox/virtualization/urls.py b/netbox/virtualization/urls.py index bfc5fe6c2..e01dbc059 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, ObjectJournalView +from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 8183555bd..850cb6388 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -11,7 +11,6 @@ from extras.views import ObjectConfigContextView from ipam.models import IPAddress, Service from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable from netbox.views import generic -from utilities.tables import paginate_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface @@ -41,7 +40,7 @@ class ClusterTypeView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('type',)) - paginate_table(clusters_table, request) + clusters_table.configure(request) return { 'clusters_table': clusters_table, @@ -50,7 +49,7 @@ class ClusterTypeView(generic.ObjectView): class ClusterTypeEditView(generic.ObjectEditView): queryset = ClusterType.objects.all() - model_form = forms.ClusterTypeForm + form = forms.ClusterTypeForm class ClusterTypeDeleteView(generic.ObjectDeleteView): @@ -103,7 +102,7 @@ class ClusterGroupView(generic.ObjectView): vm_count=count_related(VirtualMachine, 'cluster') ) clusters_table = tables.ClusterTable(clusters, exclude=('group',)) - paginate_table(clusters_table, request) + clusters_table.configure(request) return { 'clusters_table': clusters_table, @@ -112,7 +111,7 @@ class ClusterGroupView(generic.ObjectView): class ClusterGroupEditView(generic.ObjectEditView): queryset = ClusterGroup.objects.all() - model_form = forms.ClusterGroupForm + form = forms.ClusterGroupForm class ClusterGroupDeleteView(generic.ObjectDeleteView): @@ -196,7 +195,7 @@ class ClusterDevicesView(generic.ObjectChildrenView): class ClusterEditView(generic.ObjectEditView): queryset = Cluster.objects.all() - model_form = forms.ClusterForm + form = forms.ClusterForm class ClusterDeleteView(generic.ObjectDeleteView): @@ -266,7 +265,7 @@ class ClusterAddDevicesView(generic.ObjectEditView): class ClusterRemoveDevicesView(generic.ObjectEditView): queryset = Cluster.objects.all() form = forms.ClusterRemoveDevicesForm - template_name = 'generic/object_bulk_remove.html' + template_name = 'generic/bulk_remove.html' def post(self, request, pk): @@ -370,7 +369,7 @@ class VirtualMachineConfigContextView(ObjectConfigContextView): class VirtualMachineEditView(generic.ObjectEditView): queryset = VirtualMachine.objects.all() - model_form = forms.VirtualMachineForm + form = forms.VirtualMachineForm class VirtualMachineDeleteView(generic.ObjectDeleteView): @@ -405,7 +404,7 @@ class VMInterfaceListView(generic.ObjectListView): filterset = filtersets.VMInterfaceFilterSet filterset_form = forms.VMInterfaceFilterForm table = tables.VMInterfaceTable - action_buttons = ('import', 'export') + actions = ('import', 'export', 'bulk_edit', 'bulk_delete') class VMInterfaceView(generic.ObjectView): @@ -447,16 +446,16 @@ class VMInterfaceView(generic.ObjectView): } -# TODO: This should not use ComponentCreateView class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm + patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView): queryset = VMInterface.objects.all() - model_form = forms.VMInterfaceForm + form = forms.VMInterfaceForm template_name = 'virtualization/vminterface_edit.html' diff --git a/netbox/wireless/api/serializers.py b/netbox/wireless/api/serializers.py index f1fa6d58d..4a6abe94d 100644 --- a/netbox/wireless/api/serializers.py +++ b/netbox/wireless/api/serializers.py @@ -4,7 +4,7 @@ from dcim.choices import LinkStatusChoices from dcim.api.serializers import NestedInterfaceSerializer from ipam.api.serializers import NestedVLANSerializer from netbox.api import ChoiceField -from netbox.api.serializers import NestedGroupModelSerializer, PrimaryModelSerializer +from netbox.api.serializers import NestedGroupModelSerializer, NetBoxModelSerializer from wireless.choices import * from wireless.models import * from .nested_serializers import * @@ -29,7 +29,7 @@ class WirelessLANGroupSerializer(NestedGroupModelSerializer): ] -class WirelessLANSerializer(PrimaryModelSerializer): +class WirelessLANSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslan-detail') group = NestedWirelessLANGroupSerializer(required=False, allow_null=True) vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -44,7 +44,7 @@ class WirelessLANSerializer(PrimaryModelSerializer): ] -class WirelessLinkSerializer(PrimaryModelSerializer): +class WirelessLinkSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='wireless-api:wirelesslink-detail') status = ChoiceField(choices=LinkStatusChoices, required=False) interface_a = NestedInterfaceSerializer() diff --git a/netbox/wireless/api/urls.py b/netbox/wireless/api/urls.py index b02aa67c0..47799bd3a 100644 --- a/netbox/wireless/api/urls.py +++ b/netbox/wireless/api/urls.py @@ -1,8 +1,8 @@ -from netbox.api import OrderedDefaultRouter +from netbox.api import NetBoxRouter from . import views -router = OrderedDefaultRouter() +router = NetBoxRouter() router.APIRootView = views.WirelessRootView router.register('wireless-lan-groups', views.WirelessLANGroupViewSet) diff --git a/netbox/wireless/api/views.py b/netbox/wireless/api/views.py index 734f6940f..77a766c50 100644 --- a/netbox/wireless/api/views.py +++ b/netbox/wireless/api/views.py @@ -1,6 +1,6 @@ from rest_framework.routers import APIRootView -from extras.api.views import CustomFieldModelViewSet +from netbox.api.viewsets import NetBoxModelViewSet from wireless import filtersets from wireless.models import * from . import serializers @@ -14,7 +14,7 @@ class WirelessRootView(APIRootView): return 'Wireless' -class WirelessLANGroupViewSet(CustomFieldModelViewSet): +class WirelessLANGroupViewSet(NetBoxModelViewSet): queryset = WirelessLANGroup.objects.add_related_count( WirelessLANGroup.objects.all(), WirelessLAN, @@ -26,13 +26,13 @@ class WirelessLANGroupViewSet(CustomFieldModelViewSet): filterset_class = filtersets.WirelessLANGroupFilterSet -class WirelessLANViewSet(CustomFieldModelViewSet): +class WirelessLANViewSet(NetBoxModelViewSet): queryset = WirelessLAN.objects.prefetch_related('vlan', 'tags') serializer_class = serializers.WirelessLANSerializer filterset_class = filtersets.WirelessLANFilterSet -class WirelessLinkViewSet(CustomFieldModelViewSet): +class WirelessLinkViewSet(NetBoxModelViewSet): queryset = WirelessLink.objects.prefetch_related('interface_a', 'interface_b', 'tags') serializer_class = serializers.WirelessLinkSerializer filterset_class = filtersets.WirelessLinkFilterSet diff --git a/netbox/wireless/filtersets.py b/netbox/wireless/filtersets.py index b52260612..7b0be857b 100644 --- a/netbox/wireless/filtersets.py +++ b/netbox/wireless/filtersets.py @@ -2,9 +2,8 @@ import django_filters from django.db.models import Q from dcim.choices import LinkStatusChoices -from extras.filters import TagFilter from ipam.models import VLAN -from netbox.filtersets import OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import OrganizationalModelFilterSet, NetBoxModelFilterSet from utilities.filters import MultiValueNumberFilter, TreeNodeMultipleChoiceFilter from .choices import * from .models import * @@ -25,18 +24,13 @@ class WirelessLANGroupFilterSet(OrganizationalModelFilterSet): queryset=WirelessLANGroup.objects.all(), to_field_name='slug' ) - tag = TagFilter() class Meta: model = WirelessLANGroup fields = ['id', 'name', 'slug', 'description'] -class WirelessLANFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class WirelessLANFilterSet(NetBoxModelFilterSet): group_id = TreeNodeMultipleChoiceFilter( queryset=WirelessLANGroup.objects.all(), field_name='group', @@ -57,7 +51,6 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLAN @@ -73,11 +66,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class WirelessLinkFilterSet(PrimaryModelFilterSet): - q = django_filters.CharFilter( - method='search', - label='Search', - ) +class WirelessLinkFilterSet(NetBoxModelFilterSet): interface_a_id = MultiValueNumberFilter() interface_b_id = MultiValueNumberFilter() status = django_filters.MultipleChoiceFilter( @@ -89,7 +78,6 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet): auth_cipher = django_filters.MultipleChoiceFilter( choices=WirelessAuthCipherChoices ) - tag = TagFilter() class Meta: model = WirelessLink diff --git a/netbox/wireless/forms/bulk_edit.py b/netbox/wireless/forms/bulk_edit.py index 9d07d09f0..8a472e164 100644 --- a/netbox/wireless/forms/bulk_edit.py +++ b/netbox/wireless/forms/bulk_edit.py @@ -1,8 +1,8 @@ from django import forms from dcim.choices import LinkStatusChoices -from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm from ipam.models import VLAN +from netbox.forms import NetBoxModelBulkEditForm from utilities.forms import add_blank_choice, DynamicModelChoiceField from wireless.choices import * from wireless.constants import SSID_MAX_LENGTH @@ -15,11 +15,7 @@ __all__ = ( ) -class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLANGroup.objects.all(), - widget=forms.MultipleHiddenInput - ) +class WirelessLANGroupBulkEditForm(NetBoxModelBulkEditForm): parent = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -29,15 +25,14 @@ class WirelessLANGroupBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditFo required=False ) - class Meta: - nullable_fields = ['parent', 'description'] - - -class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLAN.objects.all(), - widget=forms.MultipleHiddenInput + model = WirelessLANGroup + fieldsets = ( + (None, ('parent', 'description')), ) + nullable_fields = ('parent', 'description') + + +class WirelessLANBulkEditForm(NetBoxModelBulkEditForm): group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -68,15 +63,17 @@ class WirelessLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Pre-shared key' ) - class Meta: - nullable_fields = ['ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] - - -class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=WirelessLink.objects.all(), - widget=forms.MultipleHiddenInput + model = WirelessLAN + fieldsets = ( + (None, ('group', 'vlan', 'ssid', 'description')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) + nullable_fields = ( + 'ssid', 'group', 'vlan', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) + + +class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm): ssid = forms.CharField( max_length=SSID_MAX_LENGTH, required=False, @@ -102,5 +99,11 @@ class WirelessLinkBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): label='Pre-shared key' ) - class Meta: - nullable_fields = ['ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk'] + model = WirelessLink + fieldsets = ( + (None, ('ssid', 'status', 'description')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')) + ) + nullable_fields = ( + 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', + ) diff --git a/netbox/wireless/forms/bulk_import.py b/netbox/wireless/forms/bulk_import.py index aa79e1fc7..4b8acb385 100644 --- a/netbox/wireless/forms/bulk_import.py +++ b/netbox/wireless/forms/bulk_import.py @@ -1,7 +1,7 @@ from dcim.choices import LinkStatusChoices from dcim.models import Interface -from extras.forms import CustomFieldModelCSVForm from ipam.models import VLAN +from netbox.forms import NetBoxModelCSVForm from utilities.forms import CSVChoiceField, CSVModelChoiceField, SlugField from wireless.choices import * from wireless.models import * @@ -13,7 +13,7 @@ __all__ = ( ) -class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): +class WirelessLANGroupCSVForm(NetBoxModelCSVForm): parent = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -27,7 +27,7 @@ class WirelessLANGroupCSVForm(CustomFieldModelCSVForm): fields = ('name', 'slug', 'parent', 'description') -class WirelessLANCSVForm(CustomFieldModelCSVForm): +class WirelessLANCSVForm(NetBoxModelCSVForm): group = CSVModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False, @@ -56,7 +56,7 @@ class WirelessLANCSVForm(CustomFieldModelCSVForm): fields = ('ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk') -class WirelessLinkCSVForm(CustomFieldModelCSVForm): +class WirelessLinkCSVForm(NetBoxModelCSVForm): status = CSVChoiceField( choices=LinkStatusChoices, help_text='Connection status' diff --git a/netbox/wireless/forms/filtersets.py b/netbox/wireless/forms/filtersets.py index 82df93e6c..8dcb48673 100644 --- a/netbox/wireless/forms/filtersets.py +++ b/netbox/wireless/forms/filtersets.py @@ -2,7 +2,7 @@ from django import forms from django.utils.translation import gettext as _ from dcim.choices import LinkStatusChoices -from extras.forms import CustomFieldModelFilterForm +from netbox.forms import NetBoxModelFilterSetForm from utilities.forms import add_blank_choice, DynamicModelMultipleChoiceField, StaticSelect, TagFilterField from wireless.choices import * from wireless.models import * @@ -14,7 +14,7 @@ __all__ = ( ) -class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): +class WirelessLANGroupFilterForm(NetBoxModelFilterSetForm): model = WirelessLANGroup parent_id = DynamicModelMultipleChoiceField( queryset=WirelessLANGroup.objects.all(), @@ -24,12 +24,13 @@ class WirelessLANGroupFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class WirelessLANFilterForm(CustomFieldModelFilterForm): +class WirelessLANFilterForm(NetBoxModelFilterSetForm): model = WirelessLAN - field_groups = [ - ('q', 'tag'), - ('group_id',), - ] + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('ssid', 'group_id',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), + ) ssid = forms.CharField( required=False, label='SSID' @@ -56,7 +57,7 @@ class WirelessLANFilterForm(CustomFieldModelFilterForm): tag = TagFilterField(model) -class WirelessLinkFilterForm(CustomFieldModelFilterForm): +class WirelessLinkFilterForm(NetBoxModelFilterSetForm): model = WirelessLink ssid = forms.CharField( required=False, diff --git a/netbox/wireless/forms/models.py b/netbox/wireless/forms/models.py index 7687cb372..64c9e96ef 100644 --- a/netbox/wireless/forms/models.py +++ b/netbox/wireless/forms/models.py @@ -1,7 +1,7 @@ from dcim.models import Device, Interface, Location, Site -from extras.forms import CustomFieldModelForm from extras.models import Tag from ipam.models import VLAN +from netbox.forms import NetBoxModelForm from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField, StaticSelect from wireless.models import * @@ -12,16 +12,12 @@ __all__ = ( ) -class WirelessLANGroupForm(CustomFieldModelForm): +class WirelessLANGroupForm(NetBoxModelForm): parent = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False ) slug = SlugField() - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False - ) class Meta: model = WirelessLANGroup @@ -30,7 +26,7 @@ class WirelessLANGroupForm(CustomFieldModelForm): ] -class WirelessLANForm(CustomFieldModelForm): +class WirelessLANForm(NetBoxModelForm): group = DynamicModelChoiceField( queryset=WirelessLANGroup.objects.all(), required=False @@ -40,9 +36,11 @@ class WirelessLANForm(CustomFieldModelForm): required=False, label='VLAN' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), + ('VLAN', ('vlan',)), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: @@ -50,18 +48,13 @@ class WirelessLANForm(CustomFieldModelForm): fields = [ 'ssid', 'group', 'description', 'vlan', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] - fieldsets = ( - ('Wireless LAN', ('ssid', 'group', 'description', 'tags')), - ('VLAN', ('vlan',)), - ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), - ) widgets = { 'auth_type': StaticSelect, 'auth_cipher': StaticSelect, } -class WirelessLinkForm(CustomFieldModelForm): +class WirelessLinkForm(NetBoxModelForm): site_a = DynamicModelChoiceField( queryset=Site.objects.all(), required=False, @@ -136,9 +129,12 @@ class WirelessLinkForm(CustomFieldModelForm): disabled_indicator='_occupied', label='Interface' ) - tags = DynamicModelMultipleChoiceField( - queryset=Tag.objects.all(), - required=False + + fieldsets = ( + ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), + ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), + ('Link', ('status', 'ssid', 'description', 'tags')), + ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), ) class Meta: @@ -147,12 +143,6 @@ class WirelessLinkForm(CustomFieldModelForm): 'site_a', 'location_a', 'device_a', 'interface_a', 'site_b', 'location_b', 'device_b', 'interface_b', 'status', 'ssid', 'description', 'auth_type', 'auth_cipher', 'auth_psk', 'tags', ] - fieldsets = ( - ('Side A', ('site_a', 'location_a', 'device_a', 'interface_a')), - ('Side B', ('site_b', 'location_b', 'device_b', 'interface_b')), - ('Link', ('status', 'ssid', 'description', 'tags')), - ('Authentication', ('auth_type', 'auth_cipher', 'auth_psk')), - ) widgets = { 'status': StaticSelect, 'auth_type': StaticSelect, diff --git a/netbox/wireless/graphql/types.py b/netbox/wireless/graphql/types.py index c3235e72e..2fc477dfa 100644 --- a/netbox/wireless/graphql/types.py +++ b/netbox/wireless/graphql/types.py @@ -1,5 +1,5 @@ from wireless import filtersets, models -from netbox.graphql.types import OrganizationalObjectType, PrimaryObjectType +from netbox.graphql.types import OrganizationalObjectType, NetBoxObjectType __all__ = ( 'WirelessLANType', @@ -16,7 +16,7 @@ class WirelessLANGroupType(OrganizationalObjectType): filterset_class = filtersets.WirelessLANGroupFilterSet -class WirelessLANType(PrimaryObjectType): +class WirelessLANType(NetBoxObjectType): class Meta: model = models.WirelessLAN @@ -30,7 +30,7 @@ class WirelessLANType(PrimaryObjectType): return self.auth_cipher or None -class WirelessLinkType(PrimaryObjectType): +class WirelessLinkType(NetBoxObjectType): class Meta: model = models.WirelessLink diff --git a/netbox/wireless/migrations/0002_standardize_id_fields.py b/netbox/wireless/migrations/0002_standardize_id_fields.py new file mode 100644 index 000000000..9e0b202c2 --- /dev/null +++ b/netbox/wireless/migrations/0002_standardize_id_fields.py @@ -0,0 +1,26 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0001_wireless'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='wirelesslink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + ] diff --git a/netbox/wireless/migrations/0003_created_datetimefield.py b/netbox/wireless/migrations/0003_created_datetimefield.py new file mode 100644 index 000000000..fe251248c --- /dev/null +++ b/netbox/wireless/migrations/0003_created_datetimefield.py @@ -0,0 +1,28 @@ +# Generated by Django 4.0.2 on 2022-02-08 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('wireless', '0002_standardize_id_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslan', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='wirelesslangroup', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + migrations.AlterField( + model_name='wirelesslink', + name='created', + field=models.DateTimeField(auto_now_add=True, null=True), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 151828c88..0543e5621 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -5,8 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import LinkStatusChoices from dcim.constants import WIRELESS_IFACE_TYPES -from extras.utils import extras_features -from netbox.models import BigIDModel, NestedGroupModel, PrimaryModel +from netbox.models import NestedGroupModel, NetBoxModel from .choices import * from .constants import * @@ -41,7 +40,6 @@ class WirelessAuthenticationBase(models.Model): abstract = True -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class WirelessLANGroup(NestedGroupModel): """ A nested grouping of WirelessLANs @@ -81,8 +79,7 @@ class WirelessLANGroup(NestedGroupModel): return reverse('wireless:wirelesslangroup', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): +class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): """ A wireless network formed among an arbitrary number of access point and clients. """ @@ -120,8 +117,7 @@ class WirelessLAN(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslan', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class WirelessLink(WirelessAuthenticationBase, PrimaryModel): +class WirelessLink(WirelessAuthenticationBase, NetBoxModel): """ A point-to-point connection between two wireless Interfaces. """ @@ -181,8 +177,8 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): def get_absolute_url(self): return reverse('wireless:wirelesslink', args=[self.pk]) - def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + def get_status_color(self): + return LinkStatusChoices.colors.get(self.status) def clean(self): diff --git a/netbox/wireless/tables.py b/netbox/wireless/tables.py deleted file mode 100644 index b3516c765..000000000 --- a/netbox/wireless/tables.py +++ /dev/null @@ -1,112 +0,0 @@ -import django_tables2 as tables - -from dcim.models import Interface -from utilities.tables import ( - BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MPTTColumn, TagColumn, ToggleColumn, -) -from .models import * - -__all__ = ( - 'WirelessLANTable', - 'WirelessLANGroupTable', - 'WirelessLinkTable', -) - - -class WirelessLANGroupTable(BaseTable): - pk = ToggleColumn() - name = MPTTColumn( - linkify=True - ) - wirelesslan_count = LinkedCountColumn( - viewname='wireless:wirelesslan_list', - url_params={'group_id': 'pk'}, - verbose_name='Wireless LANs' - ) - tags = TagColumn( - url_name='wireless:wirelesslangroup_list' - ) - actions = ButtonsColumn(WirelessLANGroup) - - class Meta(BaseTable.Meta): - model = WirelessLANGroup - fields = ( - 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', - ) - default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions') - - -class WirelessLANTable(BaseTable): - pk = ToggleColumn() - ssid = tables.Column( - linkify=True - ) - group = tables.Column( - linkify=True - ) - interface_count = tables.Column( - verbose_name='Interfaces' - ) - tags = TagColumn( - url_name='wireless:wirelesslan_list' - ) - - class Meta(BaseTable.Meta): - model = WirelessLAN - fields = ( - 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', - 'tags', 'created', 'last_updated', - ) - default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') - - -class WirelessLANInterfacesTable(BaseTable): - pk = ToggleColumn() - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True - ) - - class Meta(BaseTable.Meta): - model = Interface - fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') - default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') - - -class WirelessLinkTable(BaseTable): - pk = ToggleColumn() - id = tables.Column( - linkify=True, - verbose_name='ID' - ) - status = ChoiceFieldColumn() - device_a = tables.Column( - accessor=tables.A('interface_a__device'), - linkify=True - ) - interface_a = tables.Column( - linkify=True - ) - device_b = tables.Column( - accessor=tables.A('interface_b__device'), - linkify=True - ) - interface_b = tables.Column( - linkify=True - ) - tags = TagColumn( - url_name='wireless:wirelesslink_list' - ) - - class Meta(BaseTable.Meta): - model = WirelessLink - fields = ( - 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', - 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', - ) - default_columns = ( - 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', - 'description', - ) diff --git a/netbox/wireless/tables/__init__.py b/netbox/wireless/tables/__init__.py new file mode 100644 index 000000000..d01a5baf8 --- /dev/null +++ b/netbox/wireless/tables/__init__.py @@ -0,0 +1,2 @@ +from .wirelesslan import * +from .wirelesslink import * diff --git a/netbox/wireless/tables/wirelesslan.py b/netbox/wireless/tables/wirelesslan.py new file mode 100644 index 000000000..9955d4ac4 --- /dev/null +++ b/netbox/wireless/tables/wirelesslan.py @@ -0,0 +1,69 @@ +import django_tables2 as tables + +from dcim.models import Interface +from netbox.tables import NetBoxTable, columns +from wireless.models import * + +__all__ = ( + 'WirelessLANGroupTable', + 'WirelessLANInterfacesTable', + 'WirelessLANTable', +) + + +class WirelessLANGroupTable(NetBoxTable): + name = columns.MPTTColumn( + linkify=True + ) + wirelesslan_count = columns.LinkedCountColumn( + viewname='wireless:wirelesslan_list', + url_params={'group_id': 'pk'}, + verbose_name='Wireless LANs' + ) + tags = columns.TagColumn( + url_name='wireless:wirelesslangroup_list' + ) + + class Meta(NetBoxTable.Meta): + model = WirelessLANGroup + fields = ( + 'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'wirelesslan_count', 'description') + + +class WirelessLANTable(NetBoxTable): + ssid = tables.Column( + linkify=True + ) + group = tables.Column( + linkify=True + ) + interface_count = tables.Column( + verbose_name='Interfaces' + ) + tags = columns.TagColumn( + url_name='wireless:wirelesslan_list' + ) + + class Meta(NetBoxTable.Meta): + model = WirelessLAN + fields = ( + 'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', + 'tags', 'created', 'last_updated', + ) + default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count') + + +class WirelessLANInterfacesTable(NetBoxTable): + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True + ) + + class Meta(NetBoxTable.Meta): + model = Interface + fields = ('pk', 'device', 'name', 'rf_role', 'rf_channel') + default_columns = ('pk', 'device', 'name', 'rf_role', 'rf_channel') diff --git a/netbox/wireless/tables/wirelesslink.py b/netbox/wireless/tables/wirelesslink.py new file mode 100644 index 000000000..72037c4d9 --- /dev/null +++ b/netbox/wireless/tables/wirelesslink.py @@ -0,0 +1,44 @@ +import django_tables2 as tables + +from netbox.tables import NetBoxTable, columns +from wireless.models import * + +__all__ = ( + 'WirelessLinkTable', +) + + +class WirelessLinkTable(NetBoxTable): + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + status = columns.ChoiceFieldColumn() + device_a = tables.Column( + accessor=tables.A('interface_a__device'), + linkify=True + ) + interface_a = tables.Column( + linkify=True + ) + device_b = tables.Column( + accessor=tables.A('interface_b__device'), + linkify=True + ) + interface_b = tables.Column( + linkify=True + ) + tags = columns.TagColumn( + url_name='wireless:wirelesslink_list' + ) + + class Meta(NetBoxTable.Meta): + model = WirelessLink + fields = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', + 'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated', + ) + default_columns = ( + 'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', + 'description', + ) diff --git a/netbox/wireless/urls.py b/netbox/wireless/urls.py index 684f55ad5..cef96fd5e 100644 --- a/netbox/wireless/urls.py +++ b/netbox/wireless/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from extras.views import ObjectChangeLogView, ObjectJournalView +from netbox.views.generic import ObjectChangeLogView, ObjectJournalView from . import views from .models import * diff --git a/netbox/wireless/views.py b/netbox/wireless/views.py index dd1e760bb..eee7fe1ed 100644 --- a/netbox/wireless/views.py +++ b/netbox/wireless/views.py @@ -1,6 +1,5 @@ from dcim.models import Interface from netbox.views import generic -from utilities.tables import paginate_table from utilities.utils import count_related from . import filtersets, forms, tables from .models import * @@ -31,7 +30,7 @@ class WirelessLANGroupView(generic.ObjectView): group=instance ) wirelesslans_table = tables.WirelessLANTable(wirelesslans, exclude=('group',)) - paginate_table(wirelesslans_table, request) + wirelesslans_table.configure(request) return { 'wirelesslans_table': wirelesslans_table, @@ -40,7 +39,7 @@ class WirelessLANGroupView(generic.ObjectView): class WirelessLANGroupEditView(generic.ObjectEditView): queryset = WirelessLANGroup.objects.all() - model_form = forms.WirelessLANGroupForm + form = forms.WirelessLANGroupForm class WirelessLANGroupDeleteView(generic.ObjectDeleteView): @@ -99,7 +98,7 @@ class WirelessLANView(generic.ObjectView): wireless_lans=instance ) interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces) - paginate_table(interfaces_table, request) + interfaces_table.configure(request) return { 'interfaces_table': interfaces_table, @@ -108,7 +107,7 @@ class WirelessLANView(generic.ObjectView): class WirelessLANEditView(generic.ObjectEditView): queryset = WirelessLAN.objects.all() - model_form = forms.WirelessLANForm + form = forms.WirelessLANForm class WirelessLANDeleteView(generic.ObjectDeleteView): @@ -151,7 +150,7 @@ class WirelessLinkView(generic.ObjectView): class WirelessLinkEditView(generic.ObjectEditView): queryset = WirelessLink.objects.all() - model_form = forms.WirelessLinkForm + form = forms.WirelessLinkForm class WirelessLinkDeleteView(generic.ObjectDeleteView): diff --git a/requirements.txt b/requirements.txt index 801902b3c..2fcf3da64 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django==3.2.12 +Django==4.0.3 django-cors-headers==3.11.0 django-debug-toolbar==3.2.4 django-filter==21.1 @@ -11,14 +11,15 @@ django-rq==2.5.1 django-tables2==2.4.1 django-taggit==2.1.0 django-timezone-field==5.0 -djangorestframework==3.12.4 +djangorestframework==3.13.1 drf-yasg[validation]==1.20.0 -graphene_django==2.15.0 +graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.0.3 Markdown==3.3.6 markdown-include==0.6.0 mkdocs-material==8.2.8 +mkdocstrings==0.17.0 netaddr==0.8.0 Pillow==9.0.1 psycopg2-binary==2.9.3
      Status - {{ object.get_status_display }} - {% badge object.get_status_display bg_color=object.get_status_color %}
      SSID