diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 17533e2c9..16182af64 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.4 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 1c31f0c29..0be999b16 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.1.4 + placeholder: v3.1.6 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index cbc893aa9..7ceb344b0 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -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/drkjam/netaddr netaddr @@ -98,10 +102,6 @@ psycopg2-binary # https://github.com/yaml/pyyaml PyYAML -# In-memory key/value store used for caching and queuing -# https://github.com/andymccurdy/redis-py -redis - # Social authentication framework # https://github.com/python-social-auth/social-core social-auth-core[all] 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/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 acf13b82f..742e93804 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational. !!! info "IDE Integration" Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment. +## Populating Demo Data + +Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at .) + +The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data. + ## 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. diff --git a/docs/development/user-preferences.md b/docs/development/user-preferences.md index a707eb6ad..ceb5321a9 100644 --- a/docs/development/user-preferences.md +++ b/docs/development/user-preferences.md @@ -4,9 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f ## Available Preferences -| 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 | -| tables.${table}.columns | The ordered list of columns to display when viewing the table | -| ui.colormode | Light or dark mode in the user interface | +| 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 02e523825..81c899387 100644 --- a/docs/index.md +++ b/docs/index.md @@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | Application | Django/Python | | Database | PostgreSQL 10+ | | Task queuing | Redis/django-rq | -| Live device access | NAPALM | +| Live device access | NAPALM (optional) | ## Supported Python Versions @@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments. ## Getting Started -See the [installation guide](installation/index.md) for help getting NetBox up and running quickly. +Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible. + +Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox. 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/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/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 7fd510841..96ff0bbf7 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. diff --git a/docs/models/extras/webhook.md b/docs/models/extras/webhook.md index c71657336..e256a66f4 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/plugins/development.md b/docs/plugins/development.md deleted file mode 100644 index d20f73cb6..000000000 --- a/docs/plugins/development.md +++ /dev/null @@ -1,430 +0,0 @@ -# Plugin Development - -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.8/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`) | -| `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 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.8/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..7c7e2936b --- /dev/null +++ b/docs/plugins/development/background-tasks.md @@ -0,0 +1,27 @@ +# 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/filtersets.md b/docs/plugins/development/filtersets.md new file mode 100644 index 000000000..e2a98ed0b --- /dev/null +++ b/docs/plugins/development/filtersets.md @@ -0,0 +1,56 @@ +# 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 the subclass of a generic view, such as `ObjectListView` or `BulkEditView`, set it as 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 on a REST API endpoint, set it as 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 +``` diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md new file mode 100644 index 000000000..07a04f39f --- /dev/null +++ b/docs/plugins/development/index.md @@ -0,0 +1,146 @@ +# Plugins 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.8/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`) | +| `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 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.8/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 +``` diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md new file mode 100644 index 000000000..521420b1b --- /dev/null +++ b/docs/plugins/development/models.md @@ -0,0 +1,111 @@ +# 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. 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 +``` + +### Migrations + +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/). + +## 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 numerous feature, 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, or attributes necessary to the operation of these features +2. Register the model with NetBox as utilizing these feature + +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. (You will also need to inherit from Django's built-in `Model` class.) + +```python +# models.py +from django.db import models +from netbox.models.features import ExportTemplatesMixin, TagsMixin + +class MyModel(ExportTemplatesMixin, TagsMixin, models.Model): + foo = models.CharField() + ... +``` + +The example above will enable export templates and tags, but no other NetBox features. A complete list of available feature mixins is included below. (Inheriting all the available mixins is essentially the same as subclassing `BaseModel`.) + +## Feature Mixins Reference + +!!! note + 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 diff --git a/docs/plugins/development/rest-api.md b/docs/plugins/development/rest-api.md new file mode 100644 index 000000000..efe7b1127 --- /dev/null +++ b/docs/plugins/development/rest-api.md @@ -0,0 +1,46 @@ +# 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. 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. diff --git a/docs/plugins/development/tables.md b/docs/plugins/development/tables.md new file mode 100644 index 000000000..16f9f6c17 --- /dev/null +++ b/docs/plugins/development/tables.md @@ -0,0 +1,37 @@ +# 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 +* `id` - The object's numeric database ID, as a hyperlink to the object's view +* `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', ...) +``` diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md new file mode 100644 index 000000000..9c44e18ed --- /dev/null +++ b/docs/plugins/development/views.md @@ -0,0 +1,254 @@ +# 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. + +## 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' + ... +``` + +## 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. + +## Templates + +### Plugin Views + +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. + +### 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] +``` + +## 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. + +## Object Views Reference + +Below is the class definition for NetBox's BaseObjectView. The attributes and methods defined here are available on all generic views which handle a single object. + +::: netbox.views.generic.base.BaseObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectView + selection: + members: + - get_object + - get_template_name + rendering: + show_source: false + +::: netbox.views.generic.ObjectEditView + selection: + members: + - get_object + - alter_object + rendering: + show_source: false + +::: netbox.views.generic.ObjectDeleteView + selection: + members: + - get_object + rendering: + show_source: false + +## Multi-Object Views Reference + +Below is the class definition for NetBox's BaseMultiObjectView. The attributes and methods defined here are available on all generic views which deal with multiple objects. + +::: netbox.views.generic.base.BaseMultiObjectView + rendering: + show_source: false + +::: netbox.views.generic.ObjectListView + selection: + members: + - get_table + - export_table + - export_template + rendering: + show_source: false + +::: netbox.views.generic.BulkImportView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkEditView + selection: + members: false + rendering: + show_source: false + +::: netbox.views.generic.BulkDeleteView + selection: + members: + - get_form + rendering: + show_source: false diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 7caa1e3ab..13c129398 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -1,6 +1,14 @@ # Release Notes -Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page. +NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows: + +* **Major** - Introduces or removes an entire API or other core functionality +* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations +* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements + +Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks. + +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.1](./version-3.1.md) (December 2021) diff --git a/docs/release-notes/version-3.1.md b/docs/release-notes/version-3.1.md index 29213a4c5..c42837b24 100644 --- a/docs/release-notes/version-3.1.md +++ b/docs/release-notes/version-3.1.md @@ -1,6 +1,52 @@ # NetBox v3.1 -## v3.1.5 (FUTURE) +## v3.1.7 (FUTURE) + +--- + +## v3.1.6 (2022-01-17) + +### Enhancements + +* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table +* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats +* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types +* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation +* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables +* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times + +### Bug Fixes + +* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations +* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer +* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form +* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views +* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI +* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login +* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously +* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values +* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter +* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers +* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form +* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms + +--- + +## v3.1.5 (2022-01-06) + +### Enhancements + +* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion +* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form +* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table + +### Bug Fixes + +* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view +* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other" +* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay +* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection +* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links --- diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index d2702cfda..1b4a7ef87 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -14,13 +14,15 @@ ### New Features -#### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658)) +#### Plugins Framework Extensions ([#8333](https://github.com/netbox-community/netbox/issues/8333)) -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 to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. +NetBox's plugins framework has been extended considerably in this release. Changes include: -#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) +* Seven generic view classes are now officially supported for use by plugins. +* `NetBoxModel` is available for subclassing to enable various NetBox features, such as custom fields and change logging. +* `NetBoxModelFilterSet` is available to extend NetBox's dynamic filtering ability to plugin models. -A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional. +No breaking changes to previously supported components have been introduced in this release. However, plugin authors are encouraged to audit their 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)) @@ -28,6 +30,12 @@ Several new models have been added to support field-replaceable device modules, 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. +#### Custom Object Fields ([#7006](https://github.com/netbox-community/netbox/issues/7006)) + +Two new types of custom field have been added: object and multi-object. These can be used to associate objects with other objects in NetBox. For example, you might create a custom field named `primary_site` on the tenant model so that a particular site can be associated with each tenant as its primary. The multi-object custom field type allows for the assignment of one or more objects of the same type. + +Custom field object assignment is fully supported in the REST API, and functions similarly to normal foreign key relations. Nested representations are provided 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 status fields in NetBox. This is done by defining the `FIELD_CHOICES` configuration parameter to map field identifiers to an iterable of custom choices. These choices are populated automatically when NetBox initializes. For example, the following will add three custom choices for the site status field: @@ -42,20 +50,40 @@ FIELD_CHOICES = { } ``` +#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087)) + +A new model has been introduced to represent function 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 the other component types. This enables users to better pre-model fixed hardware components. Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. +#### 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 application of layer four services to 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 to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically. + ### Enhancements +* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators +* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation +* [#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 * [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form * [#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 assigning interfaces to VRFs +* [#7853](https://github.com/netbox-community/netbox/issues/7853) - Add `speed` and `duplex` fields to 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) - Webhook URLs can now be templatized +* [#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 ### Other Changes @@ -63,6 +91,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * [#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 ### REST API Changes @@ -73,6 +102,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` * `/api/dcim/module-types/` + * `/api/extras/service-templates/` * circuits.ProviderNetwork * Added `service_id` field * dcim.ConsolePort @@ -82,7 +112,7 @@ Inventory item templates can be arranged hierarchically within a device type, an * dcim.FrontPort * Added `module` field * dcim.Interface - * Added `module` field + * Added `module`, `speed`, `duplex`, and `vrf` fields * dcim.InventoryItem * Added `component_type`, `component_id`, and `role` fields * Added read-only `component` field @@ -96,6 +126,10 @@ Inventory item templates can be arranged hierarchically within a device type, an * Removed the `asn`, `contact_name`, `contact_phone`, and `contact_email` fields * extras.ConfigContext * Add `cluster_types` field +* extras.CustomField + * Added `object_type` field +* extras.CustomLink + * Added `enabled` field * ipam.VLANGroup * Added the `/availables-vlans/` endpoint * Added the `min_vid` and `max_vid` fields 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 f89bdaea7..3b1e52f50 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -16,6 +16,22 @@ 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 extra: social: - icon: fontawesome/brands/github @@ -84,7 +100,14 @@ 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' + - Tables: 'plugins/development/tables.md' + - Filter Sets: 'plugins/development/filtersets.md' + - REST API: 'plugins/development/rest-api.md' + - Background Tasks: 'plugins/development/background-tasks.md' - Administration: - Authentication: 'administration/authentication.md' - Permissions: 'administration/permissions.md' diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 7a827d547..90767d081 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri fields = [ 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', - '_occupied', + '_occupied', 'created', 'last_updated', ] diff --git a/netbox/circuits/filtersets.py b/netbox/circuits/filtersets.py index 0a90116bd..40ac61e77 100644 --- a/netbox/circuits/filtersets.py +++ b/netbox/circuits/filtersets.py @@ -3,8 +3,7 @@ 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 netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import TreeNodeMultipleChoiceFilter from .choices import * @@ -19,7 +18,7 @@ __all__ = ( ) -class ProviderFilterSet(PrimaryModelFilterSet): +class ProviderFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -61,7 +60,6 @@ class ProviderFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Provider @@ -79,7 +77,7 @@ class ProviderFilterSet(PrimaryModelFilterSet): ) -class ProviderNetworkFilterSet(PrimaryModelFilterSet): +class ProviderNetworkFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -94,7 +92,6 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Provider (slug)', ) - tag = TagFilter() class Meta: model = ProviderNetwork @@ -112,14 +109,13 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet): class CircuitTypeFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = CircuitType fields = ['id', 'name', 'slug'] -class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -190,7 +186,6 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='Site (slug)', ) - tag = TagFilter() class Meta: model = Circuit 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/models/circuits.py b/netbox/circuits/models/circuits.py index 013aef557..0f3de91ed 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 @@ -138,8 +136,7 @@ class Circuit(PrimaryModel): return CircuitStatusChoices.colors.get(self.status, 'secondary') -@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 153e241a7..9cf4bf5c1 100644 --- a/netbox/circuits/models/providers.py +++ b/netbox/circuits/models/providers.py @@ -3,8 +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 netbox.models import NetBoxModel __all__ = ( 'ProviderNetwork', @@ -12,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. @@ -72,8 +70,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. diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 32c40f269..56da24842 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -1,11 +1,10 @@ import django_tables2 as tables from django_tables2.utils import Accessor +from netbox.tables import NetBoxTable, columns from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from .models import * - __all__ = ( 'CircuitTable', 'CircuitTypeTable', @@ -23,12 +22,32 @@ CIRCUITTERMINATION_LINK = """ """ +# +# 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() +class ProviderTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -36,16 +55,16 @@ class ProviderTable(BaseTable): accessor=Accessor('count_circuits'), verbose_name='Circuits' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:provider_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Provider fields = ( 'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count') @@ -54,22 +73,23 @@ class ProviderTable(BaseTable): # Provider networks # -class ProviderNetworkTable(BaseTable): - pk = ToggleColumn() +class ProviderNetworkTable(NetBoxTable): name = tables.Column( linkify=True ) provider = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:providernetwork_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ProviderNetwork - fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags') + fields = ( + 'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags', + ) default_columns = ('pk', 'name', 'provider', 'service_id', 'description') @@ -77,31 +97,30 @@ class ProviderNetworkTable(BaseTable): # Circuit types # -class CircuitTypeTable(BaseTable): - pk = ToggleColumn() +class CircuitTypeTable(NetBoxTable): name = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='circuits:circuittype_list' ) circuit_count = tables.Column( verbose_name='Circuits' ) - actions = ButtonsColumn(CircuitType) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CircuitType - fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') - default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') + fields = ( + 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug') # # Circuits # -class CircuitTable(BaseTable): - pk = ToggleColumn() +class CircuitTable(NetBoxTable): cid = tables.Column( linkify=True, verbose_name='Circuit ID' @@ -109,7 +128,7 @@ class CircuitTable(BaseTable): provider = tables.Column( linkify=True ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() termination_a = tables.TemplateColumn( template_code=CIRCUITTERMINATION_LINK, @@ -119,16 +138,17 @@ class CircuitTable(BaseTable): template_code=CIRCUITTERMINATION_LINK, verbose_name='Side Z' ) - comments = MarkdownColumn() - tags = TagColumn( + commit_rate = CommitRateColumn() + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='circuits:circuit_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Circuit fields = ( 'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', - 'commit_rate', 'description', 'comments', 'tags', + 'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 2f1addab1..3229977be 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -5,10 +5,9 @@ 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 netbox.tables import configure_table from utilities.utils import count_related from . import filtersets, forms, tables -from .choices import CircuitTerminationSideChoices from .models import * @@ -35,7 +34,7 @@ class ProviderView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -96,7 +95,7 @@ class ProviderNetworkView(generic.ObjectView): 'type', 'tenant', 'terminations__site' ) circuits_table = tables.CircuitTable(circuits) - paginate_table(circuits_table, request) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, @@ -150,7 +149,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) + configure_table(circuits_table, request) return { 'circuits_table': circuits_table, diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 3bc369a64..36943061c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -6,7 +6,9 @@ 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 ( @@ -219,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer): class Meta: model = RackReservation fields = [ - 'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags', + 'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags', 'custom_fields', ] @@ -719,6 +721,7 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con bridge = NestedInterfaceSerializer(required=False, allow_null=True) lag = NestedInterfaceSerializer(required=False, allow_null=True) mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False) + duplex = ChoiceField(choices=InterfaceDuplexChoices, allow_blank=True, required=False) rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True) rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False) untagged_vlan = NestedVLANSerializer(required=False, allow_null=True) @@ -728,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( @@ -743,9 +747,9 @@ class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, Con model = Interface fields = [ 'id', 'url', 'display', 'device', 'module', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', - 'mtu', 'mac_address', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', + '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', 'connected_endpoint', + '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', ] @@ -910,7 +914,7 @@ class CableSerializer(PrimaryModelSerializer): fields = [ 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', - 'tags', 'custom_fields', + 'tags', 'custom_fields', 'created', 'last_updated', ] def _get_termination(self, obj, side): @@ -1004,7 +1008,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer): class Meta: model = VirtualChassis - fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count'] + fields = [ + 'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count', + 'created', 'last_updated', + ] # @@ -1023,7 +1030,10 @@ class PowerPanelSerializer(PrimaryModelSerializer): class Meta: model = PowerPanel - fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count'] + fields = [ + 'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count', + 'created', 'last_updated', + ] class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer): diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 31c1fd1d0..edba03b60 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -583,7 +583,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet): class InterfaceViewSet(PathEndpointMixin, ModelViewSet): queryset = Interface.objects.prefetch_related( 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path__destination', 'cable', '_link_peer', - 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 368ee1336..2706c684d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_FLEXSTACK = 'cisco-flexstack' TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' + TYPE_STACKWISE80 = 'cisco-stackwise-80' + TYPE_STACKWISE160 = 'cisco-stackwise-160' + TYPE_STACKWISE320 = 'cisco-stackwise-320' + TYPE_STACKWISE480 = 'cisco-stackwise-480' TYPE_JUNIPER_VCP = 'juniper-vcp' TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' @@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_FLEXSTACK, 'Cisco FlexStack'), (TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), + (TYPE_STACKWISE80, 'Cisco StackWise-80'), + (TYPE_STACKWISE160, 'Cisco StackWise-160'), + (TYPE_STACKWISE320, 'Cisco StackWise-320'), + (TYPE_STACKWISE480, 'Cisco StackWise-480'), (TYPE_JUNIPER_VCP, 'Juniper VCP'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), @@ -943,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' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 9069ab25c..dda6de5b1 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 from tenancy.models import Tenant @@ -79,7 +78,6 @@ class RegionFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent region (slug)', ) - tag = TagFilter() class Meta: model = Region @@ -97,14 +95,13 @@ class SiteGroupFilterSet(OrganizationalModelFilterSet): to_field_name='slug', label='Parent site group (slug)', ) - tag = TagFilter() class Meta: model = SiteGroup fields = ['id', 'name', 'slug', 'description'] -class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class SiteFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -148,7 +145,6 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=ASN.objects.all(), label='AS (ID)', ) - tag = TagFilter() class Meta: model = Site @@ -225,7 +221,6 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): to_field_name='slug', label='Location (slug)', ) - tag = TagFilter() class Meta: model = Location @@ -241,14 +236,13 @@ class LocationFilterSet(TenancyFilterSet, OrganizationalModelFilterSet): class RackRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RackRole fields = ['id', 'name', 'slug', 'color'] -class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -325,7 +319,6 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): serial = django_filters.CharFilter( lookup_expr='iexact' ) - tag = TagFilter() class Meta: model = Rack @@ -346,7 +339,7 @@ class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -389,7 +382,6 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='username', label='User (name)', ) - tag = TagFilter() class Meta: model = RackReservation @@ -407,14 +399,13 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class ManufacturerFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = Manufacturer fields = ['id', 'name', 'slug', 'description'] -class DeviceTypeFilterSet(PrimaryModelFilterSet): +class DeviceTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -461,7 +452,6 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = DeviceType @@ -507,7 +497,7 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet): return queryset.exclude(devicebaytemplates__isnull=value) -class ModuleTypeFilterSet(PrimaryModelFilterSet): +class ModuleTypeFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -546,7 +536,6 @@ class ModuleTypeFilterSet(PrimaryModelFilterSet): method='_pass_through_ports', label='Has pass-through ports', ) - tag = TagFilter() class Meta: model = ModuleType @@ -732,7 +721,6 @@ class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompo class DeviceRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = DeviceRole @@ -751,14 +739,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, LocalConfigContextFilterSet): +class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, LocalConfigContextFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -916,7 +903,6 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex method='_device_bays', label='Has device bays', ) - tag = TagFilter() class Meta: model = Device @@ -970,7 +956,7 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex return queryset.exclude(devicebays__isnull=value) -class ModuleFilterSet(PrimaryModelFilterSet): +class ModuleFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -990,7 +976,6 @@ class ModuleFilterSet(PrimaryModelFilterSet): queryset=Device.objects.all(), label='Device (ID)', ) - tag = TagFilter() class Meta: model = Module @@ -1080,7 +1065,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(): @@ -1112,7 +1096,7 @@ 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, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1123,7 +1107,7 @@ class ConsolePortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'description'] -class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class ConsoleServerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=ConsolePortTypeChoices, null_value=None @@ -1134,7 +1118,7 @@ class ConsoleServerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet fields = ['id', 'name', 'label', 'description'] -class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerPortTypeChoices, null_value=None @@ -1145,7 +1129,7 @@ class PowerPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'maximum_draw', 'allocated_draw', 'description'] -class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerOutletFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): type = django_filters.MultipleChoiceFilter( choices=PowerOutletTypeChoices, null_value=None @@ -1160,7 +1144,7 @@ class PowerOutletFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, Cabl fields = ['id', 'name', 'label', 'feed_leg', 'description'] -class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class InterfaceFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1196,9 +1180,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' @@ -1217,6 +1204,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 @@ -1273,7 +1271,7 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT }.get(value, queryset.none()) -class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class FrontPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1284,7 +1282,7 @@ class FrontPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT fields = ['id', 'name', 'label', 'type', 'color', 'description'] -class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): +class RearPortFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet, CableTerminationFilterSet): type = django_filters.MultipleChoiceFilter( choices=PortTypeChoices, null_value=None @@ -1295,21 +1293,21 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description'] -class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class ModuleBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = ModuleBay fields = ['id', 'name', 'label', 'description'] -class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class DeviceBayFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): class Meta: model = DeviceBay fields = ['id', 'name', 'label', 'description'] -class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): +class InventoryItemFilterSet(NetBoxModelFilterSet, DeviceComponentFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1362,14 +1360,13 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): class InventoryItemRoleFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = InventoryItemRole fields = ['id', 'name', 'slug', 'color'] -class VirtualChassisFilterSet(PrimaryModelFilterSet): +class VirtualChassisFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1432,7 +1429,6 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): to_field_name='slug', label='Tenant (slug)', ) - tag = TagFilter() class Meta: model = VirtualChassis @@ -1449,7 +1445,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter).distinct() -class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1490,7 +1486,6 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): method='filter_device', field_name='device__site__slug' ) - tag = TagFilter() class Meta: model = Cable @@ -1509,7 +1504,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset -class PowerPanelFilterSet(PrimaryModelFilterSet): +class PowerPanelFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1556,7 +1551,6 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): lookup_expr='in', label='Location (ID)', ) - tag = TagFilter() class Meta: model = PowerPanel @@ -1571,7 +1565,7 @@ class PowerPanelFilterSet(PrimaryModelFilterSet): return queryset.filter(qs_filter) -class PowerFeedFilterSet(PrimaryModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): +class PowerFeedFilterSet(NetBoxModelFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -1626,7 +1620,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 02c8feb4b..4d73fcc2a 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -72,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', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 3cd8ec35e..3d73ada47 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -7,11 +7,11 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm -from ipam.models import VLAN, ASN +from ipam.models import ASN, VLAN, VRF 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__ = ( @@ -1028,7 +1028,7 @@ class PowerOutletBulkEditForm( class InterfaceBulkEditForm( form_from_model(Interface, [ - 'label', 'type', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', + '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, @@ -1061,7 +1061,13 @@ class InterfaceBulkEditForm( required=False, query_params={ 'type': 'lag', - } + }, + label='LAG' + ) + speed = forms.IntegerField( + required=False, + widget=SelectSpeedWidget(attrs={'readonly': None}), + label='Speed' ) mgmt_only = forms.NullBooleanField( required=False, @@ -1080,11 +1086,16 @@ 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', + '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): diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 1297fc980..1aec329eb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -8,6 +8,7 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelCSVForm +from ipam.models import VRF from tenancy.models import Tenant from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from virtualization.models import Cluster @@ -617,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, @@ -631,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', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index eb3035122..8868cdf78 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -6,11 +6,11 @@ from dcim.choices import * from dcim.constants import * from dcim.models import * from extras.forms import CustomFieldModelFilterForm, LocalConfigContextFilterForm -from ipam.models import ASN +from ipam.models import ASN, VRF from tenancy.forms import TenancyFilterForm from utilities.forms import ( APISelectMultiple, add_blank_choice, ColorField, DynamicModelMultipleChoiceField, FilterForm, StaticSelect, - StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, SelectSpeedWidget, ) from wireless.choices import * @@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): model = Location field_groups = [ - ['q'], + ['q', 'tag'], ['region_id', 'site_group_id', 'site_id', 'parent_id'], ['tenant_group_id', 'tenant_id'], ] @@ -678,7 +678,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): field_groups = [ ['q', 'tag'], ['site_id', 'rack_id', 'device_id'], - ['type', 'status', 'color'], + ['type', 'status', 'color', 'length', 'length_unit'], ['tenant_group_id', 'tenant_id'], ] region_id = DynamicModelMultipleChoiceField( @@ -703,6 +703,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): 'site_id': '$site_id' } ) + device_id = DynamicModelMultipleChoiceField( + queryset=Device.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + 'tenant_id': '$tenant_id', + 'rack_id': '$rack_id', + }, + label=_('Device') + ) type = forms.MultipleChoiceField( choices=add_blank_choice(CableTypeChoices), required=False, @@ -716,15 +726,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): color = ColorField( required=False ) - device_id = DynamicModelMultipleChoiceField( - queryset=Device.objects.all(), - required=False, - query_params={ - 'site_id': '$site_id', - 'tenant_id': '$tenant_id', - 'rack_id': '$rack_id', - }, - label=_('Device') + length = forms.IntegerField( + required=False + ) + length_unit = forms.ChoiceField( + choices=add_blank_choice(CableLengthUnitChoices), + required=False ) tag = TagFilterField(model) @@ -920,7 +927,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm): model = Interface field_groups = [ ['q', 'tag'], - ['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'], + ['name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only'], + ['vrf_id', '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'], ] @@ -934,6 +942,17 @@ class InterfaceFilterForm(DeviceComponentFilterForm): required=False, widget=StaticSelectMultiple() ) + speed = forms.IntegerField( + required=False, + label='Select Speed', + widget=SelectSpeedWidget(attrs={'readonly': None}) + ) + duplex = forms.MultipleChoiceField( + choices=InterfaceDuplexChoices, + required=False, + label='Select Duplex', + widget=StaticSelectMultiple() + ) enabled = forms.NullBooleanField( required=False, widget=StaticSelect( @@ -980,6 +999,11 @@ class InterfaceFilterForm(DeviceComponentFilterForm): min_value=0, max_value=127 ) + vrf_id = DynamicModelMultipleChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tag = TagFilterField(model) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 65b7d46a8..378a567fc 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -9,12 +9,12 @@ 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 tenancy.forms import TenancyForm from utilities.forms import ( APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, - SlugField, StaticSelect, + SlugField, StaticSelect, SelectSpeedWidget, ) from virtualization.models import Cluster, ClusterGroup from wireless.models import WirelessLAN, WirelessLANGroup @@ -1261,6 +1261,11 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): 'available_on_device': '$device', } ) + vrf = DynamicModelChoiceField( + queryset=VRF.objects.all(), + required=False, + label='VRF' + ) tags = DynamicModelMultipleChoiceField( queryset=Tag.objects.all(), required=False @@ -1269,13 +1274,13 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): class Meta: model = Interface fields = [ - 'device', 'name', 'label', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mac_address', 'wwn', 'mtu', + 'device', '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', 'tags', + 'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] fieldsets = ( - ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')), - ('Addressing', ('mac_address', 'wwn')), + ('Interface', ('device', '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')), @@ -1287,6 +1292,8 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm): widgets = { 'device': forms.HiddenInput(), 'type': StaticSelect(), + 'speed': SelectSpeedWidget(), + 'duplex': StaticSelect(), 'mode': StaticSelect(), 'rf_role': StaticSelect(), 'rf_channel': StaticSelect(), diff --git a/netbox/dcim/migrations/0149_interface_vrf.py b/netbox/dcim/migrations/0149_interface_vrf.py new file mode 100644 index 000000000..224671f5b --- /dev/null +++ b/netbox/dcim/migrations/0149_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', '0148_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/0150_interface_speed_duplex.py b/netbox/dcim/migrations/0150_interface_speed_duplex.py new file mode 100644 index 000000000..f9517107a --- /dev/null +++ b/netbox/dcim/migrations/0150_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', '0149_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/0151_standardize_id_fields.py b/netbox/dcim/migrations/0151_standardize_id_fields.py new file mode 100644 index 000000000..76fea859b --- /dev/null +++ b/netbox/dcim/migrations/0151_standardize_id_fields.py @@ -0,0 +1,274 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0150_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/models/cables.py b/netbox/dcim/models/cables.py index 12fe91036..0d46d3c8f 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' @@ -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 b3ede8282..0538704d2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,4 +1,4 @@ -from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +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 @@ -7,8 +7,8 @@ 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 @@ -32,7 +32,7 @@ __all__ = ( ) -class ComponentTemplateModel(ChangeLoggedModel): +class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel): device_type = models.ForeignKey( to='dcim.DeviceType', on_delete=models.CASCADE, @@ -70,14 +70,10 @@ class ComponentTemplateModel(ChangeLoggedModel): """ raise NotImplementedError() - def to_objectchange(self, action, related_object=None): - # 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) + def to_objectchange(self, action): + objectchange = super().to_objectchange(action) + objectchange.related_object = self.device_type + return objectchange class ModularComponentTemplateModel(ComponentTemplateModel): @@ -102,19 +98,13 @@ class ModularComponentTemplateModel(ComponentTemplateModel): class Meta: abstract = True - def to_objectchange(self, action, related_object=None): - # Annotate the parent DeviceType or ModuleType - try: - if getattr(self, 'device_type'): - return super().to_objectchange(action, related_object=self.device_type) - except ObjectDoesNotExist: - pass - try: - if getattr(self, 'module_type'): - return super().to_objectchange(action, related_object=self.module_type) - except ObjectDoesNotExist: - pass - return super().to_objectchange(action) + 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() @@ -135,7 +125,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel): return self.name -@extras_features('webhooks') class ConsolePortTemplate(ModularComponentTemplateModel): """ A template for a ConsolePort to be created for a new Device. @@ -164,7 +153,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ConsoleServerPortTemplate(ModularComponentTemplateModel): """ A template for a ConsoleServerPort to be created for a new Device. @@ -193,7 +181,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class PowerPortTemplate(ModularComponentTemplateModel): """ A template for a PowerPort to be created for a new Device. @@ -245,7 +232,6 @@ class PowerPortTemplate(ModularComponentTemplateModel): }) -@extras_features('webhooks') class PowerOutletTemplate(ModularComponentTemplateModel): """ A template for a PowerOutlet to be created for a new Device. @@ -307,7 +293,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class InterfaceTemplate(ModularComponentTemplateModel): """ A template for a physical data interface on a new Device. @@ -347,7 +332,6 @@ class InterfaceTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class FrontPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the front of a new Device. @@ -420,7 +404,6 @@ class FrontPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class RearPortTemplate(ModularComponentTemplateModel): """ Template for a pass-through port on the rear of a new Device. @@ -460,7 +443,6 @@ class RearPortTemplate(ModularComponentTemplateModel): ) -@extras_features('webhooks') class ModuleBayTemplate(ComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. @@ -486,7 +468,6 @@ class ModuleBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. @@ -511,7 +492,6 @@ class DeviceBayTemplate(ComponentTemplateModel): ) -@extras_features('webhooks') class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): """ A template for an InventoryItem to be created for a new parent Device. diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index cdfaa7c89..a6887a768 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -11,8 +11,7 @@ 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 OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from utilities.choices import ColorChoices from utilities.fields import ColorField, NaturalOrderingField from utilities.mptt import TreeManager @@ -40,7 +39,7 @@ __all__ = ( ) -class ComponentModel(PrimaryModel): +class ComponentModel(NetBoxModel): """ An abstract model inherited by any model which has a parent Device. """ @@ -76,13 +75,9 @@ 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): @@ -131,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 ) @@ -254,7 +249,6 @@ class PathEndpoint(models.Model): # Console components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. @@ -282,7 +276,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): return reverse('dcim:consoleport', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. @@ -314,7 +307,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): # Power components # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. @@ -407,7 +399,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): } -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): """ A physical power outlet (output) within a Device which provides power to a PowerPort. @@ -522,7 +513,6 @@ class BaseInterface(models.Model): return self.fhrp_group_assignments.count() -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): """ A network interface within a Device. A physical Interface can connect to exactly one other Interface. @@ -551,6 +541,16 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo 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, @@ -616,6 +616,14 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo 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', @@ -785,7 +793,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo # Pass-through ports # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class FrontPort(ModularComponentModel, LinkTermination): """ A pass-through port on the front of a Device. @@ -839,7 +846,6 @@ class FrontPort(ModularComponentModel, LinkTermination): }) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RearPort(ModularComponentModel, LinkTermination): """ A pass-through port on the rear of a Device. @@ -883,7 +889,6 @@ class RearPort(ModularComponentModel, LinkTermination): # 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 @@ -904,7 +909,6 @@ class ModuleBay(ComponentModel): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class DeviceBay(ComponentModel): """ An empty space within a Device which can house a child device @@ -955,7 +959,6 @@ class DeviceBay(ComponentModel): # -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItemRole(OrganizationalModel): """ Inventory items may optionally be assigned a functional role. @@ -986,7 +989,6 @@ class InventoryItemRole(OrganizationalModel): return reverse('dcim:inventoryitemrole', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class InventoryItem(MPTTModel, ComponentModel): """ An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 631f0c8c1..37c900286 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 * @@ -37,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. @@ -70,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). @@ -353,8 +350,7 @@ class DeviceType(PrimaryModel): return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ModuleType(PrimaryModel): +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 @@ -487,7 +483,6 @@ class ModuleType(PrimaryModel): # 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 @@ -525,7 +520,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". @@ -575,8 +569,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. @@ -1012,8 +1005,7 @@ class Device(PrimaryModel, ConfigContextModel): return DeviceStatusChoices.colors.get(self.status, 'secondary') -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Module(PrimaryModel, ConfigContextModel): +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. @@ -1095,8 +1087,7 @@ class Module(PrimaryModel, ConfigContextModel): # 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 e3146c167..bbbdda83c 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. """ diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index c324d4cba..0fe84aa0c 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. @@ -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 a71206224..625422d6b 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). @@ -322,7 +317,6 @@ class Site(PrimaryModel): # 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/svg.py b/netbox/dcim/svg.py index e19e8fa2f..1058d8385 100644 --- a/netbox/dcim/svg.py +++ b/netbox/dcim/svg.py @@ -19,7 +19,12 @@ __all__ = ( def get_device_name(device): - return device.name or str(device.device_type) + if device.virtual_chassis: + return f'{device.virtual_chassis.name}:{device.vc_position}' + elif device.name: + return device.name + else: + return str(device.device_type) class RackElevationSVG: diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index 993ae0518..e3b2a42ba 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,7 +1,7 @@ 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 * @@ -36,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' ) @@ -44,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): @@ -67,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' ) @@ -75,7 +74,6 @@ class PowerConnectionTable(BaseTable): class Meta(BaseTable.Meta): model = PowerPort fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - exclude = ('id', ) class InterfaceConnectionTable(BaseTable): @@ -101,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' ) @@ -109,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 9b912894b..1774a3e22 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'), @@ -41,22 +40,22 @@ 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' ) - 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', 'termination_a', 'termination_b_parent', 'termination_b', - 'status', 'type', 'tenant', 'color', 'length', 'tags', + 'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 0c3a5f6a1..37faaae7f 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -5,11 +5,8 @@ from dcim.models import ( 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__ = ( @@ -74,69 +71,65 @@ def get_interface_state_attribute(record): # 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', + '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', + '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', ) @@ -144,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 @@ -161,7 +153,7 @@ class DeviceTable(BaseTable): rack = tables.Column( linkify=True ) - device_role = ColoredLabelColumn( + device_role = columns.ColoredLabelColumn( verbose_name='Role' ) manufacturer = tables.Column( @@ -197,17 +189,18 @@ class DeviceTable(BaseTable): vc_priority = tables.Column( verbose_name='VC Priority' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + 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', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', + 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created', + 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -215,11 +208,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 @@ -234,7 +227,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 @@ -244,8 +237,7 @@ class DeviceImportTable(BaseTable): # Device components # -class DeviceComponentTable(BaseTable): - pk = ToggleColumn() +class DeviceComponentTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -254,7 +246,7 @@ class DeviceComponentTable(BaseTable): order_by=('_name',) ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): order_by = ('device', 'name') @@ -271,26 +263,26 @@ class ModularDeviceComponentTable(DeviceComponentTable): ) -class CableTerminationTable(BaseTable): +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', @@ -305,7 +297,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleport_list' ) @@ -313,7 +305,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsolePort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -324,10 +316,8 @@ 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): @@ -336,7 +326,7 @@ class DeviceConsolePortTable(ConsolePortTable): '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 } @@ -349,7 +339,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:consoleserverport_list' ) @@ -357,7 +347,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = ConsoleServerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description') @@ -369,10 +359,8 @@ 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): @@ -381,7 +369,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable): '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 } @@ -394,7 +382,7 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): 'args': [Accessor('device_id')], } ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:powerport_list' ) @@ -402,7 +390,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', - 'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + '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') @@ -414,10 +403,8 @@ 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): @@ -428,7 +415,6 @@ class DevicePowerPortTable(PowerPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -445,7 +431,7 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): power_port = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:poweroutlet_list' ) @@ -453,7 +439,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable): model = PowerOutlet fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', - 'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', + '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') @@ -464,10 +451,8 @@ 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): @@ -477,15 +462,15 @@ class DevicePowerOutletTable(PowerOutletTable): '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, @@ -498,7 +483,7 @@ 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' @@ -512,16 +497,19 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi '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' ) @@ -529,9 +517,10 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi model = Interface fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', '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', + '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') @@ -554,10 +543,8 @@ 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): @@ -572,7 +559,7 @@ class DeviceInterfaceTable(InterfaceTable): 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, @@ -588,14 +575,14 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): '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' ) @@ -604,6 +591,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable): fields = ( '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', @@ -617,10 +605,8 @@ 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): @@ -631,7 +617,6 @@ class DeviceFrontPortTable(FrontPortTable): ) default_columns = ( 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', - 'actions', ) row_attrs = { 'class': get_cabletermination_row_class @@ -645,8 +630,8 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): 'args': [Accessor('device_id')], } ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:rearport_list' ) @@ -654,7 +639,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable): model = RearPort fields = ( 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', - 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', + 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description') @@ -666,10 +651,8 @@ 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): @@ -679,7 +662,7 @@ class DeviceRearPortTable(RearPortTable): '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 @@ -700,13 +683,17 @@ class DeviceBayTable(DeviceComponentTable): installed_device = tables.Column( linkify=True ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:devicebay_list' ) class Meta(DeviceComponentTable.Meta): model = DeviceBay - fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags', + 'created', 'last_updated', + ) + default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description') @@ -717,10 +704,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 +713,7 @@ 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): @@ -744,7 +727,7 @@ class ModuleBayTable(DeviceComponentTable): linkify=True, verbose_name='Installed module' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:modulebay_list' ) @@ -755,16 +738,14 @@ class ModuleBayTable(DeviceComponentTable): class DeviceModuleBayTable(ModuleBayTable): - actions = ButtonsColumn( - model=DeviceBay, - buttons=('edit', 'delete'), - prepend_template=MODULEBAY_BUTTONS + actions = columns.ActionsColumn( + extra_buttons=MODULEBAY_BUTTONS ) class Meta(DeviceComponentTable.Meta): model = ModuleBay fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') - default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions') + default_columns = ('pk', 'name', 'label', 'description', 'installed_module') class InventoryItemTable(DeviceComponentTable): @@ -785,17 +766,17 @@ class InventoryItemTable(DeviceComponentTable): orderable=False, linkify=True ) - discovered = BooleanColumn() - tags = TagColumn( + 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', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', - 'asset_tag', 'description', 'discovered', 'tags', + 'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', @@ -809,68 +790,62 @@ class DeviceInventoryItemTable(InventoryItemTable): order_by=Accessor('_name'), attrs={'td': {'class': 'text-nowrap'}} ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) + actions = columns.ActionsColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = InventoryItem fields = ( 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'description', 'discovered', 'tags', 'actions', ) default_columns = ( - 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions', + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', ) -class InventoryItemRoleTable(BaseTable): - pk = ToggleColumn() +class InventoryItemRoleTable(NetBoxTable): name = tables.Column( linkify=True ) - inventoryitem_count = LinkedCountColumn( + inventoryitem_count = columns.LinkedCountColumn( viewname='dcim:inventoryitem_list', url_params={'role_id': 'pk'}, verbose_name='Items' ) - color = ColorColumn() - tags = TagColumn( + color = columns.ColorColumn() + tags = columns.TagColumn( url_name='dcim:inventoryitemrole_list' ) - actions = ButtonsColumn(InventoryItemRole) - class Meta(BaseTable.Meta): + 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', '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') + 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 525c69030..44848f6ba 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -5,9 +5,7 @@ from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, -) +from netbox.tables import NetBoxTable, columns from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( @@ -30,8 +28,7 @@ __all__ = ( # Manufacturers # -class ManufacturerTable(BaseTable): - pk = ToggleColumn() +class ManufacturerTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -45,19 +42,18 @@ class ManufacturerTable(BaseTable): verbose_name='Platforms' ) slug = tables.Column() - 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', - 'actions', + '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', ) @@ -65,30 +61,29 @@ class ManufacturerTable(BaseTable): # Device types # -class DeviceTypeTable(BaseTable): - pk = ToggleColumn() +class DeviceTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Device Type' ) - 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', - 'airflow', 'comments', 'instance_count', 'tags', + 'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', @@ -99,8 +94,7 @@ class DeviceTypeTable(BaseTable): # Device type components # -class ComponentTemplateTable(BaseTable): - pk = ToggleColumn() +class ComponentTemplateTable(NetBoxTable): id = tables.Column( verbose_name='ID' ) @@ -108,15 +102,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'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -126,10 +119,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ConsoleServerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -139,10 +131,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -152,10 +143,9 @@ class PowerPortTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=PowerOutletTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -165,13 +155,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'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -184,11 +173,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): rear_port_position = tables.Column( verbose_name='Position' ) - color = ColorColumn() - actions = ButtonsColumn( - model=FrontPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + color = columns.ColorColumn() + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -198,11 +186,10 @@ class FrontPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable): - color = ColorColumn() - actions = ButtonsColumn( - model=RearPortTemplate, - buttons=('edit', 'delete'), - prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS + color = columns.ColorColumn() + actions = columns.ActionsColumn( + sequence=('edit', 'delete'), + extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -212,9 +199,8 @@ class RearPortTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=ModuleBayTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -224,9 +210,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=DeviceBayTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + sequence=('edit', 'delete') ) class Meta(ComponentTemplateTable.Meta): @@ -236,9 +221,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable): - actions = ButtonsColumn( - model=InventoryItemTemplate, - buttons=('edit', 'delete') + actions = columns.ActionsColumn( + sequence=('edit', 'delete') ) role = tables.Column( linkify=True diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 6d620433a..5b009e42e 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -1,7 +1,7 @@ import django_tables2 as tables from dcim.models import Module, ModuleType -from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn +from netbox.tables import NetBoxTable, columns __all__ = ( 'ModuleTable', @@ -9,23 +9,22 @@ __all__ = ( ) -class ModuleTypeTable(BaseTable): - pk = ToggleColumn() +class ModuleTypeTable(NetBoxTable): model = tables.Column( linkify=True, verbose_name='Module Type' ) - instance_count = LinkedCountColumn( + instance_count = columns.LinkedCountColumn( viewname='dcim:module_list', url_params={'module_type_id': 'pk'}, verbose_name='Instances' ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:moduletype_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ModuleType fields = ( 'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags', @@ -35,8 +34,7 @@ class ModuleTypeTable(BaseTable): ) -class ModuleTable(BaseTable): - pk = ToggleColumn() +class ModuleTable(NetBoxTable): device = tables.Column( linkify=True ) @@ -46,12 +44,12 @@ class ModuleTable(BaseTable): module_type = tables.Column( linkify=True ) - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + tags = columns.TagColumn( url_name='dcim:module_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Module fields = ( 'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags', diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py index ac58b64de..99bc963f9 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,26 +14,25 @@ __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' ) - 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', 'tags') + fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count') @@ -44,7 +43,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 ) @@ -54,25 +52,25 @@ 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', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', - 'comments', 'tags', + 'comments', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 14bbe3589..416e9e8ff 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,28 +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') - default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created', + 'last_updated', + ) + 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 @@ -52,27 +49,27 @@ 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' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='dcim:rack_list' ) outer_width = tables.TemplateColumn( @@ -84,11 +81,12 @@ 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', 'type', - 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', + 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', + 'get_power_utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', @@ -100,8 +98,7 @@ class RackTable(BaseTable): # Rack reservations # -class RackReservationTable(BaseTable): - pk = ToggleColumn() +class RackReservationTable(NetBoxTable): reservation = tables.Column( accessor='pk', linkify=True @@ -118,17 +115,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', - ) - default_columns = ( - 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', + 'actions', 'created', 'last_updated', ) + 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 ceca41c86..1be1f74d0 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,85 +17,85 @@ __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' ) - 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', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', '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' ) - 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', 'tags', 'actions') - default_columns = ('pk', 'name', 'site_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'site_count', 'description', '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( + asn_count = columns.LinkedCountColumn( accessor=tables.A('asns.count'), viewname='ipam:asn_list', url_params={'site_id': 'pk'}, verbose_name='ASNs' ) tenant = TenantColumn() - comments = MarkdownColumn() - tags = TagColumn( + comments = columns.MarkdownColumn() + 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', 'asn_count', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', + 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') @@ -106,37 +104,35 @@ 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' ) - 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', 'tags', - 'actions', + '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 2b6c02b82..a1baeb336 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """ {{ value }} """ -LOCATION_ELEVATIONS = """ +LOCATION_BUTTONS = """ @@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ {% load helpers %} -{% if perms.dcim.add_invnetoryitemtemplate %} - +{% if perms.dcim.add_inventoryitemtemplate %} + {% endif %} diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 0c9b918df..4ab682d74 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -6,7 +6,7 @@ from rest_framework import status from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, RIR, VLAN +from ipam.models import ASN, RIR, VLAN, VRF from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.models import WirelessLAN @@ -1424,6 +1424,13 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase ) WirelessLAN.objects.bulk_create(wireless_lans) + vrfs = ( + VRF(name='VRF 1'), + VRF(name='VRF 2'), + VRF(name='VRF 3'), + ) + VRF.objects.bulk_create(vrfs) + cls.create_data = [ { 'device': device.pk, @@ -1431,9 +1438,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'type': '1000base-t', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, + 'vrf': vrfs[0].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 1000000, + 'duplex': 'full' }, { 'device': device.pk, @@ -1442,9 +1452,12 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'mode': InterfaceModeChoices.MODE_TAGGED, 'bridge': interfaces[0].pk, 'tx_power': 10, + 'vrf': vrfs[1].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'speed': 100000, + 'duplex': 'half' }, { 'device': device.pk, @@ -1453,6 +1466,7 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase 'mode': InterfaceModeChoices.MODE_TAGGED, 'parent': interfaces[1].pk, 'tx_power': 10, + 'vrf': vrfs[2].pk, 'tagged_vlans': [vlans[0].pk, vlans[1].pk], 'untagged_vlan': vlans[2].pk, 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2973e46e7..de4806498 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -4,7 +4,7 @@ from django.test import TestCase from dcim.choices import * from dcim.filtersets import * from dcim.models import * -from ipam.models import ASN, IPAddress, RIR +from ipam.models import ASN, IPAddress, RIR, VRF from tenancy.models import Tenant, TenantGroup from utilities.choices import ColorChoices from utilities.testing import ChangeLoggedFilterSetTests, create_test_device @@ -2370,16 +2370,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): ) Device.objects.bulk_create(devices) + 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) + # VirtualChassis assignment for filtering virtual_chassis = VirtualChassis.objects.create(master=devices[0]) Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1) Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2) interfaces = ( - Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First'), - Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second'), - Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third'), - Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), + Interface(device=devices[0], name='Interface 1', label='A', type=InterfaceTypeChoices.TYPE_1GE_SFP, enabled=True, mgmt_only=True, mtu=100, mode=InterfaceModeChoices.MODE_ACCESS, mac_address='00-00-00-00-00-01', description='First', vrf=vrfs[0], speed=1000000, duplex='half'), + Interface(device=devices[1], name='Interface 2', label='B', type=InterfaceTypeChoices.TYPE_1GE_GBIC, enabled=True, mgmt_only=True, mtu=200, mode=InterfaceModeChoices.MODE_TAGGED, mac_address='00-00-00-00-00-02', description='Second', vrf=vrfs[1], speed=1000000, duplex='full'), + Interface(device=devices[2], name='Interface 3', label='C', type=InterfaceTypeChoices.TYPE_1GE_FIXED, enabled=False, mgmt_only=False, mtu=300, mode=InterfaceModeChoices.MODE_TAGGED_ALL, mac_address='00-00-00-00-00-03', description='Third', vrf=vrfs[2], speed=100000, duplex='half'), + Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40, speed=100000, duplex='full'), Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True, tx_power=40), Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False, tx_power=40), Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22), @@ -2416,6 +2423,14 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'mtu': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_speed(self): + params = {'speed': [1000000, 100000]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_duplex(self): + params = {'duplex': ['half', 'full']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_mgmt_only(self): params = {'mgmt_only': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) @@ -2550,6 +2565,13 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'tx_power': [40]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + 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) + class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8f7cb606b..4afa8a9f4 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -11,7 +11,7 @@ from netaddr import EUI from dcim.choices import * from dcim.constants import * from dcim.models import * -from ipam.models import ASN, RIR, VLAN +from ipam.models import ASN, RIR, VLAN, VRF from tenancy.models import Tenant from utilities.testing import ViewTestCases, create_tags, create_test_device from wireless.models import WirelessLAN @@ -2105,6 +2105,13 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): ) WirelessLAN.objects.bulk_create(wireless_lans) + 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 = { @@ -2117,6 +2124,8 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 65000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, @@ -2124,6 +2133,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -2137,12 +2147,15 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 100000, + 'duplex': 'half', 'mgmt_only': True, 'description': 'A front port', 'mode': InterfaceModeChoices.MODE_TAGGED, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], 'wireless_lans': [wireless_lans[0].pk, wireless_lans[1].pk], + 'vrf': vrfs[0].pk, 'tags': [t.pk for t in tags], } @@ -2153,19 +2166,22 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): 'mac_address': EUI('01:02:03:04:05:06'), 'wwn': EUI('01:02:03:04:05:06:07:08', version=64), 'mtu': 2000, + 'speed': 1000000, + 'duplex': 'full', 'mgmt_only': True, 'description': 'New description', 'mode': InterfaceModeChoices.MODE_TAGGED, 'tx_power': 10, 'untagged_vlan': vlans[0].pk, 'tagged_vlans': [v.pk for v in vlans[1:4]], + 'vrf': vrfs[1].pk, } cls.csv_data = ( - "device,name,type", - "Device 1,Interface 4,1000base-t", - "Device 1,Interface 5,1000base-t", - "Device 1,Interface 6,1000base-t", + f"device,name,type,vrf.pk", + f"Device 1,Interface 4,1000base-t,{vrfs[0].pk}", + f"Device 1,Interface 5,1000base-t,{vrfs[0].pk}", + f"Device 1,Interface 6,1000base-t,{vrfs[0].pk}", ) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index e64124539..231a3ef09 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,7 +20,7 @@ from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from netbox.tables import configure_table from utilities.utils import count_related from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from virtualization.models import VirtualMachine @@ -165,7 +165,7 @@ class RegionView(generic.ObjectView): region=instance ) sites_table = tables.SiteTable(sites, exclude=('region',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_regions_table': child_regions_table, @@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView): group=instance ) sites_table = tables.SiteTable(sites, exclude=('group',)) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'child_groups_table': child_groups_table, @@ -422,7 +422,7 @@ class LocationView(generic.ObjectView): cumulative=True ).filter(pk__in=location_ids).exclude(pk=instance.pk) child_locations_table = tables.LocationTable(child_locations) - paginate_table(child_locations_table, request) + configure_table(child_locations_table, request) return { 'rack_count': rack_count, @@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView): ) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) - paginate_table(racks_table, request) + configure_table(racks_table, request) return { 'racks_table': racks_table, @@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView): ) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',)) - paginate_table(devicetypes_table, request) + configure_table(devicetypes_table, request) return { 'devicetypes_table': devicetypes_table, @@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView): device_role=instance ) devices_table = tables.DeviceTable(devices, exclude=('device_role',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, @@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView): platform=instance ) devices_table = tables.DeviceTable(devices, exclude=('platform',)) - paginate_table(devices_table, request) + configure_table(devices_table, request) return { 'devices_table': devices_table, @@ -2379,8 +2379,9 @@ class DeviceBayPopulateView(generic.ObjectEditView): device_bay.installed_device = form.cleaned_data['installed_device'] device_bay.save() messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) + return_url = self.get_return_url(request) - return redirect('dcim:device', pk=device_bay.device.pk) + return redirect(return_url) return render(request, 'dcim/devicebay_populate.html', { 'device_bay': device_bay, diff --git a/netbox/extras/api/customfields.py b/netbox/extras/api/customfields.py index 5cb1fc276..fd6e1f550 100644 --- a/netbox/extras/api/customfields.py +++ b/netbox/extras/api/customfields.py @@ -1,6 +1,7 @@ from django.contrib.contenttypes.models import ContentType from rest_framework.fields import Field +from extras.choices import CustomFieldTypeChoices from extras.models import CustomField @@ -44,9 +45,20 @@ class CustomFieldsDataField(Field): return self._custom_fields def to_representation(self, obj): - return { - cf.name: obj.get(cf.name) for cf in self._get_custom_fields() - } + # TODO: Fix circular import + from utilities.api import get_serializer_for_model + data = {} + for cf in self._get_custom_fields(): + value = cf.deserialize(obj.get(cf.name)) + if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, context=self.parent.context).data + elif value is not None and cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + serializer = get_serializer_for_model(cf.object_type.model_class(), prefix='Nested') + value = serializer(value, many=True, context=self.parent.context).data + data[cf.name] = value + + return data def to_internal_value(self, data): # If updating an existing instance, start with existing custom_field_data diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index fa0e5189f..36b307b39 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer): fields = [ 'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', 'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', - 'conditions', 'ssl_verification', 'ca_file_path', + 'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated', ] @@ -79,14 +79,28 @@ class CustomFieldSerializer(ValidatedModelSerializer): ) type = ChoiceField(choices=CustomFieldTypeChoices) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) + data_type = serializers.SerializerMethodField() class Meta: model = CustomField fields = [ - 'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', - 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', + 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required', + 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', + 'choices', 'created', 'last_updated', ] + def get_data_type(self, obj): + types = CustomFieldTypeChoices + if obj.type == types.TYPE_INTEGER: + return 'integer' + if obj.type == types.TYPE_BOOLEAN: + return 'boolean' + if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT): + return 'object' + if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT): + return 'array' + return 'string' + # # Custom links @@ -101,8 +115,8 @@ class CustomLinkSerializer(ValidatedModelSerializer): class Meta: model = CustomLink fields = [ - 'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', 'created', 'last_updated', ] @@ -120,7 +134,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer): model = ExportTemplate fields = [ 'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', - 'file_extension', 'as_attachment', + 'file_extension', 'as_attachment', 'created', 'last_updated', ] @@ -134,7 +148,9 @@ class TagSerializer(ValidatedModelSerializer): class Meta: model = Tag - fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items'] + fields = [ + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + ] # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index cf64bc005..0632c2b1f 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -16,6 +16,8 @@ class CustomFieldTypeChoices(ChoiceSet): TYPE_JSON = 'json' TYPE_SELECT = 'select' TYPE_MULTISELECT = 'multiselect' + TYPE_OBJECT = 'object' + TYPE_MULTIOBJECT = 'multiobject' CHOICES = ( (TYPE_TEXT, 'Text'), @@ -27,6 +29,8 @@ class CustomFieldTypeChoices(ChoiceSet): (TYPE_JSON, 'JSON'), (TYPE_SELECT, 'Selection'), (TYPE_MULTISELECT, 'Multiple selection'), + (TYPE_OBJECT, 'Object'), + (TYPE_MULTIOBJECT, 'Multiple objects'), ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 64cc82f63..123eb0a45 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -7,6 +7,7 @@ EXTRAS_FEATURES = [ 'custom_links', 'export_templates', 'job_results', + 'journaling', 'tags', 'webhooks' ] diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf25ff76c..a839e2dd3 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet): class Meta: model = CustomLink - fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window'] + fields = [ + 'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window', + ] def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index 1b87256a5..56b51c894 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm): limit_choices_to=FeatureQuery('custom_fields'), required=False ) + enabled = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect() + ) new_window = forms.NullBooleanField( required=False, widget=BulkEditNullBooleanSelect() diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 9f44494e0..fa6d8af55 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm): class Meta: model = CustomLink fields = ( - 'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url', + 'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', + 'link_url', ) diff --git a/netbox/extras/forms/customfields.py b/netbox/extras/forms/customfields.py index d58e6ce65..8912d0365 100644 --- a/netbox/extras/forms/customfields.py +++ b/netbox/extras/forms/customfields.py @@ -4,7 +4,7 @@ from django.db.models import Q from extras.choices import * from extras.models import * -from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm +from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm __all__ = ( 'CustomFieldModelCSVForm', @@ -20,7 +20,7 @@ class CustomFieldsMixin: Extend a Form to include custom field support. """ def __init__(self, *args, **kwargs): - self.custom_fields = [] + self.custom_fields = {} super().__init__(*args, **kwargs) @@ -34,6 +34,9 @@ class CustomFieldsMixin: raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") return ContentType.objects.get_for_model(self.model) + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type) + def _get_form_field(self, customfield): return customfield.to_form_field() @@ -41,15 +44,12 @@ class CustomFieldsMixin: """ Append form fields for all CustomFields assigned to this object type. """ - content_type = self._get_content_type() - - # Append form fields; assign initial values if modifying and existing object - for customfield in CustomField.objects.filter(content_types=content_type): + for customfield in self._get_custom_fields(self._get_content_type()): field_name = f'cf_{customfield.name}' self.fields[field_name] = self._get_form_field(customfield) # Annotate the field in the list of CustomField form fields - self.custom_fields.append(field_name) + self.custom_fields[field_name] = customfield class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): @@ -70,12 +70,15 @@ class CustomFieldModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): def clean(self): # Save custom field data on instance - for cf_name in self.custom_fields: + for cf_name, customfield in self.custom_fields.items(): key = cf_name[3:] # Strip "cf_" from field name value = self.cleaned_data.get(cf_name) - empty_values = self.fields[cf_name].empty_values + # Convert "empty" values to null - self.instance.custom_field_data[key] = value if value not in empty_values else None + if value in self.fields[cf_name].empty_values: + self.instance.custom_field_data[key] = None + else: + self.instance.custom_field_data[key] = customfield.serialize(value) return super().clean() @@ -86,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm): return customfield.to_form_field(for_csv_import=True) -class CustomFieldModelBulkEditForm(BulkEditForm): +class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) - self.custom_fields = [] - self.obj_type = ContentType.objects.get_for_model(self.model) - - # Add all applicable CustomFields to the form - custom_fields = CustomField.objects.filter(content_types=self.obj_type) - for cf in custom_fields: + def _append_customfield_fields(self): + """ + Append form fields for all CustomFields assigned to this object type. + """ + for customfield in self._get_custom_fields(self._get_content_type()): # Annotate non-required custom fields as nullable - if not cf.required: - self.nullable_fields.append(cf.name) - self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False) - # Annotate this as a custom field - self.custom_fields.append(cf.name) + if not customfield.required: + self.nullable_fields.append(customfield.name) + + self.fields[customfield.name] = self._get_form_field(customfield) + + # Annotate the field in the list of CustomField form fields + self.custom_fields[customfield.name] = customfield -class CustomFieldModelFilterForm(FilterForm): +class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form): + q = forms.CharField( + required=False, + label='Search' + ) - def __init__(self, *args, **kwargs): - - self.obj_type = ContentType.objects.get_for_model(self.model) - - super().__init__(*args, **kwargs) - - # Add all applicable CustomFields to the form - self.custom_field_filters = [] - custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude( + def _get_custom_fields(self, content_type): + return CustomField.objects.filter(content_types=content_type).exclude( Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(type=CustomFieldTypeChoices.TYPE_JSON) ) - for cf in custom_fields: - field_name = f'cf_{cf.name}' - self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) - self.custom_field_filters.append(field_name) + + def _get_form_field(self, customfield): + return customfield.to_form_field(set_initial=False, enforce_required=False) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 388cd1e60..330bb91e3 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm): field_groups = [ ['q'], - ['content_type', 'weight', 'new_window'], + ['content_type', 'enabled', 'new_window', 'weight'], ] content_type = ContentTypeChoiceField( queryset=ContentType.objects.all(), limit_choices_to=FeatureQuery('custom_fields'), required=False ) - weight = forms.IntegerField( - required=False + enabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) ) new_window = forms.NullBooleanField( required=False, @@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) + weight = forms.IntegerField( + required=False + ) class ExportTemplateFilterForm(FilterForm): diff --git a/netbox/extras/forms/models.py b/netbox/extras/forms/models.py index d75214722..9aac8454b 100644 --- a/netbox/extras/forms/models.py +++ b/netbox/extras/forms/models.py @@ -7,8 +7,8 @@ from extras.models import * from extras.utils import FeatureQuery from tenancy.models import Tenant, TenantGroup from utilities.forms import ( - add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, - ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, + add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, + DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, ) from virtualization.models import Cluster, ClusterGroup, ClusterType @@ -35,12 +35,16 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm): model = CustomField fields = '__all__' fieldsets = ( - ('Custom Field', ('name', 'label', 'type', 'weight', 'required', 'description')), + ('Custom Field', ('name', 'label', 'type', 'object_type', 'weight', 'required', 'description')), ('Assigned Models', ('content_types',)), ('Behavior', ('filter_logic',)), ('Values', ('default', 'choices')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ) + widgets = { + 'type': StaticSelect(), + 'filter_logic': StaticSelect(), + } class CustomLinkForm(BootstrapMixin, forms.ModelForm): @@ -53,10 +57,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm): model = CustomLink fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')), + ('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')), ('Templates', ('link_text', 'link_url')), ) widgets = { + 'button_class': StaticSelect(), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 'link_url': forms.Textarea(attrs={'class': 'font-monospace'}), } @@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm): model = ExportTemplate fields = '__all__' fieldsets = ( - ('Custom Link', ('name', 'content_type', 'description')), + ('Export Template', ('name', 'content_type', 'description')), ('Template', ('template_code',)), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ) @@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): model = Webhook fields = '__all__' fieldsets = ( - ('Webhook', ('name', 'enabled')), - ('Assigned Models', ('content_types',)), + ('Webhook', ('name', 'content_types', 'enabled')), ('Events', ('type_create', 'type_update', 'type_delete')), ('HTTP Request', ( 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', @@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): ('Conditions', ('conditions',)), ('SSL', ('ssl_verification', 'ca_file_path')), ) + labels = { + 'type_create': 'Creations', + 'type_update': 'Updates', + 'type_delete': 'Deletions', + } widgets = { + 'http_method': StaticSelect(), 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), } diff --git a/netbox/extras/migrations/0069_custom_object_field.py b/netbox/extras/migrations/0069_custom_object_field.py new file mode 100644 index 000000000..720e21edc --- /dev/null +++ b/netbox/extras/migrations/0069_custom_object_field.py @@ -0,0 +1,18 @@ +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0068_configcontext_cluster_types'), + ] + + operations = [ + migrations.AddField( + model_name='customfield', + name='object_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/extras/migrations/0070_customlink_enabled.py b/netbox/extras/migrations/0070_customlink_enabled.py new file mode 100644 index 000000000..839a4dba5 --- /dev/null +++ b/netbox/extras/migrations/0070_customlink_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.11 on 2022-01-10 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0069_custom_object_field'), + ] + + operations = [ + migrations.AddField( + model_name='customlink', + name='enabled', + field=models.BooleanField(default=True), + ), + ] diff --git a/netbox/extras/migrations/0071_standardize_id_fields.py b/netbox/extras/migrations/0071_standardize_id_fields.py new file mode 100644 index 000000000..63e3051d8 --- /dev/null +++ b/netbox/extras/migrations/0071_standardize_id_fields.py @@ -0,0 +1,89 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='configcontext', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='configrevision', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customfield', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='customlink', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='exporttemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='imageattachment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='jobresult', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='journalentry', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='objectchange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='taggeditem', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='webhook', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='imageattachment', + name='object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='journalentry', + name='assigned_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='changed_object_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='objectchange', + name='related_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 8dfeb2f18..8444260c8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -5,11 +5,10 @@ from django.db import models from django.urls import reverse from extras.choices import * -from netbox.models import BigIDModel from utilities.querysets import RestrictedQuerySet -class ObjectChange(BigIDModel): +class ObjectChange(models.Model): """ Record a change to an object and the user account associated with that change. A change record may optionally indicate an object related to the one being changed. For example, a change to an interface may also indicate the @@ -43,7 +42,7 @@ class ObjectChange(BigIDModel): on_delete=models.PROTECT, related_name='+' ) - changed_object_id = models.PositiveIntegerField() + changed_object_id = models.PositiveBigIntegerField() changed_object = GenericForeignKey( ct_field='changed_object_type', fk_field='changed_object_id' @@ -55,7 +54,7 @@ class ObjectChange(BigIDModel): blank=True, null=True ) - related_object_id = models.PositiveIntegerField( + related_object_id = models.PositiveBigIntegerField( blank=True, null=True ) diff --git a/netbox/extras/models/configcontexts.py b/netbox/extras/models/configcontexts.py index 2a14f143f..0dc5d57db 100644 --- a/netbox/extras/models/configcontexts.py +++ b/netbox/extras/models/configcontexts.py @@ -5,8 +5,8 @@ from django.db import models from django.urls import reverse from extras.querysets import ConfigContextQuerySet -from extras.utils import extras_features from netbox.models import ChangeLoggedModel +from netbox.models.features import WebhooksMixin from utilities.utils import deepmerge @@ -20,8 +20,7 @@ __all__ = ( # Config contexts # -@extras_features('webhooks') -class ConfigContext(ChangeLoggedModel): +class ConfigContext(WebhooksMixin, ChangeLoggedModel): """ A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8c817ad33..923d84413 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -12,11 +12,13 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from extras.choices import * -from extras.utils import FeatureQuery, extras_features +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( - CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, + CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, + LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice, ) from utilities.querysets import RestrictedQuerySet from utilities.validators import validate_regex @@ -39,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -@extras_features('webhooks', 'export_templates') -class CustomField(ChangeLoggedModel): +class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', @@ -50,7 +51,15 @@ class CustomField(ChangeLoggedModel): type = models.CharField( max_length=50, choices=CustomFieldTypeChoices, - default=CustomFieldTypeChoices.TYPE_TEXT + default=CustomFieldTypeChoices.TYPE_TEXT, + help_text='The type of data this custom field holds' + ) + object_type = models.ForeignKey( + to=ContentType, + on_delete=models.PROTECT, + blank=True, + null=True, + help_text='The type of NetBox object this field maps to (for object fields)' ) name = models.CharField( max_length=50, @@ -122,7 +131,6 @@ class CustomField(ChangeLoggedModel): null=True, help_text='Comma-separated list of available choices (for selection fields)' ) - objects = CustomFieldManager() class Meta: @@ -234,11 +242,48 @@ class CustomField(ChangeLoggedModel): 'default': f"The specified default value ({self.default}) is not listed as an available choice." }) + # Object fields must define an object_type; other fields must not + if self.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT): + if not self.object_type: + raise ValidationError({ + 'object_type': "Object fields must define an object type." + }) + elif self.object_type: + raise ValidationError({ + 'object_type': f"{self.get_type_display()} fields may not define an object type." + }) + + def serialize(self, value): + """ + Prepare a value for storage as JSON data. + """ + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: + return value.pk + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + return [obj.pk for obj in value] or None + return value + + def deserialize(self, value): + """ + Convert JSON data to a Python object suitable for the field type. + """ + if value is None: + return value + if self.type == CustomFieldTypeChoices.TYPE_OBJECT: + model = self.object_type.model_class() + return model.objects.filter(pk=value).first() + if self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + return model.objects.filter(pk__in=value) + return value + def to_form_field(self, set_initial=True, enforce_required=True, for_csv_import=False): """ Return a form field suitable for setting a CustomField's value for an object. - set_initial: Set initial date for the field. This should be False when generating a field for bulk editing. + set_initial: Set initial data for the field. This should be False when generating a field for bulk editing. enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing. for_csv_import: Return a form field suitable for bulk import of objects in CSV format. """ @@ -287,7 +332,7 @@ class CustomField(ChangeLoggedModel): choices=choices, required=required, initial=initial, widget=StaticSelect() ) else: - field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField + field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField field = field_class( choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() ) @@ -300,6 +345,24 @@ class CustomField(ChangeLoggedModel): elif self.type == CustomFieldTypeChoices.TYPE_JSON: field = forms.JSONField(required=required, initial=initial) + # Object + elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: + model = self.object_type.model_class() + field = DynamicModelChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + + # Multiple objects + elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + model = self.object_type.model_class() + field = DynamicModelMultipleChoiceField( + queryset=model.objects.all(), + required=required, + initial=initial + ) + # Text else: if self.type == CustomFieldTypeChoices.TYPE_LONGTEXT: diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index ac3a23410..afcb6556c 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder from extras.choices import * from extras.constants import * from extras.conditions import ConditionSet -from extras.utils import extras_features, FeatureQuery, image_upload -from netbox.models import BigIDModel, ChangeLoggedModel +from extras.utils import FeatureQuery, image_upload +from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -35,8 +36,7 @@ __all__ = ( ) -@extras_features('webhooks', 'export_templates') -class Webhook(ChangeLoggedModel): +class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or delete in NetBox. The request will contain a representation of the object, which the remote application can act on. @@ -68,7 +68,8 @@ class Webhook(ChangeLoggedModel): payload_url = models.CharField( max_length=500, verbose_name='URL', - help_text="A POST will be sent to this URL when the webhook is called." + help_text='This URL will be called using the HTTP method defined when the webhook is called. ' + 'Jinja2 template processing is supported with the same context as the request body.' ) enabled = models.BooleanField( default=True @@ -176,9 +177,14 @@ class Webhook(ChangeLoggedModel): else: return json.dumps(context, cls=JSONEncoder) + def render_payload_url(self, context): + """ + Render the payload URL. + """ + return render_jinja2(self.payload_url, context) -@extras_features('webhooks', 'export_templates') -class CustomLink(ChangeLoggedModel): + +class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -192,6 +198,9 @@ class CustomLink(ChangeLoggedModel): max_length=100, unique=True ) + enabled = models.BooleanField( + default=True + ) link_text = models.CharField( max_length=500, help_text="Jinja2 template code for link text" @@ -248,8 +257,7 @@ class CustomLink(ChangeLoggedModel): } -@extras_features('webhooks', 'export_templates') -class ExportTemplate(ChangeLoggedModel): +class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE, @@ -335,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel): return response -@extras_features('webhooks') -class ImageAttachment(ChangeLoggedModel): +class ImageAttachment(WebhooksMixin, ChangeLoggedModel): """ An uploaded image which is associated with an object. """ @@ -344,7 +351,7 @@ class ImageAttachment(ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - object_id = models.PositiveIntegerField() + object_id = models.PositiveBigIntegerField() parent = GenericForeignKey( ct_field='content_type', fk_field='object_id' @@ -411,11 +418,12 @@ class ImageAttachment(ChangeLoggedModel): return None def to_objectchange(self, action): - return super().to_objectchange(action, related_object=self.parent) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.parent + return objectchange -@extras_features('webhooks') -class JournalEntry(ChangeLoggedModel): +class JournalEntry(WebhooksMixin, ChangeLoggedModel): """ A historical remark concerning an object; collectively, these form an object's journal. The journal is used to preserve historical context around an object, and complements NetBox's built-in change logging. For example, you @@ -425,7 +433,7 @@ class JournalEntry(ChangeLoggedModel): to=ContentType, on_delete=models.CASCADE ) - assigned_object_id = models.PositiveIntegerField() + assigned_object_id = models.PositiveBigIntegerField() assigned_object = GenericForeignKey( ct_field='assigned_object_type', fk_field='assigned_object_id' @@ -461,7 +469,7 @@ class JournalEntry(ChangeLoggedModel): return JournalEntryKindChoices.colors.get(self.kind) -class JobResult(BigIDModel): +class JobResult(models.Model): """ This model stores the results from running a user-defined report. """ @@ -593,8 +601,7 @@ class ConfigRevision(models.Model): # Custom scripts & reports # -@extras_features('job_results') -class Script(models.Model): +class Script(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for custom scripts. Does not exist in the database. """ @@ -606,8 +613,7 @@ class Script(models.Model): # Reports # -@extras_features('job_results') -class Report(models.Model): +class Report(JobResultsMixin, models.Model): """ Dummy model used to generate permissions for reports. Does not exist in the database. """ diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 2925da652..a4e4049d7 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -3,8 +3,8 @@ from django.urls import reverse from django.utils.text import slugify from taggit.models import TagBase, GenericTaggedItemBase -from extras.utils import extras_features -from netbox.models import BigIDModel, ChangeLoggedModel +from netbox.models import ChangeLoggedModel +from netbox.models.features import ExportTemplatesMixin, WebhooksMixin from utilities.choices import ColorChoices from utilities.fields import ColorField @@ -13,8 +13,10 @@ from utilities.fields import ColorField # Tags # -@extras_features('webhooks', 'export_templates') -class Tag(ChangeLoggedModel, TagBase): +class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase): + id = models.BigAutoField( + primary_key=True + ) color = ColorField( default=ColorChoices.COLOR_GREY ) @@ -37,7 +39,7 @@ class Tag(ChangeLoggedModel, TagBase): return slug -class TaggedItem(BigIDModel, GenericTaggedItemBase): +class TaggedItem(GenericTaggedItemBase): tag = models.ForeignKey( to=Tag, related_name="%(app_label)s_%(class)s_items", diff --git a/netbox/extras/registry.py b/netbox/extras/registry.py index cb58f5135..07fd4cc24 100644 --- a/netbox/extras/registry.py +++ b/netbox/extras/registry.py @@ -1,3 +1,8 @@ +import collections + +from extras.constants import EXTRAS_FEATURES + + class Registry(dict): """ Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or @@ -7,15 +12,19 @@ class Registry(dict): try: return super().__getitem__(key) except KeyError: - raise KeyError("Invalid store: {}".format(key)) + raise KeyError(f"Invalid store: {key}") def __setitem__(self, key, value): if key in self: - raise KeyError("Store already set: {}".format(key)) + raise KeyError(f"Store already set: {key}") super().__setitem__(key, value) def __delitem__(self, key): raise TypeError("Cannot delete stores from registry") +# Initialize the global registry registry = Registry() +registry['model_features'] = { + feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES +} diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index b128f7461..3c7ad3c15 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -21,7 +21,7 @@ from extras.models import JobResult from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from utilities.exceptions import AbortTransaction -from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField +from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField from .context_managers import change_logging from .forms import ScriptForm @@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable): def __init__(self, choices, *args, **kwargs): super().__init__(*args, **kwargs) - # Set field choices - self.field_attrs['choices'] = choices + # Set field choices, adding a blank choice to avoid forced selections + self.field_attrs['choices'] = add_blank_choice(choices) -class MultiChoiceVar(ChoiceVar): +class MultiChoiceVar(ScriptVariable): """ Like ChoiceVar, but allows for the selection of multiple choices. """ form_field = forms.MultipleChoiceField + def __init__(self, choices, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Set field choices + self.field_attrs['choices'] = choices + class ObjectVar(ScriptVariable): """ diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 62317e636..52aeb9708 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -1,10 +1,7 @@ import django_tables2 as tables from django.conf import settings -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, - MarkdownColumn, ToggleColumn, -) +from netbox.tables import NetBoxTable, columns from .models import * __all__ = ( @@ -46,19 +43,18 @@ OBJECTCHANGE_REQUEST_ID = """ # Custom fields # -class CustomFieldTable(BaseTable): - pk = ToggleColumn() +class CustomFieldTable(NetBoxTable): name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - required = BooleanColumn() + content_types = columns.ContentTypesColumn() + required = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomField fields = ( 'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', - 'description', 'filter_logic', 'choices', + 'description', 'filter_logic', 'choices', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description') @@ -67,39 +63,39 @@ class CustomFieldTable(BaseTable): # Custom links # -class CustomLinkTable(BaseTable): - pk = ToggleColumn() +class CustomLinkTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - new_window = BooleanColumn() + content_type = columns.ContentTypeColumn() + enabled = columns.BooleanColumn() + new_window = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = CustomLink fields = ( - 'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', - 'button_class', 'new_window', + 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', + 'button_class', 'new_window', 'created', 'last_updated', ) - default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window') + default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window') # # Export templates # -class ExportTemplateTable(BaseTable): - pk = ToggleColumn() +class ExportTemplateTable(NetBoxTable): name = tables.Column( linkify=True ) - content_type = ContentTypeColumn() - as_attachment = BooleanColumn() + content_type = columns.ContentTypeColumn() + as_attachment = columns.BooleanColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ExportTemplate fields = ( 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', + 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', @@ -110,31 +106,30 @@ class ExportTemplateTable(BaseTable): # Webhooks # -class WebhookTable(BaseTable): - pk = ToggleColumn() +class WebhookTable(NetBoxTable): name = tables.Column( linkify=True ) - content_types = ContentTypesColumn() - enabled = BooleanColumn() - type_create = BooleanColumn( + content_types = columns.ContentTypesColumn() + enabled = columns.BooleanColumn() + type_create = columns.BooleanColumn( verbose_name='Create' ) - type_update = BooleanColumn( + type_update = columns.BooleanColumn( verbose_name='Update' ) - type_delete = BooleanColumn( + type_delete = columns.BooleanColumn( verbose_name='Delete' ) - ssl_validation = BooleanColumn( + ssl_validation = columns.BooleanColumn( verbose_name='SSL Validation' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Webhook fields = ( 'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', - 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', + 'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', @@ -146,27 +141,25 @@ class WebhookTable(BaseTable): # Tags # -class TagTable(BaseTable): - pk = ToggleColumn() +class TagTable(NetBoxTable): name = tables.Column( linkify=True ) - color = ColorColumn() - actions = ButtonsColumn(Tag) + color = columns.ColorColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions') - default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions') + fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') + default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') -class TaggedItemTable(BaseTable): +class TaggedItemTable(NetBoxTable): id = tables.Column( verbose_name='ID', linkify=lambda record: record.content_object.get_absolute_url(), accessor='content_object__id' ) - content_type = ContentTypeColumn( + content_type = columns.ContentTypeColumn( verbose_name='Type' ) content_object = tables.Column( @@ -175,36 +168,36 @@ class TaggedItemTable(BaseTable): verbose_name='Object' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = TaggedItem fields = ('id', 'content_type', 'content_object') -class ConfigContextTable(BaseTable): - pk = ToggleColumn() +class ConfigContextTable(NetBoxTable): name = tables.Column( linkify=True ) - is_active = BooleanColumn( + is_active = columns.BooleanColumn( verbose_name='Active' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ConfigContext fields = ( 'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', - 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', + 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', + 'last_updated', ) default_columns = ('pk', 'name', 'weight', 'is_active', 'description') -class ObjectChangeTable(BaseTable): +class ObjectChangeTable(NetBoxTable): time = tables.DateTimeColumn( linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - action = ChoiceFieldColumn() - changed_object_type = ContentTypeColumn( + action = columns.ChoiceFieldColumn() + changed_object_type = columns.ContentTypeColumn( verbose_name='Type' ) object_repr = tables.TemplateColumn( @@ -215,13 +208,14 @@ class ObjectChangeTable(BaseTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name='Request ID' ) + actions = columns.ActionsColumn(sequence=()) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ObjectChange fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') -class ObjectJournalTable(BaseTable): +class ObjectJournalTable(NetBoxTable): """ Used for displaying a set of JournalEntries within the context of a single object. """ @@ -229,22 +223,18 @@ class ObjectJournalTable(BaseTable): linkify=True, format=settings.SHORT_DATETIME_FORMAT ) - kind = ChoiceFieldColumn() + kind = columns.ChoiceFieldColumn() comments = tables.TemplateColumn( template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' ) - actions = ButtonsColumn( - model=JournalEntry - ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ('id', 'created', 'created_by', 'kind', 'comments', 'actions') class JournalEntryTable(ObjectJournalTable): - pk = ToggleColumn() - assigned_object_type = ContentTypeColumn( + assigned_object_type = columns.ContentTypeColumn( verbose_name='Object type' ) assigned_object = tables.Column( @@ -252,15 +242,14 @@ class JournalEntryTable(ObjectJournalTable): orderable=False, verbose_name='Object' ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = JournalEntry fields = ( 'pk', 'id', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments', 'actions' ) default_columns = ( - 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', - 'comments', 'actions' + 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments' ) diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 32ec966b3..dd5467338 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -36,7 +36,7 @@ def custom_links(context, obj): Render all applicable links for the given object. """ content_type = ContentType.objects.get_for_model(obj) - custom_links = CustomLink.objects.filter(content_type=content_type) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) if not custom_links: return '' diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index d15b57e43..d790eff71 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): { 'content_type': 'dcim.site', 'name': 'Custom Link 4', + 'enabled': True, 'link_text': 'Link 4', 'link_url': 'http://example.com/?4', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 5', + 'enabled': True, 'link_text': 'Link 5', 'link_url': 'http://example.com/?5', }, { 'content_type': 'dcim.site', 'name': 'Custom Link 6', + 'enabled': False, 'link_text': 'Link 6', 'link_url': 'http://example.com/?6', }, ] bulk_update_data = { 'new_window': True, + 'enabled': False, } @classmethod @@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase): CustomLink( content_type=site_ct, name='Custom Link 1', + enabled=True, link_text='Link 1', link_url='http://example.com/?1', ), CustomLink( content_type=site_ct, name='Custom Link 2', + enabled=True, link_text='Link 2', link_url='http://example.com/?2', ), CustomLink( content_type=site_ct, name='Custom Link 3', + enabled=False, link_text='Link 3', link_url='http://example.com/?3', ), diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fdabe0fcf..3a5fe3ac9 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -8,13 +8,15 @@ from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * from extras.models import CustomField +from ipam.models import VLAN from utilities.testing import APITestCase, TestCase from virtualization.models import VirtualMachine class CustomFieldTest(TestCase): - def setUp(self): + @classmethod + def setUpTestData(cls): Site.objects.bulk_create([ Site(name='Site A', slug='site-a'), @@ -22,137 +24,294 @@ class CustomFieldTest(TestCase): Site(name='Site C', slug='site-c'), ]) - def test_simple_fields(self): - DATA = ( - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_TEXT, - }, - 'value': 'Foobar!', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_LONGTEXT, - }, - 'value': 'Text with **Markdown**', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - }, - 'value': 0, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': 1, - 'validation_maximum': 100, - }, - 'value': 42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_INTEGER, - 'validation_minimum': -100, - 'validation_maximum': -1, - }, - 'value': -42, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': True, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_BOOLEAN, - }, - 'value': False, - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_DATE, - }, - 'value': '2016-06-23', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_URL, - }, - 'value': 'http://example.com/', - }, - { - 'field': { - 'type': CustomFieldTypeChoices.TYPE_JSON, - }, - 'value': '{"foo": 1, "bar": 2}', - }, + cls.object_type = ContentType.objects.get_for_model(Site) + + def test_text_field(self): + value = 'Foobar!' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='text_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - obj_type = ContentType.objects.get_for_model(Site) + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - for data in DATA: + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Create a custom field - cf = CustomField(name='my_field', required=False, **data['field']) - cf.save() - cf.content_types.set([obj_type]) + def test_longtext_field(self): + value = 'A' * 256 - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='longtext_field', + type=CustomFieldTypeChoices.TYPE_LONGTEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Assign a value to the first Site - site.custom_field_data[cf.name] = data['value'] - site.save() + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], data['value']) + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + def test_integer_field(self): - # Delete the custom field - cf.delete() + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='integer_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (123456, 0, -123456): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_boolean_field(self): + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='boolean_field', + type=CustomFieldTypeChoices.TYPE_INTEGER, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + for value in (True, False): + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_date_field(self): + value = '2016-06-23' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='date_field', + type=CustomFieldTypeChoices.TYPE_TEXT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_url_field(self): + value = 'http://example.com/' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='url_field', + type=CustomFieldTypeChoices.TYPE_URL, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_json_field(self): + value = '{"foo": 1, "bar": 2}' + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='json_field', + type=CustomFieldTypeChoices.TYPE_JSON, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_select_field(self): - obj_type = ContentType.objects.get_for_model(Site) + CHOICES = ('Option A', 'Option B', 'Option C') + value = CHOICES[1] - # Create a custom field - cf = CustomField( + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='select_field', type=CustomFieldTypeChoices.TYPE_SELECT, - name='my_field', required=False, - choices=['Option A', 'Option B', 'Option C'] + choices=CHOICES ) - cf.save() - cf.content_types.set([obj_type]) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Check that the field has a null initial value - site = Site.objects.first() - self.assertIsNone(site.custom_field_data[cf.name]) + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) - # Assign a value to the first Site - site.custom_field_data[cf.name] = 'Option A' - site.save() + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) - # Retrieve the stored value - site.refresh_from_db() - self.assertEqual(site.custom_field_data[cf.name], 'Option A') + def test_multiselect_field(self): + CHOICES = ['Option A', 'Option B', 'Option C'] + value = [CHOICES[1], CHOICES[2]] - # Delete the stored value - site.custom_field_data.pop(cf.name) - site.save() - site.refresh_from_db() - self.assertIsNone(site.custom_field_data.get(cf.name)) + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='multiselect_field', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + required=False, + choices=CHOICES + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) - # Delete the custom field - cf.delete() + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_object_field(self): + value = VLAN.objects.create(name='VLAN 1', vid=1).pk + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_OBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) + + def test_multiobject_field(self): + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + ) + VLAN.objects.bulk_create(vlans) + value = [vlan.pk for vlan in vlans] + + # Create a custom field & check that initial value is null + cf = CustomField.objects.create( + name='object_field', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + required=False + ) + cf.content_types.set([self.object_type]) + instance = Site.objects.first() + self.assertIsNone(instance.custom_field_data[cf.name]) + + # Assign a value and check that it is saved + instance.custom_field_data[cf.name] = value + instance.save() + instance.refresh_from_db() + self.assertEqual(instance.custom_field_data[cf.name], value) + + # Delete the stored value and check that it is now null + instance.custom_field_data.pop(cf.name) + instance.save() + instance.refresh_from_db() + self.assertIsNone(instance.custom_field_data.get(cf.name)) def test_rename_customfield(self): obj_type = ContentType.objects.get_for_model(Site) @@ -201,76 +360,116 @@ class CustomFieldAPITest(APITestCase): def setUpTestData(cls): content_type = ContentType.objects.get_for_model(Site) - # Text custom field - cls.cf_text = CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo') - cls.cf_text.save() - cls.cf_text.content_types.set([content_type]) + # Create some VLANs + vlans = ( + VLAN(name='VLAN 1', vid=1), + VLAN(name='VLAN 2', vid=2), + VLAN(name='VLAN 3', vid=3), + VLAN(name='VLAN 4', vid=4), + VLAN(name='VLAN 5', vid=5), + ) + VLAN.objects.bulk_create(vlans) - # Long text custom field - cls.cf_longtext = CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC') - cls.cf_longtext.save() - cls.cf_longtext.content_types.set([content_type]) + custom_fields = ( + CustomField(type=CustomFieldTypeChoices.TYPE_TEXT, name='text_field', default='foo'), + CustomField(type=CustomFieldTypeChoices.TYPE_LONGTEXT, name='longtext_field', default='ABC'), + CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123), + CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False), + CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), + CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'), + CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), + CustomField( + type=CustomFieldTypeChoices.TYPE_SELECT, + name='select_field', + default='Foo', + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + name='multiselect_field', + default=['Foo'], + choices=( + 'Foo', 'Bar', 'Baz' + ) + ), + CustomField( + type=CustomFieldTypeChoices.TYPE_OBJECT, + name='object_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=vlans[0].pk, + ), + CustomField( + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + name='multiobject_field', + object_type=ContentType.objects.get_for_model(VLAN), + default=[vlans[0].pk, vlans[1].pk], + ), + ) + for cf in custom_fields: + cf.save() + cf.content_types.set([content_type]) - # Integer custom field - cls.cf_integer = CustomField(type=CustomFieldTypeChoices.TYPE_INTEGER, name='number_field', default=123) - cls.cf_integer.save() - cls.cf_integer.content_types.set([content_type]) - - # Boolean custom field - cls.cf_boolean = CustomField(type=CustomFieldTypeChoices.TYPE_BOOLEAN, name='boolean_field', default=False) - cls.cf_boolean.save() - cls.cf_boolean.content_types.set([content_type]) - - # Date custom field - cls.cf_date = CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01') - cls.cf_date.save() - cls.cf_date.content_types.set([content_type]) - - # URL custom field - cls.cf_url = CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1') - cls.cf_url.save() - cls.cf_url.content_types.set([content_type]) - - # JSON custom field - cls.cf_json = CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}') - cls.cf_json.save() - cls.cf_json.content_types.set([content_type]) - - # Select custom field - cls.cf_select = CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', choices=['Foo', 'Bar', 'Baz']) - cls.cf_select.default = 'Foo' - cls.cf_select.save() - cls.cf_select.content_types.set([content_type]) - - # Create some sites - cls.sites = ( + # Create some sites *after* creating the custom fields. This ensures that + # default values are not set for the assigned objects. + sites = ( Site(name='Site 1', slug='site-1'), Site(name='Site 2', slug='site-2'), ) - Site.objects.bulk_create(cls.sites) + Site.objects.bulk_create(sites) # Assign custom field values for site 2 - cls.sites[1].custom_field_data = { - cls.cf_text.name: 'bar', - cls.cf_longtext.name: 'DEF', - cls.cf_integer.name: 456, - cls.cf_boolean.name: True, - cls.cf_date.name: '2020-01-02', - cls.cf_url.name: 'http://example.com/2', - cls.cf_json.name: '{"foo": 1, "bar": 2}', - cls.cf_select.name: 'Bar', + sites[1].custom_field_data = { + custom_fields[0].name: 'bar', + custom_fields[1].name: 'DEF', + custom_fields[2].name: 456, + custom_fields[3].name: True, + custom_fields[4].name: '2020-01-02', + custom_fields[5].name: 'http://example.com/2', + custom_fields[6].name: '{"foo": 1, "bar": 2}', + custom_fields[7].name: 'Bar', + custom_fields[8].name: ['Bar', 'Baz'], + custom_fields[9].name: vlans[1].pk, + custom_fields[10].name: [vlans[2].pk, vlans[3].pk], } - cls.sites[1].save() + sites[1].save() + + def test_get_custom_fields(self): + TYPES = { + CustomFieldTypeChoices.TYPE_TEXT: 'string', + CustomFieldTypeChoices.TYPE_LONGTEXT: 'string', + CustomFieldTypeChoices.TYPE_INTEGER: 'integer', + CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean', + CustomFieldTypeChoices.TYPE_DATE: 'string', + CustomFieldTypeChoices.TYPE_URL: 'string', + CustomFieldTypeChoices.TYPE_JSON: 'object', + CustomFieldTypeChoices.TYPE_SELECT: 'string', + CustomFieldTypeChoices.TYPE_MULTISELECT: 'array', + CustomFieldTypeChoices.TYPE_OBJECT: 'object', + CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array', + } + + self.add_permissions('extras.view_customfield') + url = reverse('extras-api:customfield-list') + response = self.client.get(url, **self.header) + self.assertEqual(response.data['count'], len(TYPES)) + + # Validate data types + for customfield in response.data['results']: + cf_type = customfield['type']['value'] + self.assertEqual(customfield['data_type'], TYPES[cf_type]) def test_get_single_object_without_custom_field_data(self): """ Validate that custom fields are present on an object even if it has no values defined. """ - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[0].pk}) + site1 = Site.objects.get(name='Site 1') + url = reverse('dcim-api:site-detail', kwargs={'pk': site1.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[0].name) + self.assertEqual(response.data['name'], site1.name) self.assertEqual(response.data['custom_fields'], { 'text_field': None, 'longtext_field': None, @@ -279,19 +478,23 @@ class CustomFieldAPITest(APITestCase): 'date_field': None, 'url_field': None, 'json_field': None, - 'choice_field': None, + 'select_field': None, + 'multiselect_field': None, + 'object_field': None, + 'multiobject_field': None, }) def test_get_single_object_with_custom_field_data(self): """ Validate that custom fields are present and correctly set for an object with values defined. """ - site2_cfvs = self.sites[1].custom_field_data - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + site2_cfvs = site2.custom_field_data + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.view_site') response = self.client.get(url, **self.header) - self.assertEqual(response.data['name'], self.sites[1].name) + self.assertEqual(response.data['name'], site2.name) self.assertEqual(response.data['custom_fields']['text_field'], site2_cfvs['text_field']) self.assertEqual(response.data['custom_fields']['longtext_field'], site2_cfvs['longtext_field']) self.assertEqual(response.data['custom_fields']['number_field'], site2_cfvs['number_field']) @@ -299,12 +502,21 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) self.assertEqual(response.data['custom_fields']['url_field'], site2_cfvs['url_field']) self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field']) - self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field']) + self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field']) + self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field']) + self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + site2_cfvs['multiobject_field'] + ) def test_create_single_object_with_defaults(self): """ Create a new site with no specified custom field values and check that it received the default values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = { 'name': 'Site 3', 'slug': 'site-3', @@ -317,25 +529,34 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response.data['custom_fields']['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_single_object_with_values(self): """ @@ -352,7 +573,10 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], + 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), }, } url = reverse('dcim-api:site-list') @@ -371,7 +595,13 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['url_field'], data_cf['url_field']) self.assertEqual(response_cf['json_field'], data_cf['json_field']) - self.assertEqual(response_cf['choice_field'], data_cf['choice_field']) + self.assertEqual(response_cf['select_field'], data_cf['select_field']) + self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], data_cf['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + data_cf['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data['id']) @@ -382,13 +612,19 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field']) self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) def test_create_multiple_objects_with_defaults(self): """ - Create three news sites with no specified custom field values and check that each received + Create three new sites with no specified custom field values and check that each received the default custom field values. """ + cf_defaults = { + cf.name: cf.default for cf in CustomField.objects.all() + } data = ( { 'name': 'Site 3', @@ -414,25 +650,34 @@ class CustomFieldAPITest(APITestCase): # Validate response data response_cf = response.data[i]['custom_fields'] - self.assertEqual(response_cf['text_field'], self.cf_text.default) - self.assertEqual(response_cf['longtext_field'], self.cf_longtext.default) - self.assertEqual(response_cf['number_field'], self.cf_integer.default) - self.assertEqual(response_cf['boolean_field'], self.cf_boolean.default) - self.assertEqual(response_cf['date_field'], self.cf_date.default) - self.assertEqual(response_cf['url_field'], self.cf_url.default) - self.assertEqual(response_cf['json_field'], self.cf_json.default) - self.assertEqual(response_cf['choice_field'], self.cf_select.default) + self.assertEqual(response_cf['text_field'], cf_defaults['text_field']) + self.assertEqual(response_cf['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(response_cf['number_field'], cf_defaults['number_field']) + self.assertEqual(response_cf['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(response_cf['date_field'], cf_defaults['date_field']) + self.assertEqual(response_cf['url_field'], cf_defaults['url_field']) + self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) + self.assertEqual(response_cf['select_field'], cf_defaults['select_field']) + self.assertEqual(response_cf['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], cf_defaults['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + cf_defaults['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) - self.assertEqual(site.custom_field_data['text_field'], self.cf_text.default) - self.assertEqual(site.custom_field_data['longtext_field'], self.cf_longtext.default) - self.assertEqual(site.custom_field_data['number_field'], self.cf_integer.default) - self.assertEqual(site.custom_field_data['boolean_field'], self.cf_boolean.default) - self.assertEqual(str(site.custom_field_data['date_field']), self.cf_date.default) - self.assertEqual(site.custom_field_data['url_field'], self.cf_url.default) - self.assertEqual(site.custom_field_data['json_field'], self.cf_json.default) - self.assertEqual(site.custom_field_data['choice_field'], self.cf_select.default) + self.assertEqual(site.custom_field_data['text_field'], cf_defaults['text_field']) + self.assertEqual(site.custom_field_data['longtext_field'], cf_defaults['longtext_field']) + self.assertEqual(site.custom_field_data['number_field'], cf_defaults['number_field']) + self.assertEqual(site.custom_field_data['boolean_field'], cf_defaults['boolean_field']) + self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_field']) + self.assertEqual(site.custom_field_data['url_field'], cf_defaults['url_field']) + self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) + self.assertEqual(site.custom_field_data['select_field'], cf_defaults['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], cf_defaults['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], cf_defaults['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) def test_create_multiple_objects_with_values(self): """ @@ -446,7 +691,10 @@ class CustomFieldAPITest(APITestCase): 'date_field': '2020-01-02', 'url_field': 'http://example.com/2', 'json_field': '{"foo": 1, "bar": 2}', - 'choice_field': 'Bar', + 'select_field': 'Bar', + 'multiselect_field': ['Bar', 'Baz'], + 'object_field': VLAN.objects.get(vid=2).pk, + 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), } data = ( { @@ -483,7 +731,13 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) self.assertEqual(response_cf['url_field'], custom_field_data['url_field']) self.assertEqual(response_cf['json_field'], custom_field_data['json_field']) - self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field']) + self.assertEqual(response_cf['select_field'], custom_field_data['select_field']) + self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + custom_field_data['multiobject_field'] + ) # Validate database data site = Site.objects.get(pk=response.data[i]['id']) @@ -494,22 +748,25 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field']) self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field']) + self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field']) + self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field']) + self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field']) + self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field']) def test_update_single_object_with_values(self): """ Update an object with existing custom field values. Ensure that only the updated custom field values are modified. """ - site = self.sites[1] - original_cfvs = {**site.custom_field_data} + site2 = Site.objects.get(name='Site 2') + original_cfvs = {**site2.custom_field_data} data = { 'custom_fields': { 'text_field': 'ABCD', 'number_field': 1234, }, } - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') response = self.client.patch(url, data, format='json', **self.header) @@ -524,26 +781,37 @@ class CustomFieldAPITest(APITestCase): self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['url_field'], original_cfvs['url_field']) self.assertEqual(response_cf['json_field'], original_cfvs['json_field']) - self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field']) + self.assertEqual(response_cf['select_field'], original_cfvs['select_field']) + self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field']) + self.assertEqual( + [obj['id'] for obj in response_cf['multiobject_field']], + original_cfvs['multiobject_field'] + ) # Validate database data - site.refresh_from_db() - self.assertEqual(site.custom_field_data['text_field'], data['custom_fields']['text_field']) - self.assertEqual(site.custom_field_data['number_field'], data['custom_fields']['number_field']) - self.assertEqual(site.custom_field_data['longtext_field'], original_cfvs['longtext_field']) - self.assertEqual(site.custom_field_data['boolean_field'], original_cfvs['boolean_field']) - self.assertEqual(site.custom_field_data['date_field'], original_cfvs['date_field']) - self.assertEqual(site.custom_field_data['url_field'], original_cfvs['url_field']) - self.assertEqual(site.custom_field_data['json_field'], original_cfvs['json_field']) - self.assertEqual(site.custom_field_data['choice_field'], original_cfvs['choice_field']) + site2.refresh_from_db() + self.assertEqual(site2.custom_field_data['text_field'], data['custom_fields']['text_field']) + self.assertEqual(site2.custom_field_data['number_field'], data['custom_fields']['number_field']) + self.assertEqual(site2.custom_field_data['longtext_field'], original_cfvs['longtext_field']) + self.assertEqual(site2.custom_field_data['boolean_field'], original_cfvs['boolean_field']) + self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_field']) + self.assertEqual(site2.custom_field_data['url_field'], original_cfvs['url_field']) + self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) + self.assertEqual(site2.custom_field_data['select_field'], original_cfvs['select_field']) + self.assertEqual(site2.custom_field_data['multiselect_field'], original_cfvs['multiselect_field']) + self.assertEqual(site2.custom_field_data['object_field'], original_cfvs['object_field']) + self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field']) def test_minimum_maximum_values_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_integer.validation_minimum = 10 - self.cf_integer.validation_maximum = 20 - self.cf_integer.save() + cf_integer = CustomField.objects.get(name='number_field') + cf_integer.validation_minimum = 10 + cf_integer.validation_maximum = 20 + cf_integer.save() data = {'custom_fields': {'number_field': 9}} response = self.client.patch(url, data, format='json', **self.header) @@ -558,11 +826,13 @@ class CustomFieldAPITest(APITestCase): self.assertHttpStatus(response, status.HTTP_200_OK) def test_regex_validation(self): - url = reverse('dcim-api:site-detail', kwargs={'pk': self.sites[1].pk}) + site2 = Site.objects.get(name='Site 2') + url = reverse('dcim-api:site-detail', kwargs={'pk': site2.pk}) self.add_permissions('dcim.change_site') - self.cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters - self.cf_text.save() + cf_text = CustomField.objects.get(name='text_field') + cf_text.validation_regex = r'^[A-Z]{3}$' # Three uppercase letters + cf_text.save() data = {'custom_fields': {'text_field': 'ABC123'}} response = self.client.patch(url, data, format='json', **self.header) @@ -597,6 +867,9 @@ class CustomFieldImportTest(TestCase): CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ 'Choice A', 'Choice B', 'Choice C', ]), + CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[ + 'Choice A', 'Choice B', 'Choice C', + ]), ) for cf in custom_fields: cf.save() @@ -607,19 +880,20 @@ class CustomFieldImportTest(TestCase): Import a Site in CSV format, including a value for each CustomField. """ data = ( - ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), - ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'), - ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'), - ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''), + ('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'), + ('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'), + ('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'), + ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''), ) csv_data = '\n'.join(','.join(row) for row in data) response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) self.assertEqual(response.status_code, 200) + self.assertEqual(Site.objects.count(), 3) # Validate data for site 1 site1 = Site.objects.get(name='Site 1') - self.assertEqual(len(site1.custom_field_data), 8) + self.assertEqual(len(site1.custom_field_data), 9) self.assertEqual(site1.custom_field_data['text'], 'ABC') self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['integer'], 123) @@ -628,10 +902,11 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') self.assertEqual(site1.custom_field_data['json'], {"foo": 123}) self.assertEqual(site1.custom_field_data['select'], 'Choice A') + self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B']) # Validate data for site 2 site2 = Site.objects.get(name='Site 2') - self.assertEqual(len(site2.custom_field_data), 8) + self.assertEqual(len(site2.custom_field_data), 9) self.assertEqual(site2.custom_field_data['text'], 'DEF') self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['integer'], 456) @@ -640,6 +915,7 @@ class CustomFieldImportTest(TestCase): self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') self.assertEqual(site2.custom_field_data['json'], {"bar": 456}) self.assertEqual(site2.custom_field_data['select'], 'Choice B') + self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C']) # No custom field data should be set for site 3 site3 = Site.objects.get(name='Site 3') diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index a5f77afa9..3a08055cb 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 1', content_type=content_types[0], + enabled=True, weight=100, new_window=False, link_text='Link 1', @@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 2', content_type=content_types[1], + enabled=True, weight=200, new_window=False, link_text='Link 1', @@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): CustomLink( name='Custom Link 3', content_type=content_types[2], + enabled=False, weight=300, new_window=True, link_text='Link 1', @@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests): params = {'weight': [100, 200]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_enabled(self): + params = {'enabled': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'enabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + def test_new_window(self): params = {'new_window': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/extras/tests/test_forms.py b/netbox/extras/tests/test_forms.py index cf28a46e7..1ec50b7dd 100644 --- a/netbox/extras/tests/test_forms.py +++ b/netbox/extras/tests/test_forms.py @@ -38,10 +38,27 @@ class CustomFieldModelFormTest(TestCase): cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES) cf_select.content_types.set([obj_type]) - cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, - choices=CHOICES) + cf_multiselect = CustomField.objects.create( + name='multiselect', + type=CustomFieldTypeChoices.TYPE_MULTISELECT, + choices=CHOICES + ) cf_multiselect.content_types.set([obj_type]) + cf_object = CustomField.objects.create( + name='object', + type=CustomFieldTypeChoices.TYPE_OBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_object.content_types.set([obj_type]) + + cf_multiobject = CustomField.objects.create( + name='multiobject', + type=CustomFieldTypeChoices.TYPE_MULTIOBJECT, + object_type=ContentType.objects.get_for_model(Site) + ) + cf_multiobject.content_types.set([obj_type]) + def test_empty_values(self): """ Test that empty custom field values are stored as null diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index 34d5cb67e..ea3a952d6 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): site_ct = ContentType.objects.get_for_model(Site) CustomLink.objects.bulk_create(( - CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'), - CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'), - CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'), + CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'), + CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'), + CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'), )) cls.form_data = { 'name': 'Custom Link X', 'content_type': site_ct.pk, + 'enabled': False, 'weight': 100, 'button_class': CustomLinkButtonClassChoices.DEFAULT, 'link_text': 'Link X', @@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "name,content_type,weight,button_class,link_text,link_url", - "Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", - "Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", - "Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", + "name,content_type,enabled,weight,button_class,link_text,link_url", + "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4", + "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5", + "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6", ) cls.bulk_edit_data = { 'button_class': CustomLinkButtonClassChoices.CYAN, + 'enabled': False, 'weight': 200, } diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index ace49cce5..e16807821 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,3 @@ -import collections - from django.db.models import Q from django.utils.deconstruct import deconstructible from taggit.managers import _TaggableManager @@ -57,21 +55,9 @@ class FeatureQuery: return query -def extras_features(*features): - """ - Decorator used to register extras provided features to a model - """ - def wrapper(model_class): - # Initialize the model_features store if not already defined - if 'model_features' not in registry: - registry['model_features'] = { - f: collections.defaultdict(list) for f in EXTRAS_FEATURES - } - for feature in features: - if feature in EXTRAS_FEATURES: - app_label, model_name = model_class._meta.label_lower.split('.') - registry['model_features'][feature][app_label].append(model_name) - else: - raise ValueError('{} is not a valid extras feature!'.format(feature)) - return model_class - return wrapper +def register_features(model, features): + for feature in features: + if feature not in EXTRAS_FEATURES: + raise ValueError(f"{feature} is not a valid extras feature!") + app_label, model_name = model._meta.label_lower.split('.') + registry['model_features'][feature][app_label].add(model_name) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 0df4d6905..0c59ae874 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -11,7 +11,7 @@ from rq import Worker from netbox.views import generic from utilities.forms import ConfirmationForm from utilities.htmx import is_htmx -from utilities.tables import paginate_table +from netbox.tables import configure_table from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.views import ContentTypePermissionRequiredMixin from . import filtersets, forms, tables @@ -215,7 +215,7 @@ class TagView(generic.ObjectView): data=tagged_items, orderable=False ) - paginate_table(taggeditem_table, request) + configure_table(taggeditem_table, request) object_types = [ { @@ -451,7 +451,7 @@ class ObjectChangeLogView(View): data=objectchanges, orderable=False ) - paginate_table(objectchanges_table, request) + configure_table(objectchanges_table, request) # Default to using "/.html" as the template, if it exists. Otherwise, # fall back to using base.html. @@ -571,7 +571,7 @@ class ObjectJournalView(View): assigned_object_id=obj.pk ) journalentry_table = tables.ObjectJournalTable(journalentries) - paginate_table(journalentry_table, request) + configure_table(journalentry_table, request) if request.user.has_perm('extras.add_journalentry'): form = forms.JournalEntryForm( diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 1f0a66b8a..7e8965182 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -67,7 +67,7 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user # Prepare the HTTP request params = { 'method': webhook.http_method, - 'url': webhook.payload_url, + 'url': webhook.render_payload_url(context), 'headers': headers, 'data': body.encode('utf8'), } diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 1eb66743b..5f9e09049 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/netbox/ipam/api/nested_serializers.py @@ -15,6 +15,7 @@ __all__ = [ 'NestedRoleSerializer', 'NestedRouteTargetSerializer', 'NestedServiceSerializer', + 'NestedServiceTemplateSerializer', 'NestedVLANGroupSerializer', 'NestedVLANSerializer', 'NestedVRFSerializer', @@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer): # Services # +class NestedServiceTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') + + class Meta: + model = models.ServiceTemplate + fields = ['id', 'url', 'display', 'name', 'protocol', 'ports'] + + class NestedServiceSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c028a3d5d..f71d3958a 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer): # Services # +class ServiceTemplateSerializer(PrimaryModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail') + protocol = ChoiceField(choices=ServiceProtocolChoices, required=False) + + class Meta: + model = ServiceTemplate + fields = [ + 'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created', + 'last_updated', + ] + + class ServiceSerializer(PrimaryModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') device = NestedDeviceSerializer(required=False, allow_null=True) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 3d69e258e..8a68db9be 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet) router.register('vlans', views.VLANViewSet) # Services +router.register('service-templates', views.ServiceTemplateViewSet) router.register('services', views.ServiceViewSet) app_name = 'ipam-api' diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index de415cd81..357937855 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet): filterset_class = filtersets.VLANFilterSet -class ServiceViewSet(ModelViewSet): +class ServiceTemplateViewSet(CustomFieldModelViewSet): + queryset = ServiceTemplate.objects.prefetch_related('tags') + serializer_class = serializers.ServiceTemplateSerializer + filterset_class = filtersets.ServiceTemplateFilterSet + + +class ServiceViewSet(CustomFieldModelViewSet): queryset = Service.objects.prefetch_related( 'device', 'virtual_machine', 'tags', 'ipaddresses' ) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index b19d4061b..ab88dfc1a 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = { FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, + FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP, } diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 8a10a7b24..207b5e2dc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -6,8 +6,7 @@ from django.db.models import Q from netaddr.core import AddrFormatError from dcim.models import Device, Interface, Region, Site, SiteGroup -from extras.filters import TagFilter -from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, PrimaryModelFilterSet +from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -29,13 +28,14 @@ __all__ = ( 'RoleFilterSet', 'RouteTargetFilterSet', 'ServiceFilterSet', + 'ServiceTemplateFilterSet', 'VLANFilterSet', 'VLANGroupFilterSet', 'VRFFilterSet', ) -class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VRFFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -62,7 +62,6 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='name', label='Export target (name)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -78,7 +77,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet): fields = ['id', 'name', 'rd', 'enforce_unique'] -class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class RouteTargetFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -105,7 +104,6 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='rd', label='Export VRF (RD)', ) - tag = TagFilter() def search(self, queryset, name, value): if not value.strip(): @@ -121,14 +119,13 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RIRFilterSet(OrganizationalModelFilterSet): - tag = TagFilter() class Meta: model = RIR fields = ['id', 'name', 'slug', 'is_private', 'description'] -class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class AggregateFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -151,7 +148,6 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet): to_field_name='slug', label='RIR (slug)', ) - tag = TagFilter() class Meta: model = Aggregate @@ -217,14 +213,13 @@ class RoleFilterSet(OrganizationalModelFilterSet): method='search', label='Search', ) - tag = TagFilter() class Meta: model = Role fields = ['id', 'name', 'slug'] -class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -346,7 +341,6 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): choices=PrefixStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = Prefix @@ -415,7 +409,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): ) -class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): +class IPRangeFilterSet(TenancyFilterSet, NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -452,7 +446,6 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): choices=IPRangeStatusChoices, null_value=None ) - tag = TagFilter() class Meta: model = IPRange @@ -482,7 +475,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet): return queryset.none() -class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -577,7 +570,6 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): role = django_filters.MultipleChoiceFilter( choices=IPAddressRoleChoices ) - tag = TagFilter() class Meta: model = IPAddress @@ -648,7 +640,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.exclude(assigned_object_id__isnull=value) -class FHRPGroupFilterSet(PrimaryModelFilterSet): +class FHRPGroupFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -663,7 +655,6 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet): queryset=IPAddress.objects.all(), method='filter_related_ip' ) - tag = TagFilter() class Meta: model = FHRPGroup @@ -736,7 +727,6 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): cluster = django_filters.NumberFilter( method='filter_scope' ) - tag = TagFilter() class Meta: model = VLANGroup @@ -758,7 +748,7 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet): ) -class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): +class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -831,7 +821,6 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): queryset=VirtualMachine.objects.all(), method='get_for_virtualmachine' ) - tag = TagFilter() class Meta: model = VLAN @@ -854,7 +843,28 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet): return queryset.get_for_virtualmachine(value) -class ServiceFilterSet(PrimaryModelFilterSet): +class ServiceTemplateFilterSet(NetBoxModelFilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) + port = NumericArrayFilter( + field_name='ports', + lookup_expr='contains' + ) + + class Meta: + model = ServiceTemplate + fields = ['id', 'name', 'protocol'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) + + +class ServiceFilterSet(NetBoxModelFilterSet): q = django_filters.CharFilter( method='search', label='Search', @@ -883,7 +893,6 @@ class ServiceFilterSet(PrimaryModelFilterSet): field_name='ports', lookup_expr='contains' ) - tag = TagFilter() class Meta: model = Service diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 1e25a1090..308a467d1 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -23,6 +23,7 @@ __all__ = ( 'RoleBulkEditForm', 'RouteTargetBulkEditForm', 'ServiceBulkEditForm', + 'ServiceTemplateBulkEditForm', 'VLANBulkEditForm', 'VLANGroupBulkEditForm', 'VRFBulkEditForm', @@ -433,9 +434,9 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): ] -class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): +class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): pk = forms.ModelMultipleChoiceField( - queryset=Service.objects.all(), + queryset=ServiceTemplate.objects.all(), widget=forms.MultipleHiddenInput() ) protocol = forms.ChoiceField( @@ -459,3 +460,10 @@ class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): nullable_fields = [ 'description', ] + + +class ServiceBulkEditForm(ServiceTemplateBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Service.objects.all(), + widget=forms.MultipleHiddenInput() + ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index a4fdaa3ae..1ae977fe5 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -21,6 +21,7 @@ __all__ = ( 'RoleCSVForm', 'RouteTargetCSVForm', 'ServiceCSVForm', + 'ServiceTemplateCSVForm', 'VLANCSVForm', 'VLANGroupCSVForm', 'VRFCSVForm', @@ -392,6 +393,17 @@ class VLANCSVForm(CustomFieldModelCSVForm): } +class ServiceTemplateCSVForm(CustomFieldModelCSVForm): + protocol = CSVChoiceField( + choices=ServiceProtocolChoices, + help_text='IP protocol' + ) + + class Meta: + model = ServiceTemplate + fields = ('name', 'protocol', 'ports', 'description') + + class ServiceCSVForm(CustomFieldModelCSVForm): device = CSVModelChoiceField( queryset=Device.objects.all(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index df95bdd05..9bfb1df10 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -24,6 +24,7 @@ __all__ = ( 'RoleFilterForm', 'RouteTargetFilterForm', 'ServiceFilterForm', + 'ServiceTemplateFilterForm', 'VLANFilterForm', 'VLANGroupFilterForm', 'VRFFilterForm', @@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): tag = TagFilterField(model) -class ServiceFilterForm(CustomFieldModelFilterForm): - model = Service +class ServiceTemplateFilterForm(CustomFieldModelFilterForm): + model = ServiceTemplate field_groups = ( ('q', 'tag'), ('protocol', 'port'), @@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm): required=False, ) tag = TagFilterField(model) + + +class ServiceFilterForm(ServiceTemplateFilterForm): + model = Service diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 68eac5456..34c67773f 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -31,6 +31,8 @@ __all__ = ( 'RoleForm', 'RouteTargetForm', 'ServiceForm', + 'ServiceCreateForm', + 'ServiceTemplateForm', 'VLANForm', 'VLANGroupForm', 'VRFForm', @@ -580,7 +582,7 @@ class FHRPGroupForm(CustomFieldModelForm): vrf=self.cleaned_data['ip_vrf'], address=self.cleaned_data['ip_address'], status=self.cleaned_data['ip_status'], - role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']], + role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP), assigned_object=instance ) ipaddress.save() @@ -592,6 +594,8 @@ class FHRPGroupForm(CustomFieldModelForm): return instance def clean(self): + super().clean() + ip_vrf = self.cleaned_data.get('ip_vrf') ip_address = self.cleaned_data.get('ip_address') ip_status = self.cleaned_data.get('ip_status') @@ -628,8 +632,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm): class VLANGroupForm(CustomFieldModelForm): scope_type = ContentTypeChoiceField( queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False, - widget=StaticSelect + required=False ) region = DynamicModelChoiceField( queryset=Region.objects.all(), @@ -814,6 +817,27 @@ class VLANForm(TenancyForm, CustomFieldModelForm): } +class ServiceTemplateForm(CustomFieldModelForm): + ports = NumericArrayField( + base_field=forms.IntegerField( + min_value=SERVICE_PORT_MIN, + max_value=SERVICE_PORT_MAX + ), + help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen." + ) + tags = DynamicModelMultipleChoiceField( + queryset=Tag.objects.all(), + required=False + ) + + class Meta: + model = ServiceTemplate + fields = ('name', 'protocol', 'ports', 'description', 'tags') + widgets = { + 'protocol': StaticSelect(), + } + + class ServiceForm(CustomFieldModelForm): device = DynamicModelChoiceField( queryset=Device.objects.all(), @@ -857,3 +881,36 @@ class ServiceForm(CustomFieldModelForm): 'protocol': StaticSelect(), 'ipaddresses': StaticSelectMultiple(), } + + +class ServiceCreateForm(ServiceForm): + service_template = DynamicModelChoiceField( + queryset=ServiceTemplate.objects.all(), + required=False + ) + + class Meta(ServiceForm.Meta): + fields = [ + 'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description', + 'tags', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Fields which may be populated from a ServiceTemplate are not required + for field in ('name', 'protocol', 'ports'): + self.fields[field].required = False + del(self.fields[field].widget.attrs['required']) + + def clean(self): + if self.cleaned_data['service_template']: + # Create a new Service from the specified template + service_template = self.cleaned_data['service_template'] + self.cleaned_data['name'] = service_template.name + self.cleaned_data['protocol'] = service_template.protocol + self.cleaned_data['ports'] = service_template.ports + if not self.cleaned_data['description']: + self.cleaned_data['description'] = service_template.description + elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): + raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.") diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 9609d1434..f466c1857 100644 --- a/netbox/ipam/graphql/schema.py +++ b/netbox/ipam/graphql/schema.py @@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType): service = ObjectField(ServiceType) service_list = ObjectListField(ServiceType) + service_template = ObjectField(ServiceTemplateType) + service_template_list = ObjectListField(ServiceTemplateType) + fhrp_group = ObjectField(FHRPGroupType) fhrp_group_list = ObjectListField(FHRPGroupType) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index d9aec66b3..8dd122a0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -16,6 +16,7 @@ __all__ = ( 'RoleType', 'RouteTargetType', 'ServiceType', + 'ServiceTemplateType', 'VLANType', 'VLANGroupType', 'VRFType', @@ -120,6 +121,14 @@ class ServiceType(PrimaryObjectType): filterset_class = filtersets.ServiceFilterSet +class ServiceTemplateType(PrimaryObjectType): + + class Meta: + model = models.ServiceTemplate + fields = '__all__' + filterset_class = filtersets.ServiceTemplateFilterSet + + class VLANType(PrimaryObjectType): class Meta: diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py new file mode 100644 index 000000000..738317907 --- /dev/null +++ b/netbox/ipam/migrations/0055_servicetemplate.py @@ -0,0 +1,33 @@ +import django.contrib.postgres.fields +import django.core.serializers.json +import django.core.validators +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0070_customlink_enabled'), + ('ipam', '0054_vlangroup_min_max_vids'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceTemplate', + 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)), + ('protocol', models.CharField(max_length=50)), + ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)), + ('description', models.CharField(blank=True, max_length=200)), + ('name', models.CharField(max_length=100, unique=True)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'ordering': ('name',), + }, + ), + ] diff --git a/netbox/ipam/migrations/0056_standardize_id_fields.py b/netbox/ipam/migrations/0056_standardize_id_fields.py new file mode 100644 index 000000000..cb7564450 --- /dev/null +++ b/netbox/ipam/migrations/0056_standardize_id_fields.py @@ -0,0 +1,99 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0055_servicetemplate'), + ] + + operations = [ + # Model IDs + migrations.AlterField( + model_name='aggregate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='asn', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='fhrpgroupassignment', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='ipaddress', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='iprange', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='prefix', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='rir', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='role', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='routetarget', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='service', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='servicetemplate', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlan', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vlangroup', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + migrations.AlterField( + model_name='vrf', + name='id', + field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False), + ), + + # GFK IDs + migrations.AlterField( + model_name='fhrpgroupassignment', + name='interface_id', + field=models.PositiveBigIntegerField(), + ), + migrations.AlterField( + model_name='ipaddress', + name='assigned_object_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ab0e4b6ca..1857b7d66 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -16,6 +16,7 @@ __all__ = ( 'Role', 'RouteTarget', 'Service', + 'ServiceTemplate', 'VLAN', 'VLANGroup', 'VRF', diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 9e721c65f..2a8d1bdcd 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features -from netbox.models import ChangeLoggedModel, PrimaryModel +from netbox.models import ChangeLoggedModel, NetBoxModel +from netbox.models.features import WebhooksMixin from ipam.choices import * from ipam.constants import * @@ -15,8 +15,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class FHRPGroup(PrimaryModel): +class FHRPGroup(NetBoxModel): """ A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) """ @@ -70,13 +69,12 @@ class FHRPGroup(PrimaryModel): return reverse('ipam:fhrpgroup', args=[self.pk]) -@extras_features('webhooks') -class FHRPGroupAssignment(ChangeLoggedModel): +class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel): interface_type = models.ForeignKey( to=ContentType, on_delete=models.CASCADE ) - interface_id = models.PositiveIntegerField() + interface_id = models.PositiveBigIntegerField() interface = GenericForeignKey( ct_field='interface_type', fk_field='interface_id' diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 13ae0f54f..1354c6e64 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -9,8 +9,7 @@ from django.utils.functional import cached_property from dcim.fields import ASNField from dcim.models import Device -from extras.utils import extras_features -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from ipam.choices import * from ipam.constants import * from ipam.fields import IPNetworkField, IPAddressField @@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin: return available_prefixes.iter_cidrs()[0] -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class RIR(OrganizationalModel): """ A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address @@ -90,8 +88,7 @@ class RIR(OrganizationalModel): return reverse('ipam:rir', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class ASN(PrimaryModel): +class ASN(NetBoxModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. @@ -125,14 +122,32 @@ class ASN(PrimaryModel): verbose_name_plural = 'ASNs' def __str__(self): - return f'AS{self.asn}' + return f'AS{self.asn_with_asdot}' def get_absolute_url(self): return reverse('ipam:asn', args=[self.pk]) + @property + def asn_asdot(self): + """ + Return ASDOT notation for AS numbers greater than 16 bits. + """ + if self.asn > 65535: + return f'{self.asn // 65536}.{self.asn % 65536}' + return self.asn -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): + @property + def asn_with_asdot(self): + """ + Return both plain and ASDOT notation, where applicable. + """ + if self.asn > 65535: + return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})' + else: + return self.asn + + +class Aggregate(GetAvailablePrefixesMixin, NetBoxModel): """ An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR. @@ -234,7 +249,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class Role(OrganizationalModel): """ A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or @@ -266,8 +280,7 @@ class Role(OrganizationalModel): return reverse('ipam:role', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Prefix(GetAvailablePrefixesMixin, PrimaryModel): +class Prefix(GetAvailablePrefixesMixin, NetBoxModel): """ A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be @@ -544,8 +557,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel): return min(utilization, 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class IPRange(PrimaryModel): +class IPRange(NetBoxModel): """ A range of IP addresses, defined by start and end addresses. """ @@ -740,8 +752,7 @@ class IPRange(PrimaryModel): return int(float(child_count) / self.size * 100) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class IPAddress(PrimaryModel): +class IPAddress(NetBoxModel): """ An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like @@ -790,7 +801,7 @@ class IPAddress(PrimaryModel): blank=True, null=True ) - assigned_object_id = models.PositiveIntegerField( + assigned_object_id = models.PositiveBigIntegerField( blank=True, null=True ) @@ -893,8 +904,9 @@ class IPAddress(PrimaryModel): super().save(*args, **kwargs) def to_objectchange(self, action): - # Annotate the assigned object, if any - return super().to_objectchange(action, related_object=self.assigned_object) + objectchange = super().to_objectchange(action) + objectchange.related_object = self.assigned_object + return objectchange @property def family(self): diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 5c1ebb9dd..70ad38197 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -4,20 +4,65 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel from utilities.utils import array_to_string __all__ = ( 'Service', + 'ServiceTemplate', ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class Service(PrimaryModel): +class ServiceBase(models.Model): + protocol = models.CharField( + max_length=50, + choices=ServiceProtocolChoices + ) + ports = ArrayField( + base_field=models.PositiveIntegerField( + validators=[ + MinValueValidator(SERVICE_PORT_MIN), + MaxValueValidator(SERVICE_PORT_MAX) + ] + ), + verbose_name='Port numbers' + ) + description = models.CharField( + max_length=200, + blank=True + ) + + class Meta: + abstract = True + + def __str__(self): + return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' + + @property + def port_list(self): + return array_to_string(self.ports) + + +class ServiceTemplate(ServiceBase, NetBoxModel): + """ + A template for a Service to be applied to a device or virtual machine. + """ + name = models.CharField( + max_length=100, + unique=True + ) + + class Meta: + ordering = ('name',) + + def get_absolute_url(self): + return reverse('ipam:servicetemplate', args=[self.pk]) + + +class Service(ServiceBase, NetBoxModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. @@ -40,36 +85,16 @@ class Service(PrimaryModel): name = models.CharField( max_length=100 ) - protocol = models.CharField( - max_length=50, - choices=ServiceProtocolChoices - ) - ports = ArrayField( - base_field=models.PositiveIntegerField( - validators=[ - MinValueValidator(SERVICE_PORT_MIN), - MaxValueValidator(SERVICE_PORT_MAX) - ] - ), - verbose_name='Port numbers' - ) ipaddresses = models.ManyToManyField( to='ipam.IPAddress', related_name='services', blank=True, verbose_name='IP addresses' ) - description = models.CharField( - max_length=200, - blank=True - ) class Meta: ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - def __str__(self): - return f'{self.name} ({self.get_protocol_display()}/{self.port_list})' - def get_absolute_url(self): return reverse('ipam:service', args=[self.pk]) @@ -85,7 +110,3 @@ class Service(PrimaryModel): raise ValidationError("A service cannot be associated with both a device and a virtual machine.") if not self.device and not self.virtual_machine: raise ValidationError("A service must be associated with either a device or a virtual machine.") - - @property - def port_list(self): - return array_to_string(self.ports) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 31c8da2b6..7cd03ed55 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -6,11 +6,10 @@ from django.db import models from django.urls import reverse from dcim.models import Interface -from extras.utils import extras_features from ipam.choices import * from ipam.constants import * from ipam.querysets import VLANQuerySet -from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models import OrganizationalModel, NetBoxModel from virtualization.models import VMInterface @@ -20,7 +19,6 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class VLANGroup(OrganizationalModel): """ A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. @@ -118,8 +116,7 @@ class VLANGroup(OrganizationalModel): return None -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VLAN(PrimaryModel): +class VLAN(NetBoxModel): """ A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned to a Site, however VLAN IDs need not be unique within a Site. A VLAN may optionally be assigned to a VLANGroup, diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py index 11fab9c44..fc34b5488 100644 --- a/netbox/ipam/models/vrfs.py +++ b/netbox/ipam/models/vrfs.py @@ -1,9 +1,8 @@ from django.db import models from django.urls import reverse -from extras.utils import extras_features from ipam.constants import * -from netbox.models import PrimaryModel +from netbox.models import NetBoxModel __all__ = ( @@ -12,8 +11,7 @@ __all__ = ( ) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class VRF(PrimaryModel): +class VRF(NetBoxModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing table). Prefixes and IPAddresses can optionally be assigned to VRFs. (Prefixes and IPAddresses not assigned to a VRF @@ -75,8 +73,7 @@ class VRF(PrimaryModel): return reverse('ipam:vrf', args=[self.pk]) -@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') -class RouteTarget(PrimaryModel): +class RouteTarget(NetBoxModel): """ A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. """ diff --git a/netbox/ipam/tables/fhrp.py b/netbox/ipam/tables/fhrp.py index 94bc50b93..8848cb079 100644 --- a/netbox/ipam/tables/fhrp.py +++ b/netbox/ipam/tables/fhrp.py @@ -1,7 +1,7 @@ import django_tables2 as tables -from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import NetBoxTable, columns __all__ = ( 'FHRPGroupTable', @@ -16,12 +16,11 @@ IPADDRESSES = """ """ -class FHRPGroupTable(BaseTable): - pk = ToggleColumn() +class FHRPGroupTable(NetBoxTable): group_id = tables.Column( linkify=True ) - comments = MarkdownColumn() + comments = columns.MarkdownColumn() ip_addresses = tables.TemplateColumn( template_code=IPADDRESSES, orderable=False, @@ -30,21 +29,20 @@ class FHRPGroupTable(BaseTable): interface_count = tables.Column( verbose_name='Interfaces' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:fhrpgroup_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroup fields = ( 'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count') -class FHRPGroupAssignmentTable(BaseTable): - pk = ToggleColumn() +class FHRPGroupAssignmentTable(NetBoxTable): interface_parent = tables.Column( accessor=tables.A('interface.parent_object'), linkify=True, @@ -58,12 +56,11 @@ class FHRPGroupAssignmentTable(BaseTable): group = tables.Column( linkify=True ) - actions = ButtonsColumn( - model=FHRPGroupAssignment, - buttons=('edit', 'delete', 'foo') + actions = columns.ActionsColumn( + sequence=('edit', 'delete') ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = FHRPGroupAssignment fields = ('pk', 'group', 'interface_parent', 'interface', 'priority') exclude = ('id',) diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 3fddbf48e..762857136 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -2,12 +2,9 @@ import django_tables2 as tables from django.utils.safestring import mark_safe from django_tables2.utils import Accessor -from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, - ToggleColumn, UtilizationColumn, -) from ipam.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn __all__ = ( 'AggregateTable', @@ -73,58 +70,58 @@ VRF_LINK = """ # RIRs # -class RIRTable(BaseTable): - pk = ToggleColumn() +class RIRTable(NetBoxTable): name = tables.Column( linkify=True ) - is_private = BooleanColumn( + is_private = columns.BooleanColumn( verbose_name='Private' ) - aggregate_count = LinkedCountColumn( + aggregate_count = columns.LinkedCountColumn( viewname='ipam:aggregate_list', url_params={'rir_id': 'pk'}, verbose_name='Aggregates' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:rir_list' ) - actions = ButtonsColumn(RIR) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RIR - fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') - default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created', + 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description') # # ASNs # -class ASNTable(BaseTable): - pk = ToggleColumn() +class ASNTable(NetBoxTable): asn = tables.Column( + accessor=tables.A('asn_asdot'), linkify=True ) - site_count = LinkedCountColumn( + + site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'asn_id': 'pk'}, verbose_name='Sites' ) - actions = ButtonsColumn(ASN) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = ASN - fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') - default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') + fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions') + default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant') # # Aggregates # -class AggregateTable(BaseTable): - pk = ToggleColumn() +class AggregateTable(NetBoxTable): prefix = tables.Column( linkify=True, verbose_name='Aggregate' @@ -137,17 +134,20 @@ class AggregateTable(BaseTable): child_count = tables.Column( verbose_name='Prefixes' ) - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:aggregate_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Aggregate - fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags') + fields = ( + 'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description') @@ -155,37 +155,38 @@ class AggregateTable(BaseTable): # Roles # -class RoleTable(BaseTable): - pk = ToggleColumn() +class RoleTable(NetBoxTable): name = tables.Column( linkify=True ) - prefix_count = LinkedCountColumn( + prefix_count = columns.LinkedCountColumn( viewname='ipam:prefix_list', url_params={'role_id': 'pk'}, verbose_name='Prefixes' ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'role_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:role_list' ) - actions = ButtonsColumn(Role) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Role - fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') - default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') + fields = ( + 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created', + 'last_updated', 'actions', + ) + default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description') # # Prefixes # -class PrefixUtilizationColumn(UtilizationColumn): +class PrefixUtilizationColumn(columns.UtilizationColumn): """ Extend UtilizationColumn to allow disabling the warning & danger thresholds for prefixes marked as fully utilized. @@ -200,8 +201,7 @@ class PrefixUtilizationColumn(UtilizationColumn): """ -class PrefixTable(BaseTable): - pk = ToggleColumn() +class PrefixTable(NetBoxTable): prefix = tables.TemplateColumn( template_code=PREFIX_LINK, attrs={'td': {'class': 'text-nowrap'}} @@ -215,7 +215,7 @@ class PrefixTable(BaseTable): accessor=Accessor('_depth'), verbose_name='Depth' ) - children = LinkedCountColumn( + children = columns.LinkedCountColumn( accessor=Accessor('_children'), viewname='ipam:prefix_list', url_params={ @@ -224,7 +224,7 @@ class PrefixTable(BaseTable): }, verbose_name='Children' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) vrf = tables.TemplateColumn( @@ -247,25 +247,25 @@ class PrefixTable(BaseTable): role = tables.Column( linkify=True ) - is_pool = BooleanColumn( + is_pool = columns.BooleanColumn( verbose_name='Pool' ) - mark_utilized = BooleanColumn( + mark_utilized = columns.BooleanColumn( verbose_name='Marked Utilized' ) utilization = PrefixUtilizationColumn( accessor='get_utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:prefix_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Prefix fields = ( - 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', - 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', + 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', + 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', @@ -278,8 +278,7 @@ class PrefixTable(BaseTable): # # IP ranges # -class IPRangeTable(BaseTable): - pk = ToggleColumn() +class IPRangeTable(NetBoxTable): start_address = tables.Column( linkify=True ) @@ -287,26 +286,26 @@ class IPRangeTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) tenant = TenantColumn() - utilization = UtilizationColumn( + utilization = columns.UtilizationColumn( accessor='utilization', orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:iprange_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPRange fields = ( 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', - 'utilization', 'tags', + 'utilization', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', @@ -320,8 +319,7 @@ class IPRangeTable(BaseTable): # IPAddresses # -class IPAddressTable(BaseTable): - pk = ToggleColumn() +class IPAddressTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_LINK, verbose_name='IP Address' @@ -330,10 +328,10 @@ class IPAddressTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) - role = ChoiceFieldColumn() + role = columns.ChoiceFieldColumn() tenant = TenantColumn() assigned_object = tables.Column( linkify=True, @@ -351,20 +349,20 @@ class IPAddressTable(BaseTable): orderable=False, verbose_name='NAT (Inside)' ) - assigned = BooleanColumn( + assigned = columns.BooleanColumn( accessor='assigned_object_id', linkify=True, verbose_name='Assigned' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:ipaddress_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ( 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', - 'tags', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', @@ -374,24 +372,24 @@ class IPAddressTable(BaseTable): } -class IPAddressAssignTable(BaseTable): +class IPAddressAssignTable(NetBoxTable): address = tables.TemplateColumn( template_code=IPADDRESS_ASSIGN_LINK, verbose_name='IP Address' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() assigned_object = tables.Column( orderable=False ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'dns_name', 'vrf', 'status', 'role', 'tenant', 'assigned_object', 'description') exclude = ('id', ) orderable = False -class AssignedIPAddressesTable(BaseTable): +class AssignedIPAddressesTable(NetBoxTable): """ List IP addresses assigned to an object. """ @@ -403,13 +401,10 @@ class AssignedIPAddressesTable(BaseTable): template_code=VRF_LINK, verbose_name='VRF' ) - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() tenant = TenantColumn() - actions = ButtonsColumn( - model=IPAddress - ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = IPAddress fields = ('address', 'vrf', 'status', 'role', 'tenant', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index ff6b766f7..8c81a28c2 100644 --- a/netbox/ipam/tables/services.py +++ b/netbox/ipam/tables/services.py @@ -1,19 +1,32 @@ import django_tables2 as tables -from utilities.tables import BaseTable, TagColumn, ToggleColumn from ipam.models import * +from netbox.tables import NetBoxTable, columns __all__ = ( 'ServiceTable', + 'ServiceTemplateTable', ) -# -# Services -# +class ServiceTemplateTable(NetBoxTable): + name = tables.Column( + linkify=True + ) + ports = tables.Column( + accessor=tables.A('port_list') + ) + tags = columns.TagColumn( + url_name='ipam:servicetemplate_list' + ) -class ServiceTable(BaseTable): - pk = ToggleColumn() + class Meta(NetBoxTable.Meta): + model = ServiceTemplate + fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags') + default_columns = ('pk', 'name', 'protocol', 'ports', 'description') + + +class ServiceTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -21,15 +34,17 @@ class ServiceTable(BaseTable): linkify=True, order_by=('device', 'virtual_machine') ) - ports = tables.TemplateColumn( - template_code='{{ record.port_list }}', - verbose_name='Ports' + ports = tables.Column( + accessor=tables.A('port_list') ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:service_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Service - fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags') + fields = ( + 'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created', + 'last_updated', + ) default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description') diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index ca8d22552..192da0813 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -3,13 +3,10 @@ from django.utils.safestring import mark_safe from django_tables2.utils import Accessor from dcim.models import Interface -from tenancy.tables import TenantColumn -from utilities.tables import ( - BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, - TemplateColumn, ToggleColumn, -) -from virtualization.models import VMInterface from ipam.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn +from virtualization.models import VMInterface __all__ = ( 'InterfaceVLANTable', @@ -38,7 +35,7 @@ VLAN_PREFIXES = """ {% endfor %} """ -VLANGROUP_ADD_VLAN = """ +VLANGROUP_BUTTONS = """ {% with next_vid=record.get_next_available_vid %} {% if next_vid and perms.ipam.add_vlan %} @@ -61,42 +58,39 @@ VLAN_MEMBER_TAGGED = """ # VLAN groups # -class VLANGroupTable(BaseTable): - pk = ToggleColumn() +class VLANGroupTable(NetBoxTable): name = tables.Column(linkify=True) - scope_type = ContentTypeColumn() + scope_type = columns.ContentTypeColumn() scope = tables.Column( linkify=True, orderable=False ) - vlan_count = LinkedCountColumn( + vlan_count = columns.LinkedCountColumn( viewname='ipam:vlan_list', url_params={'group_id': 'pk'}, verbose_name='VLANs' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlangroup_list' ) - actions = ButtonsColumn( - model=VLANGroup, - prepend_template=VLANGROUP_ADD_VLAN + actions = columns.ActionsColumn( + extra_buttons=VLANGROUP_BUTTONS ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLANGroup fields = ( 'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', - 'tags', 'actions', + 'tags', 'created', 'last_updated', 'actions', ) - default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions') + default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description') # # VLANs # -class VLANTable(BaseTable): - pk = ToggleColumn() +class VLANTable(NetBoxTable): vid = tables.TemplateColumn( template_code=VLAN_LINK, verbose_name='VID' @@ -111,31 +105,34 @@ class VLANTable(BaseTable): linkify=True ) tenant = TenantColumn() - status = ChoiceFieldColumn( + status = columns.ChoiceFieldColumn( default=AVAILABLE_LABEL ) role = tables.Column( linkify=True ) - prefixes = TemplateColumn( + prefixes = columns.TemplateColumn( template_code=VLAN_PREFIXES, orderable=False, verbose_name='Prefixes' ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vlan_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN - fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags') + fields = ( + 'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags', + 'created', 'last_updated', + ) default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description') row_attrs = { 'class': lambda record: 'success' if not isinstance(record, VLAN) else '', } -class VLANMembersTable(BaseTable): +class VLANMembersTable(NetBoxTable): """ Base table for Interface and VMInterface assignments """ @@ -153,9 +150,11 @@ class VLANDevicesTable(VLANMembersTable): device = tables.Column( linkify=True ) - actions = ButtonsColumn(Interface, buttons=['edit']) + actions = columns.ActionsColumn( + sequence=('edit',) + ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Interface fields = ('device', 'name', 'tagged', 'actions') exclude = ('id', ) @@ -165,15 +164,17 @@ class VLANVirtualMachinesTable(VLANMembersTable): virtual_machine = tables.Column( linkify=True ) - actions = ButtonsColumn(VMInterface, buttons=['edit']) + actions = columns.ActionsColumn( + sequence=('edit',) + ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VMInterface fields = ('virtual_machine', 'name', 'tagged', 'actions') exclude = ('id', ) -class InterfaceVLANTable(BaseTable): +class InterfaceVLANTable(NetBoxTable): """ List VLANs assigned to a specific Interface. """ @@ -181,7 +182,7 @@ class InterfaceVLANTable(BaseTable): linkify=True, verbose_name='ID' ) - tagged = BooleanColumn() + tagged = columns.BooleanColumn() site = tables.Column( linkify=True ) @@ -190,12 +191,12 @@ class InterfaceVLANTable(BaseTable): verbose_name='Group' ) tenant = TenantColumn() - status = ChoiceFieldColumn() + status = columns.ChoiceFieldColumn() role = tables.Column( linkify=True ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VLAN fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description') exclude = ('id', ) diff --git a/netbox/ipam/tables/vrfs.py b/netbox/ipam/tables/vrfs.py index 1264368f4..727f402ff 100644 --- a/netbox/ipam/tables/vrfs.py +++ b/netbox/ipam/tables/vrfs.py @@ -1,8 +1,8 @@ import django_tables2 as tables -from tenancy.tables import TenantColumn -from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn from ipam.models import * +from netbox.tables import NetBoxTable, columns +from tenancy.tables import TenantColumn __all__ = ( 'RouteTargetTable', @@ -20,8 +20,7 @@ VRF_TARGETS = """ # VRFs # -class VRFTable(BaseTable): - pk = ToggleColumn() +class VRFTable(NetBoxTable): name = tables.Column( linkify=True ) @@ -29,25 +28,26 @@ class VRFTable(BaseTable): verbose_name='RD' ) tenant = TenantColumn() - enforce_unique = BooleanColumn( + enforce_unique = columns.BooleanColumn( verbose_name='Unique' ) - import_targets = TemplateColumn( + import_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - export_targets = TemplateColumn( + export_targets = columns.TemplateColumn( template_code=VRF_TARGETS, orderable=False ) - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = VRF fields = ( - 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags', + 'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', + 'tags', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'rd', 'tenant', 'description') @@ -56,17 +56,16 @@ class VRFTable(BaseTable): # Route targets # -class RouteTargetTable(BaseTable): - pk = ToggleColumn() +class RouteTargetTable(NetBoxTable): name = tables.Column( linkify=True ) tenant = TenantColumn() - tags = TagColumn( + tags = columns.TagColumn( url_name='ipam:vrf_list' ) - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = RouteTarget - fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags') + fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',) default_columns = ('pk', 'name', 'tenant', 'description') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index dfbf1a971..d99de6d20 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -832,6 +832,41 @@ class VLANTest(APIViewTestCases.APIViewTestCase): self.assertTrue(content['detail'].startswith('Unable to delete object.')) +class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): + model = ServiceTemplate + brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + service_templates = ( + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1, 2]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3, 4]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[5, 6]), + ) + ServiceTemplate.objects.bulk_create(service_templates) + + cls.create_data = [ + { + 'name': 'Service Template 4', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [7, 8], + }, + { + 'name': 'Service Template 5', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [9, 10], + }, + { + 'name': 'Service Template 6', + 'protocol': ServiceProtocolChoices.PROTOCOL_TCP, + 'ports': [11, 12], + }, + ] + + class ServiceTest(APIViewTestCases.APIViewTestCase): model = Service brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 773737dea..d673628af 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1307,6 +1307,35 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global +class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ServiceTemplate.objects.all() + filterset = ServiceTemplateFilterSet + + @classmethod + def setUpTestData(cls): + service_templates = ( + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]), + ServiceTemplate(name='Service Template 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]), + ServiceTemplate(name='Service Template 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]), + ServiceTemplate(name='Service Template 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]), + ) + ServiceTemplate.objects.bulk_create(service_templates) + + def test_name(self): + params = {'name': ['Service Template 1', 'Service Template 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_protocol(self): + params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_port(self): + params = {'port': '1001'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Service.objects.all() filterset = ServiceFilterSet diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 1e7a72389..672cfbe08 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,5 +1,7 @@ import datetime +from django.test import override_settings +from django.urls import reverse from netaddr import IPNetwork from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_aggregate_prefixes(self): + rir = RIR.objects.first() + aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir) + prefixes = ( + Prefix(prefix=IPNetwork('192.168.1.0/24')), + Prefix(prefix=IPNetwork('192.168.2.0/24')), + Prefix(prefix=IPNetwork('192.168.3.0/24')), + ) + Prefix.objects.bulk_create(prefixes) + self.assertEqual(aggregate.get_child_prefixes().count(), 3) + + url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Role @@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_prefixes(self): + prefixes = ( + Prefix(prefix=IPNetwork('192.168.0.0/16')), + Prefix(prefix=IPNetwork('192.168.1.0/24')), + Prefix(prefix=IPNetwork('192.168.2.0/24')), + Prefix(prefix=IPNetwork('192.168.3.0/24')), + ) + Prefix.objects.bulk_create(prefixes) + self.assertEqual(prefixes[0].get_child_prefixes().count(), 3) + + url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_ipranges(self): + prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) + ip_ranges = ( + IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99), + IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99), + IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99), + ) + IPRange.objects.bulk_create(ip_ranges) + self.assertEqual(prefix.get_child_ranges().count(), 3) + + url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk}) + self.assertHttpStatus(self.client.get(url), 200) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_ipaddresses(self): + prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16')) + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.1/16')), + IPAddress(address=IPNetwork('192.168.0.2/16')), + IPAddress(address=IPNetwork('192.168.0.3/16')), + ) + IPAddress.objects.bulk_create(ip_addresses) + self.assertEqual(prefix.get_child_ips().count(), 3) + + url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange @@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'description': 'New description', } + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_iprange_ipaddresses(self): + iprange = IPRange.objects.create( + start_address=IPNetwork('192.168.0.1/24'), + end_address=IPNetwork('192.168.0.100/24'), + size=99 + ) + ip_addresses = ( + IPAddress(address=IPNetwork('192.168.0.1/24')), + IPAddress(address=IPNetwork('192.168.0.2/24')), + IPAddress(address=IPNetwork('192.168.0.3/24')), + ) + IPAddress.objects.bulk_create(ip_addresses) + self.assertEqual(iprange.get_child_ips().count(), 3) + + url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk}) + self.assertHttpStatus(self.client.get(url), 200) + class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPAddress @@ -564,6 +641,41 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase): } +class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase): + model = ServiceTemplate + + @classmethod + def setUpTestData(cls): + ServiceTemplate.objects.bulk_create([ + ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]), + ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]), + ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]), + ]) + + tags = create_tags('Alpha', 'Bravo', 'Charlie') + + cls.form_data = { + 'name': 'Service Template X', + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'ports': '104,105', + 'description': 'A new service template', + 'tags': [t.pk for t in tags], + } + + cls.csv_data = ( + "name,protocol,ports,description", + "Service Template 4,tcp,1,First service template", + "Service Template 5,tcp,2,Second service template", + "Service Template 6,tcp,3,Third service template", + ) + + cls.bulk_edit_data = { + 'protocol': ServiceProtocolChoices.PROTOCOL_UDP, + 'ports': '106,107', + 'description': 'New description', + } + + class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = Service @@ -607,3 +719,30 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'ports': '106,107', 'description': 'New description', } + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_create_from_template(self): + self.add_permissions('ipam.add_service') + + device = Device.objects.first() + service_template = ServiceTemplate.objects.create( + name='HTTP', + protocol=ServiceProtocolChoices.PROTOCOL_TCP, + ports=[80], + description='Hypertext transfer protocol' + ) + + request = { + 'path': self._get_url('add'), + 'data': { + 'device': device.pk, + 'service_template': service_template.pk, + }, + } + self.assertHttpStatus(self.client.post(**request), 302) + instance = self._get_queryset().order_by('pk').last() + self.assertEqual(instance.device, device) + self.assertEqual(instance.name, service_template.name) + self.assertEqual(instance.protocol, service_template.protocol) + self.assertEqual(instance.ports, service_template.ports) + self.assertEqual(instance.description, service_template.description) diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index a9f420253..0a4eddc6c 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -162,9 +162,21 @@ urlpatterns = [ path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans//journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), + # Service templates + path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'), + path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'), + path('service-templates/import/', views.ServiceTemplateBulkImportView.as_view(), name='servicetemplate_import'), + path('service-templates/edit/', views.ServiceTemplateBulkEditView.as_view(), name='servicetemplate_bulk_edit'), + path('service-templates/delete/', views.ServiceTemplateBulkDeleteView.as_view(), name='servicetemplate_bulk_delete'), + path('service-templates//', views.ServiceTemplateView.as_view(), name='servicetemplate'), + path('service-templates//edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'), + path('service-templates//delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'), + path('service-templates//changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}), + path('service-templates//journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}), + # Services path('services/', views.ServiceListView.as_view(), name='service_list'), - path('services/add/', views.ServiceEditView.as_view(), name='service_add'), + path('services/add/', views.ServiceCreateView.as_view(), name='service_add'), path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 1f20e886f..85c2482e5 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from dcim.tables import SiteTable from netbox.views import generic -from utilities.tables import paginate_table +from netbox.tables import configure_table from utilities.utils import count_related from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface @@ -161,7 +161,7 @@ class RIRView(generic.ObjectView): rir=instance ) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) - paginate_table(aggregates_table, request) + configure_table(aggregates_table, request) return { 'aggregates_table': aggregates_table, @@ -219,7 +219,7 @@ class ASNView(generic.ObjectView): def get_extra_context(self, request, instance): sites = instance.sites.restrict(request.user, 'view') sites_table = SiteTable(sites) - paginate_table(sites_table, request) + configure_table(sites_table, request) return { 'sites_table': sites_table, @@ -356,7 +356,7 @@ class RoleView(generic.ObjectView): ) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) - paginate_table(prefixes_table, request) + configure_table(prefixes_table, request) return { 'prefixes_table': prefixes_table, @@ -505,9 +505,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView): template_name = 'ipam/prefix/ip_addresses.html' def get_children(self, request, parent): - return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( - 'vrf', 'role', 'tenant', - ) + return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant') def prep_table_data(self, request, queryset, parent): show_available = bool(request.GET.get('show_available', 'true') == 'true') @@ -531,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView): class PrefixDeleteView(generic.ObjectDeleteView): queryset = Prefix.objects.all() - template_name = 'ipam/prefix_delete.html' class PrefixBulkImportView(generic.BulkImportView): @@ -664,7 +661,7 @@ class IPAddressView(generic.ObjectView): vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) ) related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - paginate_table(related_ips_table, request) + configure_table(related_ips_table, request) return { 'parent_prefixes_table': parent_prefixes_table, @@ -800,7 +797,7 @@ class VLANGroupView(generic.ObjectView): vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): vlans_table.columns.show('pk') - paginate_table(vlans_table, request) + configure_table(vlans_table, request) # Compile permissions list for rendering the object table permissions = { @@ -1031,6 +1028,49 @@ class VLANBulkDeleteView(generic.BulkDeleteView): table = tables.VLANTable +# +# Service templates +# + +class ServiceTemplateListView(generic.ObjectListView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + filterset_form = forms.ServiceTemplateFilterForm + table = tables.ServiceTemplateTable + + +class ServiceTemplateView(generic.ObjectView): + queryset = ServiceTemplate.objects.all() + + +class ServiceTemplateEditView(generic.ObjectEditView): + queryset = ServiceTemplate.objects.all() + model_form = forms.ServiceTemplateForm + + +class ServiceTemplateDeleteView(generic.ObjectDeleteView): + queryset = ServiceTemplate.objects.all() + + +class ServiceTemplateBulkImportView(generic.BulkImportView): + queryset = ServiceTemplate.objects.all() + model_form = forms.ServiceTemplateCSVForm + table = tables.ServiceTemplateTable + + +class ServiceTemplateBulkEditView(generic.BulkEditView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + table = tables.ServiceTemplateTable + form = forms.ServiceTemplateBulkEditForm + + +class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = ServiceTemplate.objects.all() + filterset = filtersets.ServiceTemplateFilterSet + table = tables.ServiceTemplateTable + + # # Services # @@ -1047,22 +1087,28 @@ class ServiceView(generic.ObjectView): queryset = Service.objects.prefetch_related('ipaddresses') +class ServiceCreateView(generic.ObjectEditView): + queryset = Service.objects.all() + model_form = forms.ServiceCreateForm + template_name = 'ipam/service_create.html' + + class ServiceEditView(generic.ObjectEditView): queryset = Service.objects.prefetch_related('ipaddresses') model_form = forms.ServiceForm template_name = 'ipam/service_edit.html' +class ServiceDeleteView(generic.ObjectDeleteView): + queryset = Service.objects.all() + + class ServiceBulkImportView(generic.BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable -class ServiceDeleteView(generic.ObjectDeleteView): - queryset = Service.objects.all() - - class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index f42ab064b..e36b9dd1d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -18,8 +18,8 @@ from utilities import filters __all__ = ( 'BaseFilterSet', 'ChangeLoggedModelFilterSet', + 'NetBoxModelFilterSet', 'OrganizationalModelFilterSet', - 'PrimaryModelFilterSet', ) @@ -29,7 +29,7 @@ __all__ = ( class BaseFilterSet(django_filters.FilterSet): """ - A base FilterSet which provides common functionality to all NetBox FilterSets + A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. """ FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS) FILTER_DEFAULTS.update({ @@ -120,6 +120,10 @@ class BaseFilterSet(django_filters.FilterSet): def get_additional_lookups(cls, existing_filter_name, existing_filter): new_filters = {} + # Skip on abstract models + if not cls._meta.model: + return {} + # Skip nonstandard lookup expressions if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']: return {} @@ -213,7 +217,11 @@ class ChangeLoggedModelFilterSet(BaseFilterSet): ) -class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): +class NetBoxModelFilterSet(ChangeLoggedModelFilterSet): + """ + Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models. + """ + tag = TagFilter() def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -239,7 +247,7 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet): self.filters.update(custom_field_filters) -class OrganizationalModelFilterSet(PrimaryModelFilterSet): +class OrganizationalModelFilterSet(NetBoxModelFilterSet): """ A base class for adding the search method to models which only expose the `name` and `slug` fields """ diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py new file mode 100644 index 000000000..b3bfe06c0 --- /dev/null +++ b/netbox/netbox/models/__init__.py @@ -0,0 +1,124 @@ +from django.core.validators import ValidationError +from django.db import models +from mptt.models import MPTTModel, TreeForeignKey + +from utilities.mptt import TreeManager +from utilities.querysets import RestrictedQuerySet +from netbox.models.features import * + +__all__ = ( + 'ChangeLoggedModel', + 'NestedGroupModel', + 'OrganizationalModel', + 'NetBoxModel', +) + + +class NetBoxFeatureSet( + ChangeLoggingMixin, + CustomFieldsMixin, + CustomLinksMixin, + CustomValidationMixin, + ExportTemplatesMixin, + JournalingMixin, + TagsMixin, + WebhooksMixin +): + class Meta: + abstract = True + + +# +# Base model classes +# + +class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model): + """ + Base model for ancillary models; provides limited functionality for models which don't + support NetBox's full feature set. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class NetBoxModel(NetBoxFeatureSet, models.Model): + """ + Primary models represent real objects within the infrastructure being modeled. + """ + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + + +class NestedGroupModel(NetBoxFeatureSet, MPTTModel): + """ + Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest + recursively using MPTT. Within each parent, each child instance must have a unique name. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + db_index=True + ) + name = models.CharField( + max_length=100 + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = TreeManager() + + class Meta: + abstract = True + + class MPTTMeta: + order_insertion_by = ('name',) + + def __str__(self): + return self.name + + def clean(self): + super().clean() + + # An MPTT model cannot be its own parent + if self.pk and self.parent_id == self.pk: + raise ValidationError({ + "parent": "Cannot assign self as parent." + }) + + +class OrganizationalModel(NetBoxFeatureSet, models.Model): + """ + Organizational models are those which are used solely to categorize and qualify other objects, and do not convey + any real information about the infrastructure being modeled (for example, functional device roles). Organizational + models provide the following standard attributes: + - Unique name + - Unique slug (automatically derived from name) + - Optional description + """ + name = models.CharField( + max_length=100, + unique=True + ) + slug = models.SlugField( + max_length=100, + unique=True + ) + description = models.CharField( + max_length=200, + blank=True + ) + + objects = RestrictedQuerySet.as_manager() + + class Meta: + abstract = True + ordering = ('name',) diff --git a/netbox/netbox/models.py b/netbox/netbox/models/features.py similarity index 51% rename from netbox/netbox/models.py rename to netbox/netbox/models/features.py index 91240ee90..24b9a4bff 100644 --- a/netbox/netbox/models.py +++ b/netbox/netbox/models/features.py @@ -1,35 +1,37 @@ -import logging -from collections import OrderedDict - from django.contrib.contenttypes.fields import GenericRelation +from django.db.models.signals import class_prepared +from django.dispatch import receiver + from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import ValidationError from django.db import models -from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from extras.choices import ObjectChangeActionChoices +from extras.utils import register_features from netbox.signals import post_clean -from utilities.mptt import TreeManager -from utilities.querysets import RestrictedQuerySet from utilities.utils import serialize_object __all__ = ( - 'BigIDModel', - 'ChangeLoggedModel', - 'NestedGroupModel', - 'OrganizationalModel', - 'PrimaryModel', + 'ChangeLoggingMixin', + 'CustomFieldsMixin', + 'CustomLinksMixin', + 'CustomValidationMixin', + 'ExportTemplatesMixin', + 'JobResultsMixin', + 'JournalingMixin', + 'TagsMixin', + 'WebhooksMixin', ) # -# Mixins +# Feature mixins # class ChangeLoggingMixin(models.Model): """ - Provides change logging support. + Provides change logging support for a model. Adds the `created` and `last_updated` fields. """ created = models.DateField( auto_now_add=True, @@ -49,11 +51,9 @@ class ChangeLoggingMixin(models.Model): """ Save a snapshot of the object's current state in preparation for modification. """ - logger = logging.getLogger('netbox') - logger.debug(f"Taking a snapshot of {self}") self._prechange_snapshot = serialize_object(self) - def to_objectchange(self, action, related_object=None): + def to_objectchange(self, action): """ Return a new ObjectChange representing a change made to this object. This will typically be called automatically by ChangeLoggingMiddleware. @@ -61,7 +61,6 @@ class ChangeLoggingMixin(models.Model): from extras.models import ObjectChange objectchange = ObjectChange( changed_object=self, - related_object=related_object, object_repr=str(self)[:200], action=action ) @@ -75,7 +74,7 @@ class ChangeLoggingMixin(models.Model): class CustomFieldsMixin(models.Model): """ - Provides support for custom fields. + Enables support for custom fields. """ custom_field_data = models.JSONField( encoder=DjangoJSONEncoder, @@ -89,26 +88,42 @@ class CustomFieldsMixin(models.Model): @property def cf(self): """ - Convenience wrapper for custom field data. + A pass-through convenience alias for accessing `custom_field_data` (read-only). + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.cf + {'cust_id': 'CYB01'} + ``` """ return self.custom_field_data def get_custom_fields(self): """ - Return a dictionary of custom fields for a single object in the form {: value}. + Return a dictionary of custom fields for a single object in the form `{field: value}`. + + ```python + >>> tenant = Tenant.objects.first() + >>> tenant.get_custom_fields() + {: 'CYB01'} + ``` """ from extras.models import CustomField - fields = CustomField.objects.get_for_model(self) - return OrderedDict([ - (field, self.custom_field_data.get(field.name)) for field in fields - ]) + data = {} + for field in CustomField.objects.get_for_model(self): + value = self.custom_field_data.get(field.name) + data[field] = field.deserialize(value) + + return data def clean(self): super().clean() from extras.models import CustomField - custom_fields = {cf.name: cf for cf in CustomField.objects.get_for_model(self)} + custom_fields = { + cf.name: cf for cf in CustomField.objects.get_for_model(self) + } # Validate all field values for field_name, value in self.custom_field_data.items(): @@ -125,9 +140,17 @@ class CustomFieldsMixin(models.Model): raise ValidationError(f"Missing required custom field '{cf.name}'.") +class CustomLinksMixin(models.Model): + """ + Enables support for custom links. + """ + class Meta: + abstract = True + + class CustomValidationMixin(models.Model): """ - Enables user-configured validation rules for built-in models by extending the clean() method. + Enables user-configured validation rules for models. """ class Meta: abstract = True @@ -139,9 +162,41 @@ class CustomValidationMixin(models.Model): post_clean.send(sender=self.__class__, instance=self) +class ExportTemplatesMixin(models.Model): + """ + Enables support for export templates. + """ + class Meta: + abstract = True + + +class JobResultsMixin(models.Model): + """ + Enables support for job results. + """ + class Meta: + abstract = True + + +class JournalingMixin(models.Model): + """ + Enables support for object journaling. Adds a generic relation (`journal_entries`) + to NetBox's JournalEntry model. + """ + journal_entries = GenericRelation( + to='extras.JournalEntry', + object_id_field='assigned_object_id', + content_type_field='assigned_object_type' + ) + + class Meta: + abstract = True + + class TagsMixin(models.Model): """ - Enable the assignment of Tags. + Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute, + which is a `TaggableManager` instance. """ tags = TaggableManager( through='extras.TaggedItem' @@ -151,113 +206,28 @@ class TagsMixin(models.Model): abstract = True -# -# Base model classes - -class BigIDModel(models.Model): +class WebhooksMixin(models.Model): """ - Abstract base model for all data objects. Ensures the use of a 64-bit PK. + Enables support for webhooks. """ - id = models.BigAutoField( - primary_key=True - ) - class Meta: abstract = True -class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): - """ - Base model for all objects which support change logging. - """ - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True +FEATURES_MAP = ( + ('custom_fields', CustomFieldsMixin), + ('custom_links', CustomLinksMixin), + ('export_templates', ExportTemplatesMixin), + ('job_results', JobResultsMixin), + ('journaling', JournalingMixin), + ('tags', TagsMixin), + ('webhooks', WebhooksMixin), +) -class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Primary models represent real objects within the infrastructure being modeled. - """ - journal_entries = GenericRelation( - to='extras.JournalEntry', - object_id_field='assigned_object_id', - content_type_field='assigned_object_type' - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - - -class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel): - """ - Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest - recursively using MPTT. Within each parent, each child instance must have a unique name. - """ - parent = TreeForeignKey( - to='self', - on_delete=models.CASCADE, - related_name='children', - blank=True, - null=True, - db_index=True - ) - name = models.CharField( - max_length=100 - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = TreeManager() - - class Meta: - abstract = True - - class MPTTMeta: - order_insertion_by = ('name',) - - def __str__(self): - return self.name - - def clean(self): - super().clean() - - # An MPTT model cannot be its own parent - if self.pk and self.parent_id == self.pk: - raise ValidationError({ - "parent": "Cannot assign self as parent." - }) - - -class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): - """ - Organizational models are those which are used solely to categorize and qualify other objects, and do not convey - any real information about the infrastructure being modeled (for example, functional device roles). Organizational - models provide the following standard attributes: - - Unique name - - Unique slug (automatically derived from name) - - Optional description - """ - name = models.CharField( - max_length=100, - unique=True - ) - slug = models.SlugField( - max_length=100, - unique=True - ) - description = models.CharField( - max_length=200, - blank=True - ) - - objects = RestrictedQuerySet.as_manager() - - class Meta: - abstract = True - ordering = ('name',) +@receiver(class_prepared) +def _register_features(sender, **kwargs): + features = { + feature for feature, cls in FEATURES_MAP if issubclass(sender, cls) + } + register_features(sender, features) diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index dc83d02f9..85d86a47a 100644 --- a/netbox/netbox/navigation_menu.py +++ b/netbox/netbox/navigation_menu.py @@ -264,6 +264,7 @@ IPAM_MENU = Menu( label='Other', items=( get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), + get_model_item('ipam', 'servicetemplate', 'Service Templates'), get_model_item('ipam', 'service', 'Services'), ), ), diff --git a/netbox/netbox/preferences.py b/netbox/netbox/preferences.py index 4cad8cf24..aec8bc752 100644 --- a/netbox/netbox/preferences.py +++ b/netbox/netbox/preferences.py @@ -26,6 +26,16 @@ PREFERENCES = { description='The number of objects to display per page', coerce=lambda x: int(x) ), + 'pagination.placement': UserPreference( + label='Paginator placement', + choices=( + ('bottom', 'Bottom'), + ('top', 'Top'), + ('both', 'Both'), + ), + description='Where the paginator controls will be displayed relative to a table', + default='bottom' + ), # Miscellaneous 'data_format': UserPreference( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 5808602a2..2c33ec862 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -406,7 +406,7 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}' CSRF_TRUSTED_ORIGINS = ALLOWED_HOSTS -DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. diff --git a/netbox/netbox/tables/__init__.py b/netbox/netbox/tables/__init__.py new file mode 100644 index 000000000..40ae2f547 --- /dev/null +++ b/netbox/netbox/tables/__init__.py @@ -0,0 +1,29 @@ +from django_tables2 import RequestConfig + +from utilities.paginator import EnhancedPaginator, get_paginate_count +from .columns import * +from .tables import * + + +def configure_table(table, request): + """ + Paginate a table given a request context. + """ + # Save ordering preference + if request.user.is_authenticated: + table_name = table.__class__.__name__ + if table.prefixed_order_by_field in request.GET: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(table.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): + # If no ordering has been specified, set the preferred ordering (if any). + table.order_by = ordering + + # Paginate the table results + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': get_paginate_count(request) + } + RequestConfig(request, paginate).configure(table) diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py new file mode 100644 index 000000000..397a5a4ef --- /dev/null +++ b/netbox/netbox/tables/columns.py @@ -0,0 +1,420 @@ +from dataclasses import dataclass +from typing import Optional + +import django_tables2 as tables +from django.conf import settings +from django.contrib.auth.models import AnonymousUser +from django.template import Context, Template +from django.urls import reverse +from django.utils.safestring import mark_safe +from django_tables2.utils import Accessor + +from extras.choices import CustomFieldTypeChoices +from utilities.utils import content_type_identifier, content_type_name, resolve_namespace + +__all__ = ( + 'ActionsColumn', + 'BooleanColumn', + 'ChoiceFieldColumn', + 'ColorColumn', + 'ColoredLabelColumn', + 'ContentTypeColumn', + 'ContentTypesColumn', + 'CustomFieldColumn', + 'CustomLinkColumn', + 'LinkedCountColumn', + 'MarkdownColumn', + 'MPTTColumn', + 'TagColumn', + 'TemplateColumn', + 'ToggleColumn', + 'UtilizationColumn', +) + + +class ToggleColumn(tables.CheckBoxColumn): + """ + Extend CheckBoxColumn to add a "toggle all" checkbox in the column header. + """ + def __init__(self, *args, **kwargs): + default = kwargs.pop('default', '') + visible = kwargs.pop('visible', False) + if 'attrs' not in kwargs: + kwargs['attrs'] = { + 'td': { + 'class': 'min-width', + }, + 'input': { + 'class': 'form-check-input' + } + } + super().__init__(*args, default=default, visible=visible, **kwargs) + + @property + def header(self): + return mark_safe('') + + +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 + + +@dataclass +class ActionsItem: + title: str + icon: str + permission: Optional[str] = None + + +class ActionsColumn(tables.Column): + """ + A dropdown menu which provides edit, delete, and changelog links for an object. Can optionally include + additional buttons rendered from a template string. + + :param sequence: The ordered list of dropdown menu items to include + :param extra_buttons: A Django template string which renders additional buttons preceding the actions dropdown + """ + attrs = {'td': {'class': 'text-end text-nowrap noprint'}} + empty_values = () + actions = { + 'edit': ActionsItem('Edit', 'pencil', 'change'), + 'delete': ActionsItem('Delete', 'trash-can-outline', 'delete'), + 'changelog': ActionsItem('Changelog', 'history'), + } + + def __init__(self, *args, sequence=('edit', 'delete', 'changelog'), extra_buttons='', **kwargs): + super().__init__(*args, **kwargs) + + self.extra_buttons = extra_buttons + + # Determine which actions to enable + self.actions = { + name: self.actions[name] for name in sequence + } + + def header(self): + return '' + + def render(self, record, table, **kwargs): + # Skip dummy records (e.g. available VLANs) or those with no actions + if not getattr(record, 'pk', None) or not self.actions: + return '' + + model = table.Meta.model + viewname_base = f'{resolve_namespace(model)}:{model._meta.model_name}' + request = getattr(table, 'context', {}).get('request') + url_appendix = f'?return_url={request.path}' if request else '' + + links = [] + user = getattr(request, 'user', AnonymousUser()) + for action, attrs in self.actions.items(): + permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}' + if attrs.permission is None or user.has_perm(permission): + url = reverse(f'{viewname_base}_{action}', kwargs={'pk': record.pk}) + links.append(f'
  • ' + f' {attrs.title}
  • ') + + if not links: + return '' + + menu = f'' \ + f'' \ + f'' \ + f'' + + # Render any extra buttons from template code + if self.extra_buttons: + template = Template(self.extra_buttons) + context = getattr(table, "context", Context()) + context.update({'record': record}) + menu = template.render(context) + menu + + return mark_safe(menu) + + +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__( + 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_URL: + # Linkify custom URLs + return mark_safe(f'{value}') + if value is not None: + obj = self.customfield.deserialize(value) + if hasattr(obj, 'get_absolute_url'): + return mark_safe(f'{obj}') + return obj + 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 diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py new file mode 100644 index 000000000..fe422118c --- /dev/null +++ b/netbox/netbox/tables/tables.py @@ -0,0 +1,168 @@ +import django_tables2 as tables +from django.contrib.auth.models import AnonymousUser +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import FieldDoesNotExist +from django.db.models.fields.related import RelatedField +from django_tables2.data import TableQuerysetData + +from extras.models import CustomField, CustomLink +from netbox.tables import columns + +__all__ = ( + 'BaseTable', + 'NetBoxTable', +) + + +class BaseTable(tables.Table): + """ + Base table class for NetBox objects. Adds support for: + + * User configuration (column preferences) + * Automatic prefetching of related objects + * BS5 styling + + :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed. + """ + exempt_columns = () + + class Meta: + attrs = { + 'class': 'table table-hover object-list', + } + + def __init__(self, *args, user=None, **kwargs): + + super().__init__(*args, **kwargs) + + # Set default empty_text if none was provided + if self.empty_text is None: + self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" + + # Hide non-default columns + default_columns = [*getattr(self.Meta, 'default_columns', self.Meta.fields), *self.exempt_columns] + for column in self.columns: + if column.name not in default_columns: + self.columns.hide(column.name) + + # Apply custom column ordering for user + if user is not None and not isinstance(user, AnonymousUser): + selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") + if selected_columns: + + # Show only persistent or selected columns + for name, column in self.columns.items(): + if name in [*self.exempt_columns, *selected_columns]: + self.columns.show(name) + else: + self.columns.hide(name) + + # Rearrange the sequence to list selected columns first, followed by all remaining columns + # TODO: There's probably a more clever way to accomplish this + self.sequence = [ + *[c for c in selected_columns if c in self.columns.names()], + *[c for c in self.columns.names() if c not in selected_columns] + ] + + # PK column should always come first + if 'pk' in self.sequence: + self.sequence.remove('pk') + self.sequence.insert(0, 'pk') + + # Actions column should always come last + if 'actions' in self.sequence: + self.sequence.remove('actions') + self.sequence.append('actions') + + # Dynamically update the table's QuerySet to ensure related fields are pre-fetched + if isinstance(self.data, TableQuerysetData): + + prefetch_fields = [] + for column in self.columns: + if column.visible: + model = getattr(self.Meta, 'model') + accessor = column.accessor + prefetch_path = [] + for field_name in accessor.split(accessor.SEPARATOR): + try: + field = model._meta.get_field(field_name) + except FieldDoesNotExist: + break + if isinstance(field, RelatedField): + # Follow ForeignKeys to the related model + prefetch_path.append(field_name) + model = field.remote_field.model + elif isinstance(field, GenericForeignKey): + # Can't prefetch beyond a GenericForeignKey + prefetch_path.append(field_name) + break + if prefetch_path: + prefetch_fields.append('__'.join(prefetch_path)) + self.data.data = self.data.data.prefetch_related(None).prefetch_related(*prefetch_fields) + + def _get_columns(self, visible=True): + columns = [] + for name, column in self.columns.items(): + if column.visible == visible and name not in self.exempt_columns: + columns.append((name, column.verbose_name)) + return columns + + @property + def available_columns(self): + return self._get_columns(visible=False) + + @property + def selected_columns(self): + return self._get_columns(visible=True) + + @property + def objects_count(self): + """ + Return the total number of real objects represented by the Table. This is useful when dealing with + prefixes/IP addresses/etc., where some table rows may represent available address space. + """ + if not hasattr(self, '_objects_count'): + self._objects_count = sum(1 for obj in self.data if hasattr(obj, 'pk')) + return self._objects_count + + +class NetBoxTable(BaseTable): + """ + Table class for most NetBox objects. Adds support for custom field & custom link columns. Includes + default columns for: + + * PK (row selection) + * ID + * Actions + """ + pk = columns.ToggleColumn( + visible=False + ) + id = tables.Column( + linkify=True, + verbose_name='ID' + ) + actions = columns.ActionsColumn() + + exempt_columns = ('pk', 'actions') + + class Meta(BaseTable.Meta): + pass + + def __init__(self, *args, extra_columns=None, **kwargs): + if extra_columns is None: + extra_columns = [] + + # Add custom field & custom link columns + content_type = ContentType.objects.get_for_model(self._meta.model) + custom_fields = CustomField.objects.filter(content_types=content_type) + extra_columns.extend([ + (f'cf_{cf.name}', columns.CustomFieldColumn(cf)) for cf in custom_fields + ]) + custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True) + extra_columns.extend([ + (f'cl_{cl.name}', columns.CustomLinkColumn(cl)) for cl in custom_links + ]) + + super().__init__(*args, extra_columns=extra_columns, **kwargs) diff --git a/netbox/utilities/tests/test_tables.py b/netbox/netbox/tests/test_tables.py similarity index 74% rename from netbox/utilities/tests/test_tables.py rename to netbox/netbox/tests/test_tables.py index 119587ff8..17b9743cd 100644 --- a/netbox/utilities/tests/test_tables.py +++ b/netbox/netbox/tests/test_tables.py @@ -2,14 +2,14 @@ from django.template import Context, Template from django.test import TestCase from dcim.models import Site -from utilities.tables import BaseTable, TagColumn +from netbox.tables import NetBoxTable, columns from utilities.testing import create_tags -class TagColumnTable(BaseTable): - tags = TagColumn(url_name='dcim:site_list') +class TagColumnTable(NetBoxTable): + tags = columns.TagColumn(url_name='dcim:site_list') - class Meta(BaseTable.Meta): + class Meta(NetBoxTable.Meta): model = Site fields = ('pk', 'name', 'tags',) default_columns = fields @@ -30,7 +30,8 @@ class TagColumnTest(TestCase): def test_tagcolumn(self): template = Template('{% load render_table from django_tables2 %}{% render_table table %}') + table = TagColumnTable(Site.objects.all(), orderable=False) context = Context({ - 'table': TagColumnTable(Site.objects.all(), orderable=False) + 'table': table }) template.render(context) diff --git a/netbox/netbox/views/generic/base.py b/netbox/netbox/views/generic/base.py new file mode 100644 index 000000000..3ad3bcf67 --- /dev/null +++ b/netbox/netbox/views/generic/base.py @@ -0,0 +1,58 @@ +from django.shortcuts import get_object_or_404 +from django.views.generic import View + +from utilities.views import ObjectPermissionRequiredMixin + + +class BaseObjectView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + template_name: The name of the HTML template file to render + """ + queryset = None + template_name = None + + def get_object(self, **kwargs): + """ + Return the object being viewed or modified. The object is identified by an arbitrary set of keyword arguments + gleaned from the URL, which are passed to `get_object_or_404()`. (Typically, only a primary key is needed.) + + If no matching object is found, return a 404 response. + """ + return get_object_or_404(self.queryset, **kwargs) + + def get_extra_context(self, request, instance): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + instance: The object being viewed + """ + return {} + + +class BaseMultiObjectView(ObjectPermissionRequiredMixin, View): + """ + Base view class for reusable generic views. + + Attributes: + queryset: Django QuerySet from which the object(s) will be fetched + table: The django-tables2 Table class used to render the objects list + template_name: The name of the HTML template file to render + """ + queryset = None + table = None + template_name = None + + def get_extra_context(self, request): + """ + Return any additional context data to include when rendering the template. + + Args: + request: The current request + """ + return {} diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 6f21a6879..9c834d76f 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,21 +3,27 @@ import re from copy import deepcopy from django.contrib import messages +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ValidationError from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError from django.forms import Form, ModelMultipleChoiceField, MultipleHiddenInput, Textarea -from django.shortcuts import redirect, render -from django.views.generic import View +from django.http import HttpResponse +from django.shortcuts import get_object_or_404, redirect, render +from django_tables2.export import TableExport +from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, restrict_form_fields, ) +from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from netbox.tables import configure_table +from utilities.views import GetReturnURLMixin +from .base import BaseMultiObjectView __all__ = ( 'BulkComponentCreateView', @@ -26,24 +32,174 @@ __all__ = ( 'BulkEditView', 'BulkImportView', 'BulkRenameView', + 'ObjectListView', ) -class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectListView(BaseMultiObjectView): + """ + Display multiple objects, all of the same type, as a table. + + Attributes: + filterset: A django-filter FilterSet that is applied to the queryset + filterset_form: The form class used to render filter options + action_buttons: A list of buttons to include at the top of the page + """ + template_name = 'generic/object_list.html' + filterset = None + filterset_form = None + action_buttons = ('add', 'import', 'export') + + def get_required_permission(self): + return get_permission_for_model(self.queryset.model, 'view') + + def get_table(self, request, permissions): + """ + Return the django-tables2 Table instance to be used for rendering the objects list. + + Args: + request: The current request + permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating + whether the user has each + """ + table = self.table(self.queryset, user=request.user) + if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): + table.columns.show('pk') + + return table + + # + # Export methods + # + + def export_yaml(self): + """ + Export the queryset of objects as concatenated YAML documents. + """ + yaml_data = [obj.to_yaml() for obj in self.queryset] + + return '---\n'.join(yaml_data) + + def export_table(self, table, columns=None, filename=None): + """ + Export all table data in CSV format. + + Args: + table: The Table instance to export + columns: A list of specific columns to include. If None, all columns will be exported. + filename: The name of the file attachment sent to the client. If None, will be determined automatically + from the queryset model name. + """ + exclude_columns = {'pk', 'actions'} + if columns: + all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] + exclude_columns.update({ + col for col in all_columns if col not in columns + }) + exporter = TableExport( + export_format=TableExport.CSV, + table=table, + exclude_columns=exclude_columns + ) + return exporter.response( + filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' + ) + + def export_template(self, template, request): + """ + Render an ExportTemplate using the current queryset. + + Args: + template: ExportTemplate instance + request: The current request + """ + try: + return template.render_to_response(self.queryset) + except Exception as e: + messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") + return redirect(request.path) + + # + # Request handlers + # + + def get(self, request): + """ + GET request handler. + + Args: + request: The current request + """ + model = self.queryset.model + content_type = ContentType.objects.get_for_model(model) + + if self.filterset: + self.queryset = self.filterset(request.GET, self.queryset).qs + + # Compile a dictionary indicating which permissions are available to the current user for this model + permissions = {} + for action in ('add', 'change', 'delete', 'view'): + perm_name = get_permission_for_model(model, action) + permissions[action] = request.user.has_perm(perm_name) + + if 'export' in request.GET: + + # Export the current table view + if request.GET['export'] == 'table': + table = self.get_table(request, permissions) + columns = [name for name, _ in table.selected_columns] + return self.export_table(table, columns) + + # Render an ExportTemplate + elif request.GET['export']: + template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) + return self.export_template(template, request) + + # Check for YAML export support on the model + elif hasattr(model, 'to_yaml'): + response = HttpResponse(self.export_yaml(), content_type='text/yaml') + filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response + + # Fall back to default table/YAML export + else: + table = self.get_table(request, permissions) + return self.export_table(table) + + # Render the objects table + table = self.get_table(request, permissions) + configure_table(table, request) + + # If this is an HTMX request, return only the rendered table HTML + if is_htmx(request): + return render(request, 'htmx/table.html', { + 'table': table, + }) + + context = { + 'content_type': content_type, + 'table': table, + 'permissions': permissions, + 'action_buttons': self.action_buttons, + 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + **self.get_extra_context(request), + } + + return render(request, self.template_name, context) + + +class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Create new objects in bulk. - queryset: Base queryset for the objects being created form: Form class which provides the `pattern` field model_form: The ModelForm used to create individual objects pattern_target: Name of the field to be evaluated as a pattern (if any) - template_name: The name of the template """ - queryset = None form = None model_form = None pattern_target = '' - template_name = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -73,6 +229,10 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return new_objects + # + # Request handlers + # + def get(self, request): # Set initial values for visible form fields from query args initial = {} @@ -88,6 +248,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'form': form, 'model_form': model_form, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -132,31 +293,25 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'model_form': model_form, 'obj_type': model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): """ Import objects in bulk (CSV format). - queryset: Base queryset for the model - model_form: The form used to create each imported object - table: The django-tables2 Table used to render the list of imported objects - template_name: The name of the template - widget_attrs: A dict of attributes to apply to the import widget (e.g. to require a session key) + Attributes: + model_form: The form used to create each imported object """ - queryset = None - model_form = None - table = None template_name = 'generic/object_bulk_import.html' - widget_attrs = {} + model_form = None def _import_form(self, *args, **kwargs): class ImportForm(BootstrapMixin, Form): csv = CSVDataField( - from_form=self.model_form, - widget=Textarea(attrs=self.widget_attrs) + from_form=self.model_form ) csv_file = CSVFileField( label="CSV file", @@ -207,6 +362,10 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') + # + # Request handlers + # + def get(self, request): return render(request, self.template_name, { @@ -214,6 +373,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) def post(self, request): @@ -262,32 +422,29 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'fields': self.model_form().fields, 'obj_type': self.model_form._meta.model._meta.verbose_name, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): """ Edit objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) - filterset: FilterSet to apply when deleting by QuerySet - table: The table used to display devices being edited - form: The form class used to edit objects in bulk - template_name: The name of the template + Attributes: + filterset: FilterSet to apply when deleting by QuerySet + form: The form class used to edit objects in bulk """ - queryset = None - filterset = None - table = None - form = None template_name = 'generic/object_bulk_edit.html' + filterset = None + form = None def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'change') def _update_objects(self, form, request): - custom_fields = form.custom_fields if hasattr(form, 'custom_fields') else [] + custom_fields = getattr(form, 'custom_fields', []) standard_fields = [ - field for field in form.fields if field not in custom_fields + ['pk'] + field for field in form.fields if field not in list(custom_fields) + ['pk'] ] nullified_fields = request.POST.getlist('_nullify') updated_objects = [] @@ -327,7 +484,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): if name in form.nullable_fields and name in nullified_fields: obj.custom_field_data[name] = None elif name in form.changed_data: - obj.custom_field_data[name] = form.cleaned_data[name] + obj.custom_field_data[name] = form.fields[name].prepare_value(form.cleaned_data[name]) obj.full_clean() obj.save() @@ -341,6 +498,10 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return updated_objects + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) @@ -419,17 +580,14 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'table': table, 'obj_type_plural': model._meta.verbose_name_plural, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) -class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): """ An extendable view for renaming objects in bulk. - - queryset: QuerySet of objects being renamed - template_name: The name of the template """ - queryset = None template_name = 'generic/object_bulk_rename.html' def __init__(self, *args, **kwargs): @@ -513,25 +671,38 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): """ Delete objects in bulk. - queryset: Custom queryset to use when retrieving objects (e.g. to select related objects) filterset: FilterSet to apply when deleting by QuerySet table: The table used to display devices being deleted form: The form class used to delete objects in bulk - template_name: The name of the template """ - queryset = None + template_name = 'generic/object_bulk_delete.html' filterset = None table = None form = None - template_name = 'generic/object_bulk_delete.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') + def get_form(self): + """ + Provide a standard bulk delete form if none has been specified for the view + """ + class BulkDeleteForm(ConfirmationForm): + pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) + + if self.form: + return self.form + + return BulkDeleteForm + + # + # Request handlers + # + def get(self, request): return redirect(self.get_return_url(request)) @@ -594,37 +765,25 @@ class BulkDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'obj_type_plural': model._meta.verbose_name_plural, 'table': table, 'return_url': self.get_return_url(request), + **self.get_extra_context(request), }) - def get_form(self): - """ - Provide a standard bulk delete form if none has been specified for the view - """ - class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - - if self.form: - return self.form - - return BulkDeleteForm - # # Device/VirtualMachine components # -class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a set of Devices or VirtualMachines. """ + template_name = 'generic/object_bulk_add_component.html' parent_model = None parent_field = None form = None - queryset = None model_form = None filterset = None table = None - template_name = 'generic/object_bulk_add_component.html' def get_required_permission(self): return f'dcim.add_{self.queryset.model._meta.model_name}' diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 607501a9b..316c3a1ee 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -2,30 +2,26 @@ import logging from copy import deepcopy from django.contrib import messages -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.db import transaction from django.db.models import ProtectedError from django.forms.widgets import HiddenInput -from django.http import HttpResponse -from django.shortcuts import get_object_or_404, redirect, render +from django.shortcuts import redirect, render +from django.urls import reverse from django.utils.html import escape from django.utils.http import is_safe_url from django.utils.safestring import mark_safe -from django.views.generic import View -from django_tables2.export import TableExport -from dcim.forms.object_create import ComponentCreateForm -from extras.models import ExportTemplate from extras.signals import clear_webhooks from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ConfirmationForm, ImportForm, restrict_form_fields from utilities.htmx import is_htmx from utilities.permissions import get_permission_for_model -from utilities.tables import paginate_table +from netbox.tables import configure_table from utilities.utils import normalize_querydict, prepare_cloned_fields -from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin +from utilities.views import GetReturnURLMixin +from .base import BaseObjectView __all__ = ( 'ComponentCreateView', @@ -33,49 +29,41 @@ __all__ = ( 'ObjectDeleteView', 'ObjectEditView', 'ObjectImportView', - 'ObjectListView', 'ObjectView', ) -class ObjectView(ObjectPermissionRequiredMixin, View): +class ObjectView(BaseObjectView): """ Retrieve a single object for display. - queryset: The base queryset for retrieving the object - template_name: Name of the template to use + Note: If `template_name` is not specified, it will be determined automatically based on the queryset model. """ - queryset = None - template_name = None - def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'view') def get_template_name(self): """ - Return self.template_name if set. Otherwise, resolve the template path by model app_label and name. + Return self.template_name if defined. Otherwise, dynamically resolve the template name using the queryset + model's `app_label` and `model_name`. """ if self.template_name is not None: return self.template_name model_opts = self.queryset.model._meta return f'{model_opts.app_label}/{model_opts.model_name}.html' - def get_extra_context(self, request, instance): - """ - Return any additional context data for the template. + # + # Request handlers + # - :param request: The current request - :param instance: The object being viewed + def get(self, request, **kwargs): """ - return {} + GET request handler. `*args` and `**kwargs` are passed to identify the object being queried. - def get(self, request, *args, **kwargs): + Args: + request: The current request """ - GET request handler. *args and **kwargs are passed to identify the object being queried. - - :param request: The current request - """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) return render(request, self.get_template_name(), { 'object': instance, @@ -87,15 +75,12 @@ class ObjectChildrenView(ObjectView): """ Display a table of child objects associated with the parent object. - queryset: The base queryset for retrieving the *parent* object - table: Table class used to render child objects list - template_name: Name of the template to use + Attributes: + table: Table class used to render child objects list """ - queryset = None child_model = None table = None filterset = None - template_name = None def get_children(self, request, parent): """ @@ -110,17 +95,22 @@ class ObjectChildrenView(ObjectView): """ Provides a hook for subclassed views to modify data before initializing the table. - :param request: The current request - :param queryset: The filtered queryset of child objects - :param parent: The parent object + Args: + request: The current request + queryset: The filtered queryset of child objects + parent: The parent object """ return queryset + # + # Request handlers + # + def get(self, request, *args, **kwargs): """ GET handler for rendering child objects. """ - instance = get_object_or_404(self.queryset, **kwargs) + instance = self.get_object(**kwargs) child_objects = self.get_children(request, instance) if self.filterset: @@ -135,7 +125,7 @@ class ObjectChildrenView(ObjectView): # Determine whether to display bulk action checkboxes if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): table.columns.show('pk') - paginate_table(table, request) + configure_table(table, request) # If this is an HTMX request, return only the rendered table HTML if is_htmx(request): @@ -152,171 +142,17 @@ class ObjectChildrenView(ObjectView): }) -class ObjectListView(ObjectPermissionRequiredMixin, View): - """ - List a series of objects. - - queryset: The queryset of objects to display. Note: Prefetching related objects is not necessary, as the - table will prefetch objects as needed depending on the columns being displayed. - filterset: A django-filter FilterSet that is applied to the queryset - filterset_form: The form used to render filter options - table: The django-tables2 Table used to render the objects list - template_name: The name of the template - action_buttons: A list of buttons to include at the top of the page - """ - queryset = None - filterset = None - filterset_form = None - table = None - template_name = 'generic/object_list.html' - action_buttons = ('add', 'import', 'export') - - def get_required_permission(self): - return get_permission_for_model(self.queryset.model, 'view') - - def get_table(self, request, permissions): - """ - Return the django-tables2 Table instance to be used for rendering the objects list. - - :param request: The current request - :param permissions: A dictionary mapping of the view, add, change, and delete permissions to booleans indicating - whether the user has each - """ - table = self.table(self.queryset, user=request.user) - if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']): - table.columns.show('pk') - - return table - - def export_yaml(self): - """ - Export the queryset of objects as concatenated YAML documents. - """ - yaml_data = [obj.to_yaml() for obj in self.queryset] - - return '---\n'.join(yaml_data) - - def export_table(self, table, columns=None): - """ - Export all table data in CSV format. - - :param table: The Table instance to export - :param columns: A list of specific columns to include. If not specified, all columns will be exported. - """ - exclude_columns = {'pk'} - if columns: - all_columns = [col_name for col_name, _ in table.selected_columns + table.available_columns] - exclude_columns.update({ - col for col in all_columns if col not in columns - }) - exporter = TableExport( - export_format=TableExport.CSV, - table=table, - exclude_columns=exclude_columns - ) - return exporter.response( - filename=f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' - ) - - def export_template(self, template, request): - """ - Render an ExportTemplate using the current queryset. - - :param template: ExportTemplate instance - :param request: The current request - """ - try: - return template.render_to_response(self.queryset) - except Exception as e: - messages.error(request, f"There was an error rendering the selected export template ({template.name}): {e}") - return redirect(request.path) - - def get_extra_context(self, request): - """ - Return any additional context data for the template. - - :param request: The current request - """ - return {} - - def get(self, request): - """ - GET request handler. - - :param request: The current request - """ - model = self.queryset.model - content_type = ContentType.objects.get_for_model(model) - - if self.filterset: - self.queryset = self.filterset(request.GET, self.queryset).qs - - # Compile a dictionary indicating which permissions are available to the current user for this model - permissions = {} - for action in ('add', 'change', 'delete', 'view'): - perm_name = get_permission_for_model(model, action) - permissions[action] = request.user.has_perm(perm_name) - - if 'export' in request.GET: - - # Export the current table view - if request.GET['export'] == 'table': - table = self.get_table(request, permissions) - columns = [name for name, _ in table.selected_columns] - return self.export_table(table, columns) - - # Render an ExportTemplate - elif request.GET['export']: - template = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export']) - return self.export_template(template, request) - - # Check for YAML export support on the model - elif hasattr(model, 'to_yaml'): - response = HttpResponse(self.export_yaml(), content_type='text/yaml') - filename = 'netbox_{}.yaml'.format(self.queryset.model._meta.verbose_name_plural) - response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) - return response - - # Fall back to default table/YAML export - else: - table = self.get_table(request, permissions) - return self.export_table(table) - - # Render the objects table - table = self.get_table(request, permissions) - paginate_table(table, request) - - # If this is an HTMX request, return only the rendered table HTML - if is_htmx(request): - return render(request, 'htmx/table.html', { - 'table': table, - }) - - context = { - 'content_type': content_type, - 'table': table, - 'permissions': permissions, - 'action_buttons': self.action_buttons, - 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, - } - context.update(self.get_extra_context(request)) - - return render(request, self.template_name, context) - - -class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectImportView(GetReturnURLMixin, BaseObjectView): """ Import a single object (YAML or JSON format). - queryset: Base queryset for the objects being created - model_form: The ModelForm used to create individual objects - related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects - template_name: The name of the template + Attributes: + model_form: The ModelForm used to create individual objects + related_object_forms: A dictionary mapping of forms to be used for the creation of related (child) objects """ - queryset = None + template_name = 'generic/object_import.html' model_form = None related_object_forms = dict() - template_name = 'generic/object_import.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'add') @@ -367,6 +203,10 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): return obj + # + # Request handlers + # + def get(self, request): form = ImportForm() @@ -445,17 +285,21 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): }) -class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectEditView(GetReturnURLMixin, BaseObjectView): """ Create or edit a single object. - queryset: The base QuerySet for the object being modified - model_form: The form used to create or edit the object - template_name: The name of the template + Attributes: + model_form: The form used to create or edit the object """ - queryset = None - model_form = None template_name = 'generic/object_edit.html' + model_form = None + + def dispatch(self, request, *args, **kwargs): + # Determine required permission based on whether we are editing an existing object + self._permission_action = 'change' if kwargs else 'add' + + return super().dispatch(request, *args, **kwargs) def get_required_permission(self): # self._permission_action is set by dispatch() to either "add" or "change" depending on whether @@ -464,42 +308,36 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): def get_object(self, **kwargs): """ - Return an instance for editing. If a PK has been specified, this will be an existing object. - - :param kwargs: URL path kwargs + Return an object for editing. If no keyword arguments have been specified, this will be a new instance. """ - if 'pk' in kwargs: - obj = get_object_or_404(self.queryset, **kwargs) - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - return obj - - return self.queryset.model() + if not kwargs: + # We're creating a new object + return self.queryset.model() + return super().get_object(**kwargs) def alter_object(self, obj, request, url_args, url_kwargs): """ Provides a hook for views to modify an object before it is processed. For example, a parent object can be defined given some parameter from the request URL. - :param obj: The object being edited - :param request: The current request - :param url_args: URL path args - :param url_kwargs: URL path kwargs + Args: + obj: The object being edited + request: The current request + url_args: URL path args + url_kwargs: URL path kwargs """ return obj - def dispatch(self, request, *args, **kwargs): - # Determine required permission based on whether we are editing an existing object - self._permission_action = 'change' if kwargs else 'add' - - return super().dispatch(request, *args, **kwargs) + # + # Request handlers + # def get(self, request, *args, **kwargs): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) obj = self.alter_object(obj, request, args, kwargs) @@ -513,16 +351,23 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectEditView') obj = self.get_object(**kwargs) + + # Take a snapshot for change logging (if editing an existing object) + if obj.pk and hasattr(obj, 'snapshot'): + obj.snapshot() + obj = self.alter_object(obj, request, args, kwargs) form = self.model_form( @@ -585,62 +430,68 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): 'obj_type': self.queryset.model._meta.verbose_name, 'form': form, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) -class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ Delete a single object. - - queryset: The base queryset for the object being deleted - template_name: The name of the template """ - queryset = None template_name = 'generic/object_delete.html' def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') - def get_object(self, **kwargs): - """ - Return an instance for deletion. If a PK has been specified, this will be an existing object. - - :param kwargs: URL path kwargs - """ - obj = get_object_or_404(self.queryset, **kwargs) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() - - return obj + # + # Request handlers + # def get(self, request, *args, **kwargs): """ GET request handler. - :param request: The current request + Args: + request: The current request """ obj = self.get_object(**kwargs) form = ConfirmationForm(initial=request.GET) + # If this is an HTMX request, return only the rendered deletion form as modal content + if is_htmx(request): + viewname = f'{self.queryset.model._meta.app_label}:{self.queryset.model._meta.model_name}_delete' + form_url = reverse(viewname, kwargs={'pk': obj.pk}) + return render(request, 'htmx/delete_form.html', { + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, + 'form': form, + 'form_url': form_url, + **self.get_extra_context(request, obj), + }) + return render(request, self.template_name, { - 'obj': obj, + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) def post(self, request, *args, **kwargs): """ POST request handler. - :param request: The current request + Args: + request: The current request """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) form = ConfirmationForm(request.POST) + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + if form.is_valid(): logger.debug("Form validation was successful") @@ -665,10 +516,11 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): logger.debug("Form validation failed") return render(request, self.template_name, { - 'obj': obj, + 'object': obj, + 'object_type': self.queryset.model._meta.verbose_name, 'form': form, - 'obj_type': self.queryset.model._meta.verbose_name, 'return_url': self.get_return_url(request, obj), + **self.get_extra_context(request, obj), }) @@ -676,14 +528,13 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Device/VirtualMachine components # -class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): +class ComponentCreateView(GetReturnURLMixin, BaseObjectView): """ Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. """ - queryset = None + template_name = 'dcim/component_create.html' form = None model_form = None - template_name = 'dcim/component_create.html' patterned_fields = ('name', 'label') def get_required_permission(self): diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index fe89a1b48..752715e7c 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index f8e59ef99..341369adf 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 9b46d50c8..61bcedf9c 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index d78429bf9..a54b6c324 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -358,7 +358,7 @@ nav.search { // Don't overtake dropdowns z-index: 999; justify-content: center; - background-color: var(--nbx-body-bg); + background-color: $navbar-light-color; .search-container { display: flex; @@ -452,8 +452,8 @@ main.login-container { } .footer { + background-color: $tab-content-bg; padding: 0; - .nav-link { padding: 0.5rem; } @@ -517,6 +517,10 @@ h6.accordion-item-title { } } +.navbar { + border-bottom: 1px solid $border-color; +} + .navbar-brand { padding-top: 0.75rem; padding-bottom: 0.75rem; @@ -554,6 +558,7 @@ div.content-container { } div.content { + background-color: $tab-content-bg; flex: 1; } @@ -592,6 +597,10 @@ span.color-label { box-shadow: $box-shadow-sm; } +.badge a { + color: inherit; +} + .btn { white-space: nowrap; } @@ -898,6 +907,7 @@ div.card-overlay { // Tabbed content .nav-tabs { + background-color: $body-bg; .nav-link { &:hover { // Don't show a bottom-border on a hovered nav link because it overlaps with the .nav-tab border. @@ -919,14 +929,6 @@ div.card-overlay { display: flex; flex-direction: column; padding: $spacer; - background-color: $tab-content-bg; - border-bottom: 1px solid $nav-tabs-border-color; - - // Remove background and border when printing. - @media print { - background-color: var(--nbx-body-bg) !important; - border-bottom: none !important; - } } // Override masonry-layout styles when printing. diff --git a/netbox/project-static/styles/sidenav.scss b/netbox/project-static/styles/sidenav.scss index 9dfdd855a..4261e5120 100644 --- a/netbox/project-static/styles/sidenav.scss +++ b/netbox/project-static/styles/sidenav.scss @@ -223,11 +223,6 @@ font-weight: $font-weight-bold; color: var(--nbx-sidenav-parent-color); - &.active { - color: $accordion-button-active-color; - background: $accordion-button-active-bg; - } - &:after { display: inline-block; margin-left: auto; @@ -284,7 +279,7 @@ font-size: $font-size-sm; color: var(--nbx-sidenav-link-color); white-space: nowrap; - transition: $transition-100ms-ease-in-out; + transition-duration: 0ms; &.active { background-color: var(--nbx-sidebar-link-active-bg); diff --git a/netbox/project-static/styles/theme-dark.scss b/netbox/project-static/styles/theme-dark.scss index c787d38bf..2db29ad38 100644 --- a/netbox/project-static/styles/theme-dark.scss +++ b/netbox/project-static/styles/theme-dark.scss @@ -146,7 +146,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg $nav-pills-link-active-color: $component-active-color; $nav-pills-link-active-bg: $component-active-bg; -$navbar-light-color: $gray-500; +$navbar-light-color: $darkest; $navbar-light-toggler-icon-bg: url("data:image/svg+xml,"); $navbar-light-toggler-border-color: $gray-700; diff --git a/netbox/project-static/styles/theme-light.scss b/netbox/project-static/styles/theme-light.scss index 05ead54b5..d417e1bf6 100644 --- a/netbox/project-static/styles/theme-light.scss +++ b/netbox/project-static/styles/theme-light.scss @@ -2,8 +2,6 @@ @import './theme-base.scss'; -$input-border-color: $gray-200; - // Theme colors (BS5 classes) $primary: #337ab7; $secondary: $gray-600; @@ -43,6 +41,8 @@ $theme-colors: ( $light: $gray-200; +$navbar-light-color: $gray-100; + $card-cap-color: $gray-800; $accordion-bg: transparent; diff --git a/netbox/project-static/styles/variables.scss b/netbox/project-static/styles/variables.scss index ddeb6025a..8075cf5b0 100644 --- a/netbox/project-static/styles/variables.scss +++ b/netbox/project-static/styles/variables.scss @@ -5,7 +5,7 @@ --nbx-sidebar-bg: #{$gray-200}; --nbx-sidebar-scroll: #{$gray-500}; --nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)}; - --nbx-sidebar-link-active-bg: #{$blue-100}; + --nbx-sidebar-link-active-bg: #9cc8f8; --nbx-sidebar-title-color: #{$text-muted}; --nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25); --nbx-breadcrumb-bg: #{$light}; diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 7b1597bf0..cf3841dd2 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -20,7 +20,7 @@ {# Top bar #} -