Merge branch 'feature' of https://github.com/netbox-community/netbox into 7853-speed_duplex

This commit is contained in:
Daniel Sheppard 2022-01-20 13:12:04 -06:00
commit 375a140343
174 changed files with 2391 additions and 1158 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.4 placeholder: v3.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.4 placeholder: v3.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -82,6 +82,10 @@ markdown-include
# https://github.com/squidfunk/mkdocs-material # https://github.com/squidfunk/mkdocs-material
mkdocs-material mkdocs-material
# Introspection for embedded code
# https://github.com/mkdocstrings/mkdocstrings
mkdocstrings
# Library for manipulating IP prefixes and addresses # Library for manipulating IP prefixes and addresses
# https://github.com/drkjam/netaddr # https://github.com/drkjam/netaddr
netaddr netaddr
@ -98,10 +102,6 @@ psycopg2-binary
# https://github.com/yaml/pyyaml # https://github.com/yaml/pyyaml
PyYAML PyYAML
# In-memory key/value store used for caching and queuing
# https://github.com/andymccurdy/redis-py
redis
# Social authentication framework # Social authentication framework
# https://github.com/python-social-auth/social-core # https://github.com/python-social-auth/social-core
social-auth-core[all] social-auth-core[all]

View File

@ -1,3 +1,4 @@
# Service Mapping # Service Mapping
{!models/ipam/servicetemplate.md!}
{!models/ipam/service.md!} {!models/ipam/service.md!}

View File

@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration" !!! 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. 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 <https://demo.netbox.dev>.)
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 ## 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. 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.

View File

@ -4,9 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f
## Available Preferences ## Available Preferences
| Name | Description | | Name | Description |
|-------------------------|-------------| |--------------------------|---------------------------------------------------------------|
| data_format | Preferred format when rendering raw data (JSON or YAML) | | 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.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 | | pagination.placement | Where to display the paginator controls relative to the table |
| ui.colormode | Light or dark mode in the user interface | | 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 |

View File

@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Application | Django/Python | | Application | Django/Python |
| Database | PostgreSQL 10+ | | Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM (optional) |
## Supported Python Versions ## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
## Getting Started ## 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.

View File

@ -15,7 +15,7 @@ When viewing a device named Router4, this link would render as:
<a href="https://nms.example.com/nodes/?name=Router4">View NMS</a> <a href="https://nms.example.com/nodes/?name=Router4">View NMS</a>
``` ```
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 !!! 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. 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.

View File

@ -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. 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 !!! 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 ## 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. * **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. * **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`. * **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`) * **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). * **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.) * **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 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: 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:

View File

@ -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.

View File

@ -1,5 +1,8 @@
# Plugin Development # Plugin Development
!!! info "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox. 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: Plugins can do a lot, including:

View File

@ -0,0 +1,3 @@
# Plugins Development
TODO

View File

@ -0,0 +1,64 @@
# Model Features
## Enabling NetBox Features
Plugin models can leverage certain NetBox features by inheriting from NetBox's `BaseModel` class. This class extends the plugin model to enable numerous feature, including:
* 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 netbox.models import BaseModel
class MyModel(BaseModel):
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.models 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.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

View File

@ -1,6 +1,14 @@
# Release Notes # 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) #### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,6 +1,52 @@
# NetBox v3.1 # 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
--- ---

View File

@ -14,6 +14,10 @@
### New Features ### New Features
#### 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)) #### 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/<pk>/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. A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/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.
@ -56,13 +60,19 @@ Inventory item templates can be arranged hierarchically within a device type, an
### Enhancements ### 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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs
* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group * [#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 ### Other Changes
@ -80,6 +90,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
* `/api/dcim/module-bays/` * `/api/dcim/module-bays/`
* `/api/dcim/module-bay-templates/` * `/api/dcim/module-bay-templates/`
* `/api/dcim/module-types/` * `/api/dcim/module-types/`
* `/api/extras/service-templates/`
* circuits.ProviderNetwork * circuits.ProviderNetwork
* Added `service_id` field * Added `service_id` field
* dcim.ConsolePort * dcim.ConsolePort
@ -105,6 +116,8 @@ Inventory item templates can be arranged hierarchically within a device type, an
* Add `cluster_types` field * Add `cluster_types` field
* extras.CustomField * extras.CustomField
* Added `object_type` field * Added `object_type` field
* extras.CustomLink
* Added `enabled` field
* ipam.VLANGroup * ipam.VLANGroup
* Added the `/availables-vlans/` endpoint * Added the `/availables-vlans/` endpoint
* Added the `min_vid` and `max_vid` fields * Added the `min_vid` and `max_vid` fields

View File

@ -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

View File

@ -16,6 +16,21 @@ theme:
toggle: toggle:
icon: material/lightbulb icon: material/lightbulb
name: Switch to Light Mode 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
show_root_heading: true
show_root_full_path: false
show_root_toc_entry: false
extra: extra:
social: social:
- icon: fontawesome/brands/github - icon: fontawesome/brands/github
@ -84,7 +99,10 @@ nav:
- Webhooks: 'additional-features/webhooks.md' - Webhooks: 'additional-features/webhooks.md'
- Plugins: - Plugins:
- Using Plugins: 'plugins/index.md' - Using Plugins: 'plugins/index.md'
- Developing Plugins: 'plugins/development.md' - Developing Plugins:
- Introduction: 'plugins/development/index.md'
- Model Features: 'plugins/development/model-features.md'
- Developing Plugins (Old): 'plugins/development.md'
- Administration: - Administration:
- Authentication: 'administration/authentication.md' - Authentication: 'administration/authentication.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'

View File

@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', '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', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied', '_occupied', 'created', 'last_updated',
] ]

View File

@ -5,8 +5,8 @@ from django.urls import reverse
from circuits.choices import * from circuits.choices import *
from dcim.models import LinkTermination from dcim.models import LinkTermination
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
from netbox.models.features import WebhooksMixin
__all__ = ( __all__ = (
'Circuit', 'Circuit',
@ -15,7 +15,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class CircuitType(OrganizationalModel): class CircuitType(OrganizationalModel):
""" """
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
@ -44,7 +43,6 @@ class CircuitType(OrganizationalModel):
return reverse('circuits:circuittype', args=[self.pk]) return reverse('circuits:circuittype', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Circuit(PrimaryModel): class Circuit(PrimaryModel):
""" """
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
@ -138,8 +136,7 @@ class Circuit(PrimaryModel):
return CircuitStatusChoices.colors.get(self.status, 'secondary') return CircuitStatusChoices.colors.get(self.status, 'secondary')
@extras_features('webhooks') class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -3,7 +3,6 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from dcim.fields import ASNField from dcim.fields import ASNField
from extras.utils import extras_features
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
__all__ = ( __all__ = (
@ -12,7 +11,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Provider(PrimaryModel): class Provider(PrimaryModel):
""" """
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@ -72,7 +70,6 @@ class Provider(PrimaryModel):
return reverse('circuits:provider', args=[self.pk]) return reverse('circuits:provider', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ProviderNetwork(PrimaryModel): class ProviderNetwork(PrimaryModel):
""" """
This represents a provider network which exists outside of NetBox, the details of which are unknown or This represents a provider network which exists outside of NetBox, the details of which are unknown or

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import BaseTable, ButtonsColumn, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn from utilities.tables import BaseTable, ChoiceFieldColumn, MarkdownColumn, TagColumn, ToggleColumn
from .models import * from .models import *
@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
{% endif %} {% endif %}
""" """
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
# #
# Providers # Providers
# #
class ProviderTable(BaseTable): class ProviderTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column( name = tables.Column(
@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', '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') default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -69,7 +90,9 @@ class ProviderNetworkTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ProviderNetwork 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') default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
@ -88,12 +111,13 @@ class CircuitTypeTable(BaseTable):
circuit_count = tables.Column( circuit_count = tables.Column(
verbose_name='Circuits' verbose_name='Circuits'
) )
actions = ButtonsColumn(CircuitType)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CircuitType model = CircuitType
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions') 'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
# #
@ -119,6 +143,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name='Side Z'
) )
commit_rate = CommitRateColumn()
comments = MarkdownColumn() comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
@ -128,7 +153,7 @@ class CircuitTable(BaseTable):
model = Circuit model = Circuit
fields = ( fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', '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 = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.tables import paginate_table from utilities.tables import configure_table
from utilities.utils import count_related from utilities.utils import count_related
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import CircuitTerminationSideChoices from .choices import CircuitTerminationSideChoices
@ -35,7 +35,7 @@ class ProviderView(generic.ObjectView):
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits, exclude=('provider',)) circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,
@ -96,7 +96,7 @@ class ProviderNetworkView(generic.ObjectView):
'type', 'tenant', 'terminations__site' 'type', 'tenant', 'terminations__site'
) )
circuits_table = tables.CircuitTable(circuits) circuits_table = tables.CircuitTable(circuits)
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,
@ -150,7 +150,7 @@ class CircuitTypeView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance) circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
circuits_table = tables.CircuitTable(circuits, exclude=('type',)) circuits_table = tables.CircuitTable(circuits, exclude=('type',))
paginate_table(circuits_table, request) configure_table(circuits_table, request)
return { return {
'circuits_table': circuits_table, 'circuits_table': circuits_table,

View File

@ -221,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ 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', 'custom_fields',
] ]
@ -914,7 +914,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', 'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', '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): def _get_termination(self, obj, side):
@ -1008,7 +1008,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = VirtualChassis 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',
]
# #
@ -1027,7 +1030,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = PowerPanel 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): class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack' TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' 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_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'), (TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), (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_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

View File

@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm): class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location model = Location
field_groups = [ field_groups = [
['q'], ['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'parent_id'], ['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'], ['tenant_group_id', 'tenant_id'],
] ]
@ -678,7 +678,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag'],
['site_id', 'rack_id', 'device_id'], ['site_id', 'rack_id', 'device_id'],
['type', 'status', 'color'], ['type', 'status', 'color', 'length', 'length_unit'],
['tenant_group_id', 'tenant_id'], ['tenant_group_id', 'tenant_id'],
] ]
region_id = DynamicModelMultipleChoiceField( region_id = DynamicModelMultipleChoiceField(
@ -703,6 +703,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
'site_id': '$site_id' '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( type = forms.MultipleChoiceField(
choices=add_blank_choice(CableTypeChoices), choices=add_blank_choice(CableTypeChoices),
required=False, required=False,
@ -716,15 +726,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
color = ColorField( color = ColorField(
required=False required=False
) )
device_id = DynamicModelMultipleChoiceField( length = forms.IntegerField(
queryset=Device.objects.all(), required=False
required=False, )
query_params={ length_unit = forms.ChoiceField(
'site_id': '$site_id', choices=add_blank_choice(CableLengthUnitChoices),
'tenant_id': '$tenant_id', required=False
'rack_id': '$rack_id',
},
label=_('Device')
) )
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -11,7 +11,6 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import PathField from dcim.fields import PathField
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object 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 BigIDModel, PrimaryModel
from utilities.fields import ColorField from utilities.fields import ColorField
from utilities.utils import to_meters from utilities.utils import to_meters
@ -29,7 +28,6 @@ __all__ = (
# Cables # Cables
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cable(PrimaryModel): class Cable(PrimaryModel):
""" """
A physical connection between two endpoints. A physical connection between two endpoints.

View File

@ -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.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
from utilities.mptt import TreeManager from utilities.mptt import TreeManager
from utilities.ordering import naturalize_interface from utilities.ordering import naturalize_interface
@ -32,7 +32,7 @@ __all__ = (
) )
class ComponentTemplateModel(ChangeLoggedModel): class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
device_type = models.ForeignKey( device_type = models.ForeignKey(
to='dcim.DeviceType', to='dcim.DeviceType',
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -135,7 +135,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
return self.name return self.name
@extras_features('webhooks')
class ConsolePortTemplate(ModularComponentTemplateModel): class ConsolePortTemplate(ModularComponentTemplateModel):
""" """
A template for a ConsolePort to be created for a new Device. A template for a ConsolePort to be created for a new Device.
@ -164,7 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class ConsoleServerPortTemplate(ModularComponentTemplateModel): class ConsoleServerPortTemplate(ModularComponentTemplateModel):
""" """
A template for a ConsoleServerPort to be created for a new Device. A template for a ConsoleServerPort to be created for a new Device.
@ -193,7 +191,6 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class PowerPortTemplate(ModularComponentTemplateModel): class PowerPortTemplate(ModularComponentTemplateModel):
""" """
A template for a PowerPort to be created for a new Device. A template for a PowerPort to be created for a new Device.
@ -245,7 +242,6 @@ class PowerPortTemplate(ModularComponentTemplateModel):
}) })
@extras_features('webhooks')
class PowerOutletTemplate(ModularComponentTemplateModel): class PowerOutletTemplate(ModularComponentTemplateModel):
""" """
A template for a PowerOutlet to be created for a new Device. A template for a PowerOutlet to be created for a new Device.
@ -307,7 +303,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class InterfaceTemplate(ModularComponentTemplateModel): class InterfaceTemplate(ModularComponentTemplateModel):
""" """
A template for a physical data interface on a new Device. A template for a physical data interface on a new Device.
@ -347,7 +342,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class FrontPortTemplate(ModularComponentTemplateModel): class FrontPortTemplate(ModularComponentTemplateModel):
""" """
Template for a pass-through port on the front of a new Device. Template for a pass-through port on the front of a new Device.
@ -420,7 +414,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class RearPortTemplate(ModularComponentTemplateModel): class RearPortTemplate(ModularComponentTemplateModel):
""" """
Template for a pass-through port on the rear of a new Device. Template for a pass-through port on the rear of a new Device.
@ -460,7 +453,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
) )
@extras_features('webhooks')
class ModuleBayTemplate(ComponentTemplateModel): class ModuleBayTemplate(ComponentTemplateModel):
""" """
A template for a ModuleBay to be created for a new parent Device. A template for a ModuleBay to be created for a new parent Device.
@ -486,7 +478,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
) )
@extras_features('webhooks')
class DeviceBayTemplate(ComponentTemplateModel): class DeviceBayTemplate(ComponentTemplateModel):
""" """
A template for a DeviceBay to be created for a new parent Device. A template for a DeviceBay to be created for a new parent Device.
@ -511,7 +502,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
) )
@extras_features('webhooks')
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
""" """
A template for an InventoryItem to be created for a new parent Device. A template for an InventoryItem to be created for a new parent Device.

View File

@ -11,7 +11,6 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.fields import MACAddressField, WWNField from dcim.fields import MACAddressField, WWNField
from dcim.svg import CableTraceSVG from dcim.svg import CableTraceSVG
from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField, NaturalOrderingField from utilities.fields import ColorField, NaturalOrderingField
@ -254,7 +253,6 @@ class PathEndpoint(models.Model):
# Console components # Console components
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint): class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts. A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@ -282,7 +280,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
return reverse('dcim:consoleport', kwargs={'pk': self.pk}) return reverse('dcim:consoleport', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint): class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts. A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@ -314,7 +311,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
# Power components # Power components
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint): class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets. A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@ -407,7 +403,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
} }
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint): class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
""" """
A physical power outlet (output) within a Device which provides power to a PowerPort. A physical power outlet (output) within a Device which provides power to a PowerPort.
@ -522,7 +517,6 @@ class BaseInterface(models.Model):
return self.fhrp_group_assignments.count() return self.fhrp_group_assignments.count()
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint): class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
""" """
A network interface within a Device. A physical Interface can connect to exactly one other Interface. A network interface within a Device. A physical Interface can connect to exactly one other Interface.
@ -805,7 +799,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
# Pass-through ports # Pass-through ports
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ModularComponentModel, LinkTermination): class FrontPort(ModularComponentModel, LinkTermination):
""" """
A pass-through port on the front of a Device. A pass-through port on the front of a Device.
@ -859,7 +852,6 @@ class FrontPort(ModularComponentModel, LinkTermination):
}) })
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RearPort(ModularComponentModel, LinkTermination): class RearPort(ModularComponentModel, LinkTermination):
""" """
A pass-through port on the rear of a Device. A pass-through port on the rear of a Device.
@ -903,7 +895,6 @@ class RearPort(ModularComponentModel, LinkTermination):
# Bays # Bays
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ModuleBay(ComponentModel): class ModuleBay(ComponentModel):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
@ -924,7 +915,6 @@ class ModuleBay(ComponentModel):
return reverse('dcim:modulebay', kwargs={'pk': self.pk}) return reverse('dcim:modulebay', kwargs={'pk': self.pk})
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceBay(ComponentModel): class DeviceBay(ComponentModel):
""" """
An empty space within a Device which can house a child device An empty space within a Device which can house a child device
@ -975,7 +965,6 @@ class DeviceBay(ComponentModel):
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItemRole(OrganizationalModel): class InventoryItemRole(OrganizationalModel):
""" """
Inventory items may optionally be assigned a functional role. Inventory items may optionally be assigned a functional role.
@ -1006,7 +995,6 @@ class InventoryItemRole(OrganizationalModel):
return reverse('dcim:inventoryitemrole', args=[self.pk]) return reverse('dcim:inventoryitemrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel): class InventoryItem(MPTTModel, ComponentModel):
""" """
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply. An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

View File

@ -13,7 +13,6 @@ from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.models import ConfigContextModel from extras.models import ConfigContextModel
from extras.querysets import ConfigContextModelQuerySet from extras.querysets import ConfigContextModelQuerySet
from extras.utils import extras_features
from netbox.config import ConfigItem from netbox.config import ConfigItem
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -37,7 +36,6 @@ __all__ = (
# Device Types # Device Types
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Manufacturer(OrganizationalModel): class Manufacturer(OrganizationalModel):
""" """
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell. A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@ -70,7 +68,6 @@ class Manufacturer(OrganizationalModel):
return reverse('dcim:manufacturer', args=[self.pk]) return reverse('dcim:manufacturer', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceType(PrimaryModel): class DeviceType(PrimaryModel):
""" """
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@ -353,7 +350,6 @@ class DeviceType(PrimaryModel):
return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ModuleType(PrimaryModel): class ModuleType(PrimaryModel):
""" """
A ModuleType represents a hardware element that can be installed within a device and which houses additional A ModuleType represents a hardware element that can be installed within a device and which houses additional
@ -487,7 +483,6 @@ class ModuleType(PrimaryModel):
# Devices # Devices
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceRole(OrganizationalModel): class DeviceRole(OrganizationalModel):
""" """
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a 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]) return reverse('dcim:devicerole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Platform(OrganizationalModel): class Platform(OrganizationalModel):
""" """
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos". Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
@ -575,7 +569,6 @@ class Platform(OrganizationalModel):
return reverse('dcim:platform', args=[self.pk]) return reverse('dcim:platform', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Device(PrimaryModel, ConfigContextModel): class Device(PrimaryModel, ConfigContextModel):
""" """
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType, A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@ -1012,7 +1005,6 @@ class Device(PrimaryModel, ConfigContextModel):
return DeviceStatusChoices.colors.get(self.status, 'secondary') return DeviceStatusChoices.colors.get(self.status, 'secondary')
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Module(PrimaryModel, ConfigContextModel): class Module(PrimaryModel, ConfigContextModel):
""" """
A Module represents a field-installable component within a Device which may itself hold multiple device components A Module represents a field-installable component within a Device which may itself hold multiple device components
@ -1095,7 +1087,6 @@ class Module(PrimaryModel, ConfigContextModel):
# Virtual chassis # Virtual chassis
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualChassis(PrimaryModel): class VirtualChassis(PrimaryModel):
""" """
A collection of Devices which operate with a shared control plane (e.g. a switch stack). A collection of Devices which operate with a shared control plane (e.g. a switch stack).

View File

@ -6,7 +6,6 @@ from django.urls import reverse
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from extras.utils import extras_features
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
from utilities.validators import ExclusionValidator from utilities.validators import ExclusionValidator
from .device_components import LinkTermination, PathEndpoint from .device_components import LinkTermination, PathEndpoint
@ -21,7 +20,6 @@ __all__ = (
# Power # Power
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPanel(PrimaryModel): class PowerPanel(PrimaryModel):
""" """
A distribution point for electrical power; e.g. a data center RPP. A distribution point for electrical power; e.g. a data center RPP.
@ -68,7 +66,6 @@ class PowerPanel(PrimaryModel):
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
""" """
An electrical circuit delivered from a PowerPanel. An electrical circuit delivered from a PowerPanel.

View File

@ -13,7 +13,6 @@ from django.urls import reverse
from dcim.choices import * from dcim.choices import *
from dcim.constants import * from dcim.constants import *
from dcim.svg import RackElevationSVG from dcim.svg import RackElevationSVG
from extras.utils import extras_features
from netbox.config import get_config from netbox.config import get_config
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
@ -34,7 +33,6 @@ __all__ = (
# Racks # Racks
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackRole(OrganizationalModel): class RackRole(OrganizationalModel):
""" """
Racks can be organized by functional role, similar to Devices. Racks can be organized by functional role, similar to Devices.
@ -65,7 +63,6 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk]) return reverse('dcim:rackrole', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Rack(PrimaryModel): class Rack(PrimaryModel):
""" """
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@ -438,7 +435,6 @@ class Rack(PrimaryModel):
return int(allocated_draw_total / available_power_total * 100) return int(allocated_draw_total / available_power_total * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackReservation(PrimaryModel): class RackReservation(PrimaryModel):
""" """
One or more reserved units within a Rack. One or more reserved units within a Rack.

View File

@ -7,8 +7,6 @@ from timezone_field import TimeZoneField
from dcim.choices import * from dcim.choices import *
from dcim.constants 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, PrimaryModel
from utilities.fields import NaturalOrderingField from utilities.fields import NaturalOrderingField
@ -24,7 +22,6 @@ __all__ = (
# Regions # Regions
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Region(NestedGroupModel): class Region(NestedGroupModel):
""" """
A region represents a geographic collection of sites. For example, you might create regions representing countries, 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 # Site groups
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class SiteGroup(NestedGroupModel): class SiteGroup(NestedGroupModel):
""" """
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
@ -198,7 +194,6 @@ class SiteGroup(NestedGroupModel):
# Sites # Sites
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Site(PrimaryModel): class Site(PrimaryModel):
""" """
A Site represents a geographic location within a network; typically a building or campus. The optional facility A Site represents a geographic location within a network; typically a building or campus. The optional facility
@ -322,7 +317,6 @@ class Site(PrimaryModel):
# Locations # Locations
# #
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Location(NestedGroupModel): class Location(NestedGroupModel):
""" """
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a

View File

@ -19,7 +19,12 @@ __all__ = (
def get_device_name(device): 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: class RackElevationSVG:

View File

@ -56,7 +56,7 @@ class CableTable(BaseTable):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', '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 = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -7,7 +7,7 @@ from dcim.models import (
) )
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn, MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
) )
from .template_code import * from .template_code import *
@ -94,15 +94,14 @@ class DeviceRoleTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:devicerole_list' url_name='dcim:devicerole_list'
) )
actions = ButtonsColumn(DeviceRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = DeviceRole model = DeviceRole
fields = ( fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', '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')
# #
@ -127,16 +126,15 @@ class PlatformTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:platform_list' url_name='dcim:platform_list'
) )
actions = ButtonsColumn(Platform)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Platform model = Platform
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions', 'description', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
) )
@ -207,7 +205,8 @@ class DeviceTable(BaseTable):
fields = ( fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', '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 = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@ -313,7 +312,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -324,10 +323,8 @@ class DeviceConsolePortTable(ConsolePortTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=ConsolePort, extra_buttons=CONSOLEPORT_BUTTONS
buttons=('edit', 'delete'),
prepend_template=CONSOLEPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -336,7 +333,7 @@ class DeviceConsolePortTable(ConsolePortTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions' '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 = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
} }
@ -357,7 +354,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -369,10 +366,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=ConsoleServerPort, extra_buttons=CONSOLESERVERPORT_BUTTONS
buttons=('edit', 'delete'),
prepend_template=CONSOLESERVERPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -381,7 +376,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected', 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', '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 = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
} }
@ -402,7 +397,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -414,10 +410,8 @@ class DevicePowerPortTable(PowerPortTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=PowerPort, extra_buttons=POWERPORT_BUTTONS
buttons=('edit', 'delete'),
prepend_template=POWERPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -428,7 +422,6 @@ class DevicePowerPortTable(PowerPortTable):
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection', 'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
'actions',
) )
row_attrs = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
@ -453,7 +446,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -464,10 +458,8 @@ class DevicePowerOutletTable(PowerOutletTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=PowerOutlet, extra_buttons=POWEROUTLET_BUTTONS
buttons=('edit', 'delete'),
prepend_template=POWEROUTLET_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -477,7 +469,7 @@ class DevicePowerOutletTable(PowerOutletTable):
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
) )
default_columns = ( 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 = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
@ -535,6 +527,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', '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', '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', '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') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -557,10 +550,8 @@ class DeviceInterfaceTable(InterfaceTable):
linkify=True, linkify=True,
verbose_name='LAG' verbose_name='LAG'
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=Interface, extra_buttons=INTERFACE_BUTTONS
buttons=('edit', 'delete'),
prepend_template=INTERFACE_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -575,7 +566,7 @@ class DeviceInterfaceTable(InterfaceTable):
order_by = ('name',) order_by = ('name',)
default_columns = ( default_columns = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
'cable', 'connection', 'actions', 'cable', 'connection',
) )
row_attrs = { row_attrs = {
'class': get_interface_row_class, 'class': get_interface_row_class,
@ -607,6 +598,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -620,10 +612,8 @@ class DeviceFrontPortTable(FrontPortTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=FrontPort, extra_buttons=FRONTPORT_BUTTONS
buttons=('edit', 'delete'),
prepend_template=FRONTPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -634,7 +624,6 @@ class DeviceFrontPortTable(FrontPortTable):
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer', 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
'actions',
) )
row_attrs = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
@ -657,7 +646,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -669,10 +658,8 @@ class DeviceRearPortTable(RearPortTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=RearPort, extra_buttons=REARPORT_BUTTONS
buttons=('edit', 'delete'),
prepend_template=REARPORT_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -682,7 +669,7 @@ class DeviceRearPortTable(RearPortTable):
'cable', 'cable_color', 'link_peer', 'tags', 'actions', 'cable', 'cable_color', 'link_peer', 'tags', 'actions',
) )
default_columns = ( default_columns = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions', 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
) )
row_attrs = { row_attrs = {
'class': get_cabletermination_row_class 'class': get_cabletermination_row_class
@ -709,7 +696,11 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = DeviceBay 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') default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@ -720,10 +711,8 @@ class DeviceDeviceBayTable(DeviceBayTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=DeviceBay, extra_buttons=DEVICEBAY_BUTTONS
buttons=('edit', 'delete'),
prepend_template=DEVICEBAY_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
@ -731,9 +720,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
fields = ( fields = (
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions', 'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
) )
default_columns = ( default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
)
class ModuleBayTable(DeviceComponentTable): class ModuleBayTable(DeviceComponentTable):
@ -758,16 +745,14 @@ class ModuleBayTable(DeviceComponentTable):
class DeviceModuleBayTable(ModuleBayTable): class DeviceModuleBayTable(ModuleBayTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=DeviceBay, extra_buttons=MODULEBAY_BUTTONS
buttons=('edit', 'delete'),
prepend_template=MODULEBAY_BUTTONS
) )
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = ModuleBay model = ModuleBay
fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions') 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): class InventoryItemTable(DeviceComponentTable):
@ -798,7 +783,7 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial', '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 = ( default_columns = (
'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
@ -812,10 +797,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
order_by=Accessor('_name'), order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
actions = ButtonsColumn( actions = ActionsColumn()
model=InventoryItem,
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InventoryItem model = InventoryItem
@ -824,7 +806,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
'description', 'discovered', 'tags', 'actions', 'description', 'discovered', 'tags', 'actions',
) )
default_columns = ( 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',
) )
@ -842,14 +824,13 @@ class InventoryItemRoleTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:inventoryitemrole_list' url_name='dcim:inventoryitemrole_list'
) )
actions = ButtonsColumn(InventoryItemRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InventoryItemRole model = InventoryItemRole
fields = ( fields = (
'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions', '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')
# #
@ -875,5 +856,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VirtualChassis 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') default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@ -6,7 +6,7 @@ from dcim.models import (
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
) )
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ActionsColumn, BaseTable, BooleanColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
) )
from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS
@ -48,16 +48,15 @@ class ManufacturerTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:manufacturer_list' url_name='dcim:manufacturer_list'
) )
actions = ButtonsColumn(Manufacturer)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Manufacturer model = Manufacturer
fields = ( fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
) )
@ -88,7 +87,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType model = DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', '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 = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', 'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
@ -113,10 +112,9 @@ class ComponentTemplateTable(BaseTable):
class ConsolePortTemplateTable(ComponentTemplateTable): class ConsolePortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=ConsolePortTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -126,10 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
class ConsoleServerPortTemplateTable(ComponentTemplateTable): class ConsoleServerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=ConsoleServerPortTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -139,10 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
class PowerPortTemplateTable(ComponentTemplateTable): class PowerPortTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=PowerPortTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -152,10 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable): class PowerOutletTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=PowerOutletTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -168,10 +163,9 @@ class InterfaceTemplateTable(ComponentTemplateTable):
mgmt_only = BooleanColumn( mgmt_only = BooleanColumn(
verbose_name='Management Only' verbose_name='Management Only'
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=InterfaceTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -185,10 +179,9 @@ class FrontPortTemplateTable(ComponentTemplateTable):
verbose_name='Position' verbose_name='Position'
) )
color = ColorColumn() color = ColorColumn()
actions = ButtonsColumn( actions = ActionsColumn(
model=FrontPortTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -199,10 +192,9 @@ class FrontPortTemplateTable(ComponentTemplateTable):
class RearPortTemplateTable(ComponentTemplateTable): class RearPortTemplateTable(ComponentTemplateTable):
color = ColorColumn() color = ColorColumn()
actions = ButtonsColumn( actions = ActionsColumn(
model=RearPortTemplate, sequence=('edit', 'delete'),
buttons=('edit', 'delete'), extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -212,9 +204,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
class ModuleBayTemplateTable(ComponentTemplateTable): class ModuleBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=ModuleBayTemplate, sequence=('edit', 'delete')
buttons=('edit', 'delete')
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -224,9 +215,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
class DeviceBayTemplateTable(ComponentTemplateTable): class DeviceBayTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=DeviceBayTemplate, sequence=('edit', 'delete')
buttons=('edit', 'delete')
) )
class Meta(ComponentTemplateTable.Meta): class Meta(ComponentTemplateTable.Meta):
@ -236,9 +226,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
class InventoryItemTemplateTable(ComponentTemplateTable): class InventoryItemTemplateTable(ComponentTemplateTable):
actions = ButtonsColumn( actions = ActionsColumn(
model=InventoryItemTemplate, sequence=('edit', 'delete')
buttons=('edit', 'delete')
) )
role = tables.Column( role = tables.Column(
linkify=True linkify=True

View File

@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = PowerPanel 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') default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = ( fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', 'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -4,8 +4,8 @@ from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole from dcim.models import Rack, RackReservation, RackRole
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn,
TagColumn, ToggleColumn, UtilizationColumn, ToggleColumn, UtilizationColumn,
) )
__all__ = ( __all__ = (
@ -27,12 +27,14 @@ class RackRoleTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:rackrole_list' url_name='dcim:rackrole_list'
) )
actions = ButtonsColumn(RackRole)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackRole model = RackRole
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions') 'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
# #
@ -87,8 +89,9 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@ -121,14 +124,11 @@ class RackReservationTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:rackreservation_list' url_name='dcim:rackreservation_list'
) )
actions = ButtonsColumn(RackReservation)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RackReservation model = RackReservation
fields = ( fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
) )
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')

View File

@ -3,9 +3,9 @@ import django_tables2 as tables
from dcim.models import Location, Region, Site, SiteGroup from dcim.models import Location, Region, Site, SiteGroup
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn, ActionsColumn, BaseTable, ChoiceFieldColumn, LinkedCountColumn, MarkdownColumn, MPTTColumn, TagColumn, ToggleColumn,
) )
from .template_code import LOCATION_ELEVATIONS from .template_code import LOCATION_BUTTONS
__all__ = ( __all__ = (
'LocationTable', 'LocationTable',
@ -32,12 +32,13 @@ class RegionTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:region_list' url_name='dcim:region_list'
) )
actions = ButtonsColumn(Region)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Region model = Region
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'site_count', 'description', 'actions') 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
# #
@ -57,12 +58,13 @@ class SiteGroupTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:sitegroup_list' url_name='dcim:sitegroup_list'
) )
actions = ButtonsColumn(SiteGroup)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = SiteGroup model = SiteGroup
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'site_count', 'description', 'actions') 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'site_count', 'description')
# #
@ -98,6 +100,7 @@ class SiteTable(BaseTable):
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
'created', 'last_updated', 'actions',
) )
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description') default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@ -128,15 +131,14 @@ class LocationTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='dcim:location_list' url_name='dcim:location_list'
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=Location, extra_buttons=LOCATION_BUTTONS
prepend_template=LOCATION_ELEVATIONS
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Location model = Location
fields = ( fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags', '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')

View File

@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """
<a href="{{ value.get_absolute_url }}">{{ value }}</a> <a href="{{ value.get_absolute_url }}">{{ value }}</a>
""" """
LOCATION_ELEVATIONS = """ LOCATION_BUTTONS = """
<a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?site={{ record.site.slug }}&location_id={{ record.pk }}" class="btn btn-sm btn-primary" title="View elevations">
<i class="mdi mdi-server"></i> <i class="mdi mdi-server"></i>
</a> </a>
@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
{% load helpers %} {% load helpers %}
{% if perms.dcim.add_invnetoryitemtemplate %} {% if perms.dcim.add_inventoryitemtemplate %}
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm"> <a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type_id }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a> </a>
{% endif %} {% endif %}

View File

@ -20,7 +20,7 @@ from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table from utilities.tables import configure_table
from utilities.utils import count_related from utilities.utils import count_related
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -165,7 +165,7 @@ class RegionView(generic.ObjectView):
region=instance region=instance
) )
sites_table = tables.SiteTable(sites, exclude=('region',)) sites_table = tables.SiteTable(sites, exclude=('region',))
paginate_table(sites_table, request) configure_table(sites_table, request)
return { return {
'child_regions_table': child_regions_table, 'child_regions_table': child_regions_table,
@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView):
group=instance group=instance
) )
sites_table = tables.SiteTable(sites, exclude=('group',)) sites_table = tables.SiteTable(sites, exclude=('group',))
paginate_table(sites_table, request) configure_table(sites_table, request)
return { return {
'child_groups_table': child_groups_table, 'child_groups_table': child_groups_table,
@ -422,7 +422,7 @@ class LocationView(generic.ObjectView):
cumulative=True cumulative=True
).filter(pk__in=location_ids).exclude(pk=instance.pk) ).filter(pk__in=location_ids).exclude(pk=instance.pk)
child_locations_table = tables.LocationTable(child_locations) child_locations_table = tables.LocationTable(child_locations)
paginate_table(child_locations_table, request) configure_table(child_locations_table, request)
return { return {
'rack_count': rack_count, 'rack_count': rack_count,
@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView):
) )
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization')) racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
paginate_table(racks_table, request) configure_table(racks_table, request)
return { return {
'racks_table': racks_table, 'racks_table': racks_table,
@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView):
) )
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',)) devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
paginate_table(devicetypes_table, request) configure_table(devicetypes_table, request)
return { return {
'devicetypes_table': devicetypes_table, 'devicetypes_table': devicetypes_table,
@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView):
device_role=instance device_role=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('device_role',)) devices_table = tables.DeviceTable(devices, exclude=('device_role',))
paginate_table(devices_table, request) configure_table(devices_table, request)
return { return {
'devices_table': devices_table, 'devices_table': devices_table,
@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView):
platform=instance platform=instance
) )
devices_table = tables.DeviceTable(devices, exclude=('platform',)) devices_table = tables.DeviceTable(devices, exclude=('platform',))
paginate_table(devices_table, request) configure_table(devices_table, request)
return { return {
'devices_table': devices_table, 'devices_table': devices_table,
@ -2379,8 +2379,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
device_bay.installed_device = form.cleaned_data['installed_device'] device_bay.installed_device = form.cleaned_data['installed_device']
device_bay.save() device_bay.save()
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay)) 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', { return render(request, 'dcim/devicebay_populate.html', {
'device_bay': device_bay, 'device_bay': device_bay,

View File

@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', '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', '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) type = ChoiceField(choices=CustomFieldTypeChoices)
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False) filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
data_type = serializers.SerializerMethodField()
class Meta: class Meta:
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', 'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', '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 # Custom links
@ -101,8 +115,8 @@ class CustomLinkSerializer(ValidatedModelSerializer):
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', 'button_class', 'new_window', 'created', 'last_updated',
] ]
@ -120,7 +134,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', '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: class Meta:
model = Tag 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',
]
# #

View File

@ -7,6 +7,7 @@ EXTRAS_FEATURES = [
'custom_links', 'custom_links',
'export_templates', 'export_templates',
'job_results', 'job_results',
'journaling',
'tags', 'tags',
'webhooks' 'webhooks'
] ]

View File

@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomLink 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): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm):
limit_choices_to=FeatureQuery('custom_fields'), limit_choices_to=FeatureQuery('custom_fields'),
required=False required=False
) )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
new_window = forms.NullBooleanField( new_window = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()

View File

@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm):
class Meta: class Meta:
model = CustomLink model = CustomLink
fields = ( 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',
) )

View File

@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
__all__ = ( __all__ = (
'CustomFieldModelCSVForm', 'CustomFieldModelCSVForm',
@ -34,6 +34,9 @@ class CustomFieldsMixin:
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model) 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): def _get_form_field(self, customfield):
return customfield.to_form_field() return customfield.to_form_field()
@ -41,10 +44,7 @@ class CustomFieldsMixin:
""" """
Append form fields for all CustomFields assigned to this object type. Append form fields for all CustomFields assigned to this object type.
""" """
content_type = self._get_content_type() for customfield in self._get_custom_fields(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):
field_name = f'cf_{customfield.name}' field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield) self.fields[field_name] = self._get_form_field(customfield)
@ -89,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
return customfield.to_form_field(for_csv_import=True) return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm): class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
def __init__(self, *args, **kwargs): def _get_form_field(self, customfield):
super().__init__(*args, **kwargs) return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_fields = [] def _append_customfield_fields(self):
self.obj_type = ContentType.objects.get_for_model(self.model) """
Append form fields for all CustomFields assigned to this object type.
# Add all applicable CustomFields to the form """
custom_fields = CustomField.objects.filter(content_types=self.obj_type) for customfield in self._get_custom_fields(self._get_content_type()):
for cf in custom_fields:
# Annotate non-required custom fields as nullable # Annotate non-required custom fields as nullable
if not cf.required: if not customfield.required:
self.nullable_fields.append(cf.name) self.nullable_fields.append(customfield.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field self.fields[customfield.name] = self._get_form_field(customfield)
self.custom_fields.append(cf.name)
# 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): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
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(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON) Q(type=CustomFieldTypeChoices.TYPE_JSON)
) )
for cf in custom_fields:
field_name = f'cf_{cf.name}' def _get_form_field(self, customfield):
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_field_filters.append(field_name)

View File

@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm):
class CustomLinkFilterForm(FilterForm): class CustomLinkFilterForm(FilterForm):
field_groups = [ field_groups = [
['q'], ['q'],
['content_type', 'weight', 'new_window'], ['content_type', 'enabled', 'new_window', 'weight'],
] ]
content_type = ContentTypeChoiceField( content_type = ContentTypeChoiceField(
queryset=ContentType.objects.all(), queryset=ContentType.objects.all(),
limit_choices_to=FeatureQuery('custom_fields'), limit_choices_to=FeatureQuery('custom_fields'),
required=False required=False
) )
weight = forms.IntegerField( enabled = forms.NullBooleanField(
required=False required=False,
widget=StaticSelect(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
) )
new_window = forms.NullBooleanField( new_window = forms.NullBooleanField(
required=False, required=False,
@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm):
choices=BOOLEAN_WITH_BLANK_CHOICES choices=BOOLEAN_WITH_BLANK_CHOICES
) )
) )
weight = forms.IntegerField(
required=False
)
class ExportTemplateFilterForm(FilterForm): class ExportTemplateFilterForm(FilterForm):

View File

@ -7,8 +7,8 @@ from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
) )
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Values', ('default', 'choices')), ('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )
widgets = {
'type': StaticSelect(),
'filter_logic': StaticSelect(),
}
class CustomLinkForm(BootstrapMixin, forms.ModelForm): class CustomLinkForm(BootstrapMixin, forms.ModelForm):
@ -53,10 +57,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
model = CustomLink model = CustomLink
fields = '__all__' fields = '__all__'
fieldsets = ( 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')), ('Templates', ('link_text', 'link_url')),
) )
widgets = { widgets = {
'button_class': StaticSelect(),
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}), 'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
'link_url': 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 model = ExportTemplate
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Custom Link', ('name', 'content_type', 'description')), ('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)), ('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
) )
@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
model = Webhook model = Webhook
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Webhook', ('name', 'enabled')), ('Webhook', ('name', 'content_types', 'enabled')),
('Assigned Models', ('content_types',)),
('Events', ('type_create', 'type_update', 'type_delete')), ('Events', ('type_create', 'type_update', 'type_delete')),
('HTTP Request', ( ('HTTP Request', (
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret', 'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
('Conditions', ('conditions',)), ('Conditions', ('conditions',)),
('SSL', ('ssl_verification', 'ca_file_path')), ('SSL', ('ssl_verification', 'ca_file_path')),
) )
labels = {
'type_create': 'Creations',
'type_update': 'Updates',
'type_delete': 'Deletions',
}
widgets = { widgets = {
'http_method': StaticSelect(),
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}), 'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}), 'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
} }

View File

@ -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),
),
]

View File

@ -5,8 +5,8 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import WebhooksMixin
from utilities.utils import deepmerge from utilities.utils import deepmerge
@ -20,8 +20,7 @@ __all__ = (
# Config contexts # Config contexts
# #
@extras_features('webhooks') class ConfigContext(WebhooksMixin, ChangeLoggedModel):
class ConfigContext(ChangeLoggedModel):
""" """
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned 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 qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B

View File

@ -12,12 +12,13 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from extras.choices import * from extras.choices import *
from extras.utils import FeatureQuery, extras_features from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities import filters from utilities import filters
from utilities.forms import ( from utilities.forms import (
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField, CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
StaticSelectMultiple, StaticSelect, add_blank_choice, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
) )
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex from utilities.validators import validate_regex
@ -40,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
return self.get_queryset().filter(content_types=content_type) return self.get_queryset().filter(content_types=content_type)
@extras_features('webhooks', 'export_templates') class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class CustomField(ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
related_name='custom_fields', related_name='custom_fields',
@ -283,7 +283,7 @@ class CustomField(ChangeLoggedModel):
""" """
Return a form field suitable for setting a CustomField's value for an object. 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. 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. for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
""" """
@ -332,7 +332,7 @@ class CustomField(ChangeLoggedModel):
choices=choices, required=required, initial=initial, widget=StaticSelect() choices=choices, required=required, initial=initial, widget=StaticSelect()
) )
else: else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class( field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
) )

View File

@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder
from extras.choices import * from extras.choices import *
from extras.constants import * from extras.constants import *
from extras.conditions import ConditionSet from extras.conditions import ConditionSet
from extras.utils import extras_features, FeatureQuery, image_upload from extras.utils import FeatureQuery, image_upload
from netbox.models import BigIDModel, ChangeLoggedModel from netbox.models import BigIDModel, ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.utils import render_jinja2 from utilities.utils import render_jinja2
@ -35,8 +36,7 @@ __all__ = (
) )
@extras_features('webhooks', 'export_templates') class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class Webhook(ChangeLoggedModel):
""" """
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or 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. 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( payload_url = models.CharField(
max_length=500, max_length=500,
verbose_name='URL', 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( enabled = models.BooleanField(
default=True default=True
@ -176,9 +177,14 @@ class Webhook(ChangeLoggedModel):
else: else:
return json.dumps(context, cls=JSONEncoder) 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 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. code to be rendered with an object as context.
@ -192,6 +198,9 @@ class CustomLink(ChangeLoggedModel):
max_length=100, max_length=100,
unique=True unique=True
) )
enabled = models.BooleanField(
default=True
)
link_text = models.CharField( link_text = models.CharField(
max_length=500, max_length=500,
help_text="Jinja2 template code for link text" help_text="Jinja2 template code for link text"
@ -248,8 +257,7 @@ class CustomLink(ChangeLoggedModel):
} }
@extras_features('webhooks', 'export_templates') class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
class ExportTemplate(ChangeLoggedModel):
content_type = models.ForeignKey( content_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.CASCADE, on_delete=models.CASCADE,
@ -335,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel):
return response return response
@extras_features('webhooks') class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
class ImageAttachment(ChangeLoggedModel):
""" """
An uploaded image which is associated with an object. An uploaded image which is associated with an object.
""" """
@ -414,8 +421,7 @@ class ImageAttachment(ChangeLoggedModel):
return super().to_objectchange(action, related_object=self.parent) return super().to_objectchange(action, related_object=self.parent)
@extras_features('webhooks') class JournalEntry(WebhooksMixin, ChangeLoggedModel):
class JournalEntry(ChangeLoggedModel):
""" """
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to 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 preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
@ -593,8 +599,7 @@ class ConfigRevision(models.Model):
# Custom scripts & reports # Custom scripts & reports
# #
@extras_features('job_results') class Script(JobResultsMixin, models.Model):
class Script(models.Model):
""" """
Dummy model used to generate permissions for custom scripts. Does not exist in the database. Dummy model used to generate permissions for custom scripts. Does not exist in the database.
""" """
@ -606,8 +611,7 @@ class Script(models.Model):
# Reports # Reports
# #
@extras_features('job_results') class Report(JobResultsMixin, models.Model):
class Report(models.Model):
""" """
Dummy model used to generate permissions for reports. Does not exist in the database. Dummy model used to generate permissions for reports. Does not exist in the database.
""" """

View File

@ -3,8 +3,8 @@ from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel from netbox.models import BigIDModel, ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField from utilities.fields import ColorField
@ -13,8 +13,7 @@ from utilities.fields import ColorField
# Tags # Tags
# #
@extras_features('webhooks', 'export_templates') class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
class Tag(ChangeLoggedModel, TagBase):
color = ColorField( color = ColorField(
default=ColorChoices.COLOR_GREY default=ColorChoices.COLOR_GREY
) )

View File

@ -1,3 +1,8 @@
import collections
from extras.constants import EXTRAS_FEATURES
class Registry(dict): class Registry(dict):
""" """
Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or 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: try:
return super().__getitem__(key) return super().__getitem__(key)
except KeyError: except KeyError:
raise KeyError("Invalid store: {}".format(key)) raise KeyError(f"Invalid store: {key}")
def __setitem__(self, key, value): def __setitem__(self, key, value):
if key in self: if key in self:
raise KeyError("Store already set: {}".format(key)) raise KeyError(f"Store already set: {key}")
super().__setitem__(key, value) super().__setitem__(key, value)
def __delitem__(self, key): def __delitem__(self, key):
raise TypeError("Cannot delete stores from registry") raise TypeError("Cannot delete stores from registry")
# Initialize the global registry
registry = Registry() registry = Registry()
registry['model_features'] = {
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
}

View File

@ -21,7 +21,7 @@ from extras.models import JobResult
from ipam.formfields import IPAddressFormField, IPNetworkFormField from ipam.formfields import IPAddressFormField, IPNetworkFormField
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
from utilities.exceptions import AbortTransaction 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 .context_managers import change_logging
from .forms import ScriptForm from .forms import ScriptForm
@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
def __init__(self, choices, *args, **kwargs): def __init__(self, choices, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Set field choices # Set field choices, adding a blank choice to avoid forced selections
self.field_attrs['choices'] = choices self.field_attrs['choices'] = add_blank_choice(choices)
class MultiChoiceVar(ChoiceVar): class MultiChoiceVar(ScriptVariable):
""" """
Like ChoiceVar, but allows for the selection of multiple choices. Like ChoiceVar, but allows for the selection of multiple choices.
""" """
form_field = forms.MultipleChoiceField 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): class ObjectVar(ScriptVariable):
""" """

View File

@ -2,7 +2,7 @@ import django_tables2 as tables
from django.conf import settings from django.conf import settings
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn, ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
MarkdownColumn, ToggleColumn, MarkdownColumn, ToggleColumn,
) )
from .models import * from .models import *
@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default', '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') default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable):
linkify=True linkify=True
) )
content_type = ContentTypeColumn() content_type = ContentTypeColumn()
enabled = BooleanColumn()
new_window = BooleanColumn() new_window = BooleanColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CustomLink model = CustomLink
fields = ( fields = (
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name', 'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window', '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')
# #
@ -100,6 +101,7 @@ class ExportTemplateTable(BaseTable):
model = ExportTemplate model = ExportTemplate
fields = ( fields = (
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment', 'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@ -134,7 +136,7 @@ class WebhookTable(BaseTable):
model = Webhook model = Webhook
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', '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 = ( default_columns = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method', 'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
@ -152,12 +154,11 @@ class TagTable(BaseTable):
linkify=True linkify=True
) )
color = ColorColumn() color = ColorColumn()
actions = ButtonsColumn(Tag)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Tag model = Tag
fields = ('pk', 'id', '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', 'actions') default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
class TaggedItemTable(BaseTable): class TaggedItemTable(BaseTable):
@ -193,7 +194,8 @@ class ConfigContextTable(BaseTable):
model = ConfigContext model = ConfigContext
fields = ( fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles', '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') default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
@ -215,6 +217,7 @@ class ObjectChangeTable(BaseTable):
template_code=OBJECTCHANGE_REQUEST_ID, template_code=OBJECTCHANGE_REQUEST_ID,
verbose_name='Request ID' verbose_name='Request ID'
) )
actions = ActionsColumn(sequence=())
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ObjectChange model = ObjectChange
@ -233,9 +236,6 @@ class ObjectJournalTable(BaseTable):
comments = tables.TemplateColumn( comments = tables.TemplateColumn(
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}' template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
) )
actions = ButtonsColumn(
model=JournalEntry
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = JournalEntry model = JournalEntry
@ -261,6 +261,5 @@ class JournalEntryTable(ObjectJournalTable):
'comments', 'actions' 'comments', 'actions'
) )
default_columns = ( default_columns = (
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
'comments', 'actions'
) )

View File

@ -36,7 +36,7 @@ def custom_links(context, obj):
Render all applicable links for the given object. Render all applicable links for the given object.
""" """
content_type = ContentType.objects.get_for_model(obj) 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: if not custom_links:
return '' return ''

View File

@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 4', 'name': 'Custom Link 4',
'enabled': True,
'link_text': 'Link 4', 'link_text': 'Link 4',
'link_url': 'http://example.com/?4', 'link_url': 'http://example.com/?4',
}, },
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 5', 'name': 'Custom Link 5',
'enabled': True,
'link_text': 'Link 5', 'link_text': 'Link 5',
'link_url': 'http://example.com/?5', 'link_url': 'http://example.com/?5',
}, },
{ {
'content_type': 'dcim.site', 'content_type': 'dcim.site',
'name': 'Custom Link 6', 'name': 'Custom Link 6',
'enabled': False,
'link_text': 'Link 6', 'link_text': 'Link 6',
'link_url': 'http://example.com/?6', 'link_url': 'http://example.com/?6',
}, },
] ]
bulk_update_data = { bulk_update_data = {
'new_window': True, 'new_window': True,
'enabled': False,
} }
@classmethod @classmethod
@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 1', name='Custom Link 1',
enabled=True,
link_text='Link 1', link_text='Link 1',
link_url='http://example.com/?1', link_url='http://example.com/?1',
), ),
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 2', name='Custom Link 2',
enabled=True,
link_text='Link 2', link_text='Link 2',
link_url='http://example.com/?2', link_url='http://example.com/?2',
), ),
CustomLink( CustomLink(
content_type=site_ct, content_type=site_ct,
name='Custom Link 3', name='Custom Link 3',
enabled=False,
link_text='Link 3', link_text='Link 3',
link_url='http://example.com/?3', link_url='http://example.com/?3',
), ),

View File

@ -378,9 +378,22 @@ class CustomFieldAPITest(APITestCase):
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'), 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_URL, name='url_field', default='http://example.com/1'),
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'), CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=( CustomField(
'Foo', 'Bar', 'Baz' 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( CustomField(
type=CustomFieldTypeChoices.TYPE_OBJECT, type=CustomFieldTypeChoices.TYPE_OBJECT,
name='object_field', name='object_field',
@ -416,11 +429,37 @@ class CustomFieldAPITest(APITestCase):
custom_fields[5].name: 'http://example.com/2', custom_fields[5].name: 'http://example.com/2',
custom_fields[6].name: '{"foo": 1, "bar": 2}', custom_fields[6].name: '{"foo": 1, "bar": 2}',
custom_fields[7].name: 'Bar', custom_fields[7].name: 'Bar',
custom_fields[8].name: vlans[1].pk, custom_fields[8].name: ['Bar', 'Baz'],
custom_fields[9].name: [vlans[2].pk, vlans[3].pk], custom_fields[9].name: vlans[1].pk,
custom_fields[10].name: [vlans[2].pk, vlans[3].pk],
} }
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): 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. Validate that custom fields are present on an object even if it has no values defined.
@ -439,7 +478,8 @@ class CustomFieldAPITest(APITestCase):
'date_field': None, 'date_field': None,
'url_field': None, 'url_field': None,
'json_field': None, 'json_field': None,
'choice_field': None, 'select_field': None,
'multiselect_field': None,
'object_field': None, 'object_field': None,
'multiobject_field': None, 'multiobject_field': None,
}) })
@ -462,7 +502,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response.data['custom_fields']['date_field'], site2_cfvs['date_field']) 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']['url_field'], site2_cfvs['url_field'])
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_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(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'])
self.assertEqual( self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']], [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
@ -495,7 +536,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], cf_defaults['date_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['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_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(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual( self.assertEqual(
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']], [obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
@ -511,7 +553,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_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['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_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['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
@ -530,7 +573,8 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 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, 'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}, },
@ -551,7 +595,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], data_cf['date_field']) self.assertEqual(response_cf['date_field'], data_cf['date_field'])
self.assertEqual(response_cf['url_field'], data_cf['url_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['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(response_cf['object_field']['id'], data_cf['object_field'])
self.assertEqual( self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']], [obj['id'] for obj in response_cf['multiobject_field']],
@ -567,7 +612,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field']) 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['url_field'], data_cf['url_field'])
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_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['object_field'], data_cf['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field']) self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
@ -611,7 +657,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], cf_defaults['date_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['url_field'], cf_defaults['url_field'])
self.assertEqual(response_cf['json_field'], cf_defaults['json_field']) self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
self.assertEqual(response_cf['choice_field'], cf_defaults['choice_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(response_cf['object_field']['id'], cf_defaults['object_field'])
self.assertEqual( self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']], [obj['id'] for obj in response_cf['multiobject_field']],
@ -627,7 +674,8 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), cf_defaults['date_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['url_field'], cf_defaults['url_field'])
self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field']) self.assertEqual(site.custom_field_data['json_field'], cf_defaults['json_field'])
self.assertEqual(site.custom_field_data['choice_field'], cf_defaults['choice_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['object_field'], cf_defaults['object_field'])
self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field']) self.assertEqual(site.custom_field_data['multiobject_field'], cf_defaults['multiobject_field'])
@ -643,7 +691,8 @@ class CustomFieldAPITest(APITestCase):
'date_field': '2020-01-02', 'date_field': '2020-01-02',
'url_field': 'http://example.com/2', 'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 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, 'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)), 'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
} }
@ -682,7 +731,9 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], custom_field_data['date_field']) 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['url_field'], custom_field_data['url_field'])
self.assertEqual(response_cf['json_field'], custom_field_data['json_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( self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']], [obj['id'] for obj in response_cf['multiobject_field']],
custom_field_data['multiobject_field'] custom_field_data['multiobject_field']
@ -697,7 +748,9 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field']) 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['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['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']) self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
def test_update_single_object_with_values(self): def test_update_single_object_with_values(self):
@ -728,7 +781,9 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(response_cf['date_field'], original_cfvs['date_field']) self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
self.assertEqual(response_cf['url_field'], original_cfvs['url_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['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( self.assertEqual(
[obj['id'] for obj in response_cf['multiobject_field']], [obj['id'] for obj in response_cf['multiobject_field']],
original_cfvs['multiobject_field'] original_cfvs['multiobject_field']
@ -743,7 +798,9 @@ class CustomFieldAPITest(APITestCase):
self.assertEqual(site2.custom_field_data['date_field'], original_cfvs['date_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['url_field'], original_cfvs['url_field'])
self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field']) self.assertEqual(site2.custom_field_data['json_field'], original_cfvs['json_field'])
self.assertEqual(site2.custom_field_data['choice_field'], original_cfvs['choice_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']) self.assertEqual(site2.custom_field_data['multiobject_field'], original_cfvs['multiobject_field'])
def test_minimum_maximum_values_validation(self): def test_minimum_maximum_values_validation(self):
@ -810,6 +867,9 @@ class CustomFieldImportTest(TestCase):
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[ CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C', '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: for cf in custom_fields:
cf.save() cf.save()
@ -820,19 +880,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField. Import a Site in CSV format, including a value for each CustomField.
""" """
data = ( data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'), ('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'), ('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'), ('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', '', '', '', '', '', '', '', ''), ('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
) )
csv_data = '\n'.join(','.join(row) for row in data) csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data}) response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1 # Validate data for site 1
site1 = Site.objects.get(name='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['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo') self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123) self.assertEqual(site1.custom_field_data['integer'], 123)
@ -841,10 +902,11 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1') 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['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A') 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 # Validate data for site 2
site2 = Site.objects.get(name='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['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar') self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456) self.assertEqual(site2.custom_field_data['integer'], 456)
@ -853,6 +915,7 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2') 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['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B') 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 # No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3') site3 = Site.objects.get(name='Site 3')

View File

@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 1', name='Custom Link 1',
content_type=content_types[0], content_type=content_types[0],
enabled=True,
weight=100, weight=100,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 2', name='Custom Link 2',
content_type=content_types[1], content_type=content_types[1],
enabled=True,
weight=200, weight=200,
new_window=False, new_window=False,
link_text='Link 1', link_text='Link 1',
@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
CustomLink( CustomLink(
name='Custom Link 3', name='Custom Link 3',
content_type=content_types[2], content_type=content_types[2],
enabled=False,
weight=300, weight=300,
new_window=True, new_window=True,
link_text='Link 1', link_text='Link 1',
@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
params = {'weight': [100, 200]} params = {'weight': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_new_window(self):
params = {'new_window': False} params = {'new_window': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
CustomLink.objects.bulk_create(( 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 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, link_text='Link 2', link_url='http://example.com/?2'), 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, link_text='Link 3', link_url='http://example.com/?3'), CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
)) ))
cls.form_data = { cls.form_data = {
'name': 'Custom Link X', 'name': 'Custom Link X',
'content_type': site_ct.pk, 'content_type': site_ct.pk,
'enabled': False,
'weight': 100, 'weight': 100,
'button_class': CustomLinkButtonClassChoices.DEFAULT, 'button_class': CustomLinkButtonClassChoices.DEFAULT,
'link_text': 'Link X', 'link_text': 'Link X',
@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( cls.csv_data = (
"name,content_type,weight,button_class,link_text,link_url", "name,content_type,enabled,weight,button_class,link_text,link_url",
"Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4", "Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
"Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5", "Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
"Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6", "Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
) )
cls.bulk_edit_data = { cls.bulk_edit_data = {
'button_class': CustomLinkButtonClassChoices.CYAN, 'button_class': CustomLinkButtonClassChoices.CYAN,
'enabled': False,
'weight': 200, 'weight': 200,
} }

View File

@ -1,5 +1,3 @@
import collections
from django.db.models import Q from django.db.models import Q
from django.utils.deconstruct import deconstructible from django.utils.deconstruct import deconstructible
from taggit.managers import _TaggableManager from taggit.managers import _TaggableManager
@ -57,21 +55,9 @@ class FeatureQuery:
return query return query
def extras_features(*features): def register_features(model, features):
""" for feature in features:
Decorator used to register extras provided features to a model if feature not in EXTRAS_FEATURES:
""" raise ValueError(f"{feature} is not a valid extras feature!")
def wrapper(model_class): app_label, model_name = model._meta.label_lower.split('.')
# Initialize the model_features store if not already defined registry['model_features'][feature][app_label].add(model_name)
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

View File

@ -11,7 +11,7 @@ from rq import Worker
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.htmx import is_htmx from utilities.htmx import is_htmx
from utilities.tables import paginate_table from utilities.tables import configure_table
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin from utilities.views import ContentTypePermissionRequiredMixin
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -215,7 +215,7 @@ class TagView(generic.ObjectView):
data=tagged_items, data=tagged_items,
orderable=False orderable=False
) )
paginate_table(taggeditem_table, request) configure_table(taggeditem_table, request)
object_types = [ object_types = [
{ {
@ -451,7 +451,7 @@ class ObjectChangeLogView(View):
data=objectchanges, data=objectchanges,
orderable=False orderable=False
) )
paginate_table(objectchanges_table, request) configure_table(objectchanges_table, request)
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise, # Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
# fall back to using base.html. # fall back to using base.html.
@ -571,7 +571,7 @@ class ObjectJournalView(View):
assigned_object_id=obj.pk assigned_object_id=obj.pk
) )
journalentry_table = tables.ObjectJournalTable(journalentries) journalentry_table = tables.ObjectJournalTable(journalentries)
paginate_table(journalentry_table, request) configure_table(journalentry_table, request)
if request.user.has_perm('extras.add_journalentry'): if request.user.has_perm('extras.add_journalentry'):
form = forms.JournalEntryForm( form = forms.JournalEntryForm(

View File

@ -67,7 +67,7 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
# Prepare the HTTP request # Prepare the HTTP request
params = { params = {
'method': webhook.http_method, 'method': webhook.http_method,
'url': webhook.payload_url, 'url': webhook.render_payload_url(context),
'headers': headers, 'headers': headers,
'data': body.encode('utf8'), 'data': body.encode('utf8'),
} }

View File

@ -15,6 +15,7 @@ __all__ = [
'NestedRoleSerializer', 'NestedRoleSerializer',
'NestedRouteTargetSerializer', 'NestedRouteTargetSerializer',
'NestedServiceSerializer', 'NestedServiceSerializer',
'NestedServiceTemplateSerializer',
'NestedVLANGroupSerializer', 'NestedVLANGroupSerializer',
'NestedVLANSerializer', 'NestedVLANSerializer',
'NestedVRFSerializer', 'NestedVRFSerializer',
@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
# Services # 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): class NestedServiceSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')

View File

@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer):
# Services # 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): class ServiceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
device = NestedDeviceSerializer(required=False, allow_null=True) device = NestedDeviceSerializer(required=False, allow_null=True)

View File

@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet)
router.register('vlans', views.VLANViewSet) router.register('vlans', views.VLANViewSet)
# Services # Services
router.register('service-templates', views.ServiceTemplateViewSet)
router.register('services', views.ServiceViewSet) router.register('services', views.ServiceViewSet)
app_name = 'ipam-api' app_name = 'ipam-api'

View File

@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet):
filterset_class = filtersets.VLANFilterSet 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( queryset = Service.objects.prefetch_related(
'device', 'virtual_machine', 'tags', 'ipaddresses' 'device', 'virtual_machine', 'tags', 'ipaddresses'
) )

View File

@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP, FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP, FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP, FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
} }

View File

@ -29,6 +29,7 @@ __all__ = (
'RoleFilterSet', 'RoleFilterSet',
'RouteTargetFilterSet', 'RouteTargetFilterSet',
'ServiceFilterSet', 'ServiceFilterSet',
'ServiceTemplateFilterSet',
'VLANFilterSet', 'VLANFilterSet',
'VLANGroupFilterSet', 'VLANGroupFilterSet',
'VRFFilterSet', 'VRFFilterSet',
@ -854,6 +855,28 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
return queryset.get_for_virtualmachine(value) return queryset.get_for_virtualmachine(value)
class ServiceTemplateFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
port = NumericArrayFilter(
field_name='ports',
lookup_expr='contains'
)
tag = TagFilter()
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(PrimaryModelFilterSet): class ServiceFilterSet(PrimaryModelFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@ -23,6 +23,7 @@ __all__ = (
'RoleBulkEditForm', 'RoleBulkEditForm',
'RouteTargetBulkEditForm', 'RouteTargetBulkEditForm',
'ServiceBulkEditForm', 'ServiceBulkEditForm',
'ServiceTemplateBulkEditForm',
'VLANBulkEditForm', 'VLANBulkEditForm',
'VLANGroupBulkEditForm', 'VLANGroupBulkEditForm',
'VRFBulkEditForm', 'VRFBulkEditForm',
@ -433,9 +434,9 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
] ]
class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm): class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Service.objects.all(), queryset=ServiceTemplate.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()
) )
protocol = forms.ChoiceField( protocol = forms.ChoiceField(
@ -459,3 +460,10 @@ class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
nullable_fields = [ nullable_fields = [
'description', 'description',
] ]
class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=Service.objects.all(),
widget=forms.MultipleHiddenInput()
)

View File

@ -21,6 +21,7 @@ __all__ = (
'RoleCSVForm', 'RoleCSVForm',
'RouteTargetCSVForm', 'RouteTargetCSVForm',
'ServiceCSVForm', 'ServiceCSVForm',
'ServiceTemplateCSVForm',
'VLANCSVForm', 'VLANCSVForm',
'VLANGroupCSVForm', 'VLANGroupCSVForm',
'VRFCSVForm', '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): class ServiceCSVForm(CustomFieldModelCSVForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),

View File

@ -24,6 +24,7 @@ __all__ = (
'RoleFilterForm', 'RoleFilterForm',
'RouteTargetFilterForm', 'RouteTargetFilterForm',
'ServiceFilterForm', 'ServiceFilterForm',
'ServiceTemplateFilterForm',
'VLANFilterForm', 'VLANFilterForm',
'VLANGroupFilterForm', 'VLANGroupFilterForm',
'VRFFilterForm', 'VRFFilterForm',
@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
tag = TagFilterField(model) tag = TagFilterField(model)
class ServiceFilterForm(CustomFieldModelFilterForm): class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
model = Service model = ServiceTemplate
field_groups = ( field_groups = (
('q', 'tag'), ('q', 'tag'),
('protocol', 'port'), ('protocol', 'port'),
@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm):
required=False, required=False,
) )
tag = TagFilterField(model) tag = TagFilterField(model)
class ServiceFilterForm(ServiceTemplateFilterForm):
model = Service

View File

@ -31,6 +31,8 @@ __all__ = (
'RoleForm', 'RoleForm',
'RouteTargetForm', 'RouteTargetForm',
'ServiceForm', 'ServiceForm',
'ServiceCreateForm',
'ServiceTemplateForm',
'VLANForm', 'VLANForm',
'VLANGroupForm', 'VLANGroupForm',
'VRFForm', 'VRFForm',
@ -580,7 +582,7 @@ class FHRPGroupForm(CustomFieldModelForm):
vrf=self.cleaned_data['ip_vrf'], vrf=self.cleaned_data['ip_vrf'],
address=self.cleaned_data['ip_address'], address=self.cleaned_data['ip_address'],
status=self.cleaned_data['ip_status'], 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 assigned_object=instance
) )
ipaddress.save() ipaddress.save()
@ -592,6 +594,8 @@ class FHRPGroupForm(CustomFieldModelForm):
return instance return instance
def clean(self): def clean(self):
super().clean()
ip_vrf = self.cleaned_data.get('ip_vrf') ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address') ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status') ip_status = self.cleaned_data.get('ip_status')
@ -628,8 +632,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
class VLANGroupForm(CustomFieldModelForm): class VLANGroupForm(CustomFieldModelForm):
scope_type = ContentTypeChoiceField( scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False, required=False
widget=StaticSelect
) )
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), 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): class ServiceForm(CustomFieldModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
@ -857,3 +881,36 @@ class ServiceForm(CustomFieldModelForm):
'protocol': StaticSelect(), 'protocol': StaticSelect(),
'ipaddresses': StaticSelectMultiple(), '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.")

View File

@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType):
service = ObjectField(ServiceType) service = ObjectField(ServiceType)
service_list = ObjectListField(ServiceType) service_list = ObjectListField(ServiceType)
service_template = ObjectField(ServiceTemplateType)
service_template_list = ObjectListField(ServiceTemplateType)
fhrp_group = ObjectField(FHRPGroupType) fhrp_group = ObjectField(FHRPGroupType)
fhrp_group_list = ObjectListField(FHRPGroupType) fhrp_group_list = ObjectListField(FHRPGroupType)

View File

@ -16,6 +16,7 @@ __all__ = (
'RoleType', 'RoleType',
'RouteTargetType', 'RouteTargetType',
'ServiceType', 'ServiceType',
'ServiceTemplateType',
'VLANType', 'VLANType',
'VLANGroupType', 'VLANGroupType',
'VRFType', 'VRFType',
@ -120,6 +121,14 @@ class ServiceType(PrimaryObjectType):
filterset_class = filtersets.ServiceFilterSet filterset_class = filtersets.ServiceFilterSet
class ServiceTemplateType(PrimaryObjectType):
class Meta:
model = models.ServiceTemplate
fields = '__all__'
filterset_class = filtersets.ServiceTemplateFilterSet
class VLANType(PrimaryObjectType): class VLANType(PrimaryObjectType):
class Meta: class Meta:

View File

@ -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',),
},
),
]

View File

@ -16,6 +16,7 @@ __all__ = (
'Role', 'Role',
'RouteTarget', 'RouteTarget',
'Service', 'Service',
'ServiceTemplate',
'VLAN', 'VLAN',
'VLANGroup', 'VLANGroup',
'VRF', 'VRF',

View File

@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.utils import extras_features
from netbox.models import ChangeLoggedModel, PrimaryModel from netbox.models import ChangeLoggedModel, PrimaryModel
from netbox.models.features import WebhooksMixin
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
@ -15,7 +15,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FHRPGroup(PrimaryModel): class FHRPGroup(PrimaryModel):
""" """
A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.) A grouping of next hope resolution protocol (FHRP) peers. (For instance, VRRP or HSRP.)
@ -70,8 +69,7 @@ class FHRPGroup(PrimaryModel):
return reverse('ipam:fhrpgroup', args=[self.pk]) return reverse('ipam:fhrpgroup', args=[self.pk])
@extras_features('webhooks') class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel):
class FHRPGroupAssignment(ChangeLoggedModel):
interface_type = models.ForeignKey( interface_type = models.ForeignKey(
to=ContentType, to=ContentType,
on_delete=models.CASCADE on_delete=models.CASCADE

View File

@ -9,7 +9,6 @@ from django.utils.functional import cached_property
from dcim.fields import ASNField from dcim.fields import ASNField
from dcim.models import Device from dcim.models import Device
from extras.utils import extras_features
from netbox.models import OrganizationalModel, PrimaryModel from netbox.models import OrganizationalModel, PrimaryModel
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin:
return available_prefixes.iter_cidrs()[0] return available_prefixes.iter_cidrs()[0]
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RIR(OrganizationalModel): class RIR(OrganizationalModel):
""" """
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
@ -90,7 +88,6 @@ class RIR(OrganizationalModel):
return reverse('ipam:rir', args=[self.pk]) return reverse('ipam:rir', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ASN(PrimaryModel): class ASN(PrimaryModel):
""" """
An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have
@ -125,13 +122,31 @@ class ASN(PrimaryModel):
verbose_name_plural = 'ASNs' verbose_name_plural = 'ASNs'
def __str__(self): def __str__(self):
return f'AS{self.asn}' return f'AS{self.asn_with_asdot}'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk]) 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
@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
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
""" """
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@ -234,7 +249,6 @@ class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
return min(utilization, 100) return min(utilization, 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Role(OrganizationalModel): class Role(OrganizationalModel):
""" """
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
@ -266,7 +280,6 @@ class Role(OrganizationalModel):
return reverse('ipam:role', args=[self.pk]) return reverse('ipam:role', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Prefix(GetAvailablePrefixesMixin, PrimaryModel): class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
""" """
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@ -544,7 +557,6 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
return min(utilization, 100) return min(utilization, 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class IPRange(PrimaryModel): class IPRange(PrimaryModel):
""" """
A range of IP addresses, defined by start and end addresses. A range of IP addresses, defined by start and end addresses.
@ -740,7 +752,6 @@ class IPRange(PrimaryModel):
return int(float(child_count) / self.size * 100) return int(float(child_count) / self.size * 100)
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class IPAddress(PrimaryModel): class IPAddress(PrimaryModel):
""" """
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is

View File

@ -4,7 +4,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.utils import extras_features
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
@ -13,11 +12,57 @@ from utilities.utils import array_to_string
__all__ = ( __all__ = (
'Service', 'Service',
'ServiceTemplate',
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks') class ServiceBase(models.Model):
class Service(PrimaryModel): 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, PrimaryModel):
"""
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, PrimaryModel):
""" """
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may 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. optionally be tied to one or more specific IPAddresses belonging to its parent.
@ -40,36 +85,16 @@ class Service(PrimaryModel):
name = models.CharField( name = models.CharField(
max_length=100 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( ipaddresses = models.ManyToManyField(
to='ipam.IPAddress', to='ipam.IPAddress',
related_name='services', related_name='services',
blank=True, blank=True,
verbose_name='IP addresses' verbose_name='IP addresses'
) )
description = models.CharField(
max_length=200,
blank=True
)
class Meta: class Meta:
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique 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): def get_absolute_url(self):
return reverse('ipam:service', args=[self.pk]) 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.") raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
if not self.device and not self.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.") 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)

View File

@ -6,7 +6,6 @@ from django.db import models
from django.urls import reverse from django.urls import reverse
from dcim.models import Interface from dcim.models import Interface
from extras.utils import extras_features
from ipam.choices import * from ipam.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.querysets import VLANQuerySet from ipam.querysets import VLANQuerySet
@ -20,7 +19,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VLANGroup(OrganizationalModel): class VLANGroup(OrganizationalModel):
""" """
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
@ -118,7 +116,6 @@ class VLANGroup(OrganizationalModel):
return None return None
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VLAN(PrimaryModel): class VLAN(PrimaryModel):
""" """
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned

View File

@ -1,7 +1,6 @@
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from extras.utils import extras_features
from ipam.constants import * from ipam.constants import *
from netbox.models import PrimaryModel from netbox.models import PrimaryModel
@ -12,7 +11,6 @@ __all__ = (
) )
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VRF(PrimaryModel): class VRF(PrimaryModel):
""" """
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@ -75,7 +73,6 @@ class VRF(PrimaryModel):
return reverse('ipam:vrf', args=[self.pk]) return reverse('ipam:vrf', args=[self.pk])
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RouteTarget(PrimaryModel): class RouteTarget(PrimaryModel):
""" """
A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364. A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.

View File

@ -1,6 +1,6 @@
import django_tables2 as tables import django_tables2 as tables
from utilities.tables import BaseTable, ButtonsColumn, MarkdownColumn, TagColumn, ToggleColumn from utilities.tables import ActionsColumn, BaseTable, MarkdownColumn, TagColumn, ToggleColumn
from ipam.models import * from ipam.models import *
__all__ = ( __all__ = (
@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable):
model = FHRPGroup model = FHRPGroup
fields = ( fields = (
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count', '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') default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
@ -58,9 +58,8 @@ class FHRPGroupAssignmentTable(BaseTable):
group = tables.Column( group = tables.Column(
linkify=True linkify=True
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=FHRPGroupAssignment, sequence=('edit', 'delete')
buttons=('edit', 'delete', 'foo')
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):

View File

@ -2,12 +2,11 @@ import django_tables2 as tables
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
from ipam.models import *
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn,
ToggleColumn, UtilizationColumn,
) )
from ipam.models import *
__all__ = ( __all__ = (
'AggregateTable', 'AggregateTable',
@ -89,12 +88,14 @@ class RIRTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='ipam:rir_list' url_name='ipam:rir_list'
) )
actions = ButtonsColumn(RIR)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RIR model = RIR
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions') 'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created',
'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description')
# #
@ -104,19 +105,20 @@ class RIRTable(BaseTable):
class ASNTable(BaseTable): class ASNTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
asn = tables.Column( asn = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True linkify=True
) )
site_count = LinkedCountColumn( site_count = LinkedCountColumn(
viewname='dcim:site_list', viewname='dcim:site_list',
url_params={'asn_id': 'pk'}, url_params={'asn_id': 'pk'},
verbose_name='Sites' verbose_name='Sites'
) )
actions = ButtonsColumn(ASN)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ASN model = ASN
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions') fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions')
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant')
# #
@ -147,7 +149,10 @@ class AggregateTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Aggregate 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') default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@ -173,12 +178,14 @@ class RoleTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='ipam:role_list' url_name='ipam:role_list'
) )
actions = ButtonsColumn(Role)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Role model = Role
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions') fields = (
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions') 'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created',
'last_updated', 'actions',
)
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description')
# #
@ -264,8 +271,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Prefix model = Prefix
fields = ( fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group', 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@ -306,7 +313,7 @@ class IPRangeTable(BaseTable):
model = IPRange model = IPRange
fields = ( fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization', 'tags', 'utilization', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description', 'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@ -364,7 +371,7 @@ class IPAddressTable(BaseTable):
model = IPAddress model = IPAddress
fields = ( fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description', 'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'tags', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description', 'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
@ -405,9 +412,6 @@ class AssignedIPAddressesTable(BaseTable):
) )
status = ChoiceFieldColumn() status = ChoiceFieldColumn()
tenant = TenantColumn() tenant = TenantColumn()
actions = ButtonsColumn(
model=IPAddress
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = IPAddress model = IPAddress

View File

@ -5,12 +5,27 @@ from ipam.models import *
__all__ = ( __all__ = (
'ServiceTable', 'ServiceTable',
'ServiceTemplateTable',
) )
# class ServiceTemplateTable(BaseTable):
# Services pk = ToggleColumn()
# name = tables.Column(
linkify=True
)
ports = tables.Column(
accessor=tables.A('port_list')
)
tags = TagColumn(
url_name='ipam:servicetemplate_list'
)
class Meta(BaseTable.Meta):
model = ServiceTemplate
fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags')
default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
class ServiceTable(BaseTable): class ServiceTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
@ -21,9 +36,8 @@ class ServiceTable(BaseTable):
linkify=True, linkify=True,
order_by=('device', 'virtual_machine') order_by=('device', 'virtual_machine')
) )
ports = tables.TemplateColumn( ports = tables.Column(
template_code='{{ record.port_list }}', accessor=tables.A('port_list')
verbose_name='Ports'
) )
tags = TagColumn( tags = TagColumn(
url_name='ipam:service_list' url_name='ipam:service_list'
@ -31,5 +45,8 @@ class ServiceTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Service 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') default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -5,7 +5,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import TenantColumn from tenancy.tables import TenantColumn
from utilities.tables import ( from utilities.tables import (
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn, ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
TemplateColumn, ToggleColumn, TemplateColumn, ToggleColumn,
) )
from virtualization.models import VMInterface from virtualization.models import VMInterface
@ -38,7 +38,7 @@ VLAN_PREFIXES = """
{% endfor %} {% endfor %}
""" """
VLANGROUP_ADD_VLAN = """ VLANGROUP_BUTTONS = """
{% with next_vid=record.get_next_available_vid %} {% with next_vid=record.get_next_available_vid %}
{% if next_vid and perms.ipam.add_vlan %} {% if next_vid and perms.ipam.add_vlan %}
<a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success"> <a href="{% url 'ipam:vlan_add' %}?group={{ record.pk }}&vid={{ next_vid }}" title="Add VLAN" class="btn btn-sm btn-success">
@ -77,18 +77,17 @@ class VLANGroupTable(BaseTable):
tags = TagColumn( tags = TagColumn(
url_name='ipam:vlangroup_list' url_name='ipam:vlangroup_list'
) )
actions = ButtonsColumn( actions = ActionsColumn(
model=VLANGroup, extra_buttons=VLANGROUP_BUTTONS
prepend_template=VLANGROUP_ADD_VLAN
) )
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLANGroup model = VLANGroup
fields = ( fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description', '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')
# #
@ -128,7 +127,10 @@ class VLANTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLAN 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') default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '', 'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
@ -153,7 +155,9 @@ class VLANDevicesTable(VLANMembersTable):
device = tables.Column( device = tables.Column(
linkify=True linkify=True
) )
actions = ButtonsColumn(Interface, buttons=['edit']) actions = ActionsColumn(
sequence=('edit',)
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface model = Interface
@ -165,7 +169,9 @@ class VLANVirtualMachinesTable(VLANMembersTable):
virtual_machine = tables.Column( virtual_machine = tables.Column(
linkify=True linkify=True
) )
actions = ButtonsColumn(VMInterface, buttons=['edit']) actions = ActionsColumn(
sequence=('edit',)
)
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VMInterface model = VMInterface

View File

@ -47,7 +47,8 @@ class VRFTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VRF model = VRF
fields = ( 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') default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RouteTarget 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') default_columns = ('pk', 'name', 'tenant', 'description')

View File

@ -832,6 +832,41 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
self.assertTrue(content['detail'].startswith('Unable to delete object.')) 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): class ServiceTest(APIViewTestCases.APIViewTestCase):
model = Service model = Service
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url'] brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']

View File

@ -1307,6 +1307,35 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global 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): class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Service.objects.all() queryset = Service.objects.all()
filterset = ServiceFilterSet filterset = ServiceFilterSet

View File

@ -1,5 +1,7 @@
import datetime import datetime
from django.test import override_settings
from django.urls import reverse
from netaddr import IPNetwork from netaddr import IPNetwork
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Role model = Role
@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPRange model = IPRange
@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'description': 'New description', '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): class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = IPAddress 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): class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Service model = Service
@ -607,3 +719,30 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'ports': '106,107', 'ports': '106,107',
'description': 'New description', '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)

View File

@ -162,9 +162,21 @@ urlpatterns = [
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}), path('vlans/<int:pk>/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/<int:pk>/', views.ServiceTemplateView.as_view(), name='servicetemplate'),
path('service-templates/<int:pk>/edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'),
path('service-templates/<int:pk>/delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'),
path('service-templates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}),
path('service-templates/<int:pk>/journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}),
# Services # Services
path('services/', views.ServiceListView.as_view(), name='service_list'), 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/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

View File

@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet
from dcim.models import Interface, Site from dcim.models import Interface, Site
from dcim.tables import SiteTable from dcim.tables import SiteTable
from netbox.views import generic from netbox.views import generic
from utilities.tables import paginate_table from utilities.tables import configure_table
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import VMInterfaceFilterSet from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VMInterface from virtualization.models import VMInterface
@ -161,7 +161,7 @@ class RIRView(generic.ObjectView):
rir=instance rir=instance
) )
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization')) aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
paginate_table(aggregates_table, request) configure_table(aggregates_table, request)
return { return {
'aggregates_table': aggregates_table, 'aggregates_table': aggregates_table,
@ -219,7 +219,7 @@ class ASNView(generic.ObjectView):
def get_extra_context(self, request, instance): def get_extra_context(self, request, instance):
sites = instance.sites.restrict(request.user, 'view') sites = instance.sites.restrict(request.user, 'view')
sites_table = SiteTable(sites) sites_table = SiteTable(sites)
paginate_table(sites_table, request) configure_table(sites_table, request)
return { return {
'sites_table': sites_table, 'sites_table': sites_table,
@ -356,7 +356,7 @@ class RoleView(generic.ObjectView):
) )
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization')) prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
paginate_table(prefixes_table, request) configure_table(prefixes_table, request)
return { return {
'prefixes_table': prefixes_table, 'prefixes_table': prefixes_table,
@ -505,9 +505,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent): def get_children(self, request, parent):
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related( return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
'vrf', 'role', 'tenant',
)
def prep_table_data(self, request, queryset, parent): def prep_table_data(self, request, queryset, parent):
show_available = bool(request.GET.get('show_available', 'true') == 'true') show_available = bool(request.GET.get('show_available', 'true') == 'true')
@ -531,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
class PrefixDeleteView(generic.ObjectDeleteView): class PrefixDeleteView(generic.ObjectDeleteView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
template_name = 'ipam/prefix_delete.html'
class PrefixBulkImportView(generic.BulkImportView): class PrefixBulkImportView(generic.BulkImportView):
@ -664,7 +661,7 @@ class IPAddressView(generic.ObjectView):
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
) )
related_ips_table = tables.IPAddressTable(related_ips, orderable=False) related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
paginate_table(related_ips_table, request) configure_table(related_ips_table, request)
return { return {
'parent_prefixes_table': parent_prefixes_table, 'parent_prefixes_table': parent_prefixes_table,
@ -800,7 +797,7 @@ class VLANGroupView(generic.ObjectView):
vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes')) 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'): if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
vlans_table.columns.show('pk') vlans_table.columns.show('pk')
paginate_table(vlans_table, request) configure_table(vlans_table, request)
# Compile permissions list for rendering the object table # Compile permissions list for rendering the object table
permissions = { permissions = {
@ -1031,6 +1028,49 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
table = tables.VLANTable 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 # Services
# #
@ -1047,22 +1087,28 @@ class ServiceView(generic.ObjectView):
queryset = Service.objects.prefetch_related('ipaddresses') 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): class ServiceEditView(generic.ObjectEditView):
queryset = Service.objects.prefetch_related('ipaddresses') queryset = Service.objects.prefetch_related('ipaddresses')
model_form = forms.ServiceForm model_form = forms.ServiceForm
template_name = 'ipam/service_edit.html' template_name = 'ipam/service_edit.html'
class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
class ServiceBulkImportView(generic.BulkImportView): class ServiceBulkImportView(generic.BulkImportView):
queryset = Service.objects.all() queryset = Service.objects.all()
model_form = forms.ServiceCSVForm model_form = forms.ServiceCSVForm
table = tables.ServiceTable table = tables.ServiceTable
class ServiceDeleteView(generic.ObjectDeleteView):
queryset = Service.objects.all()
class ServiceBulkEditView(generic.BulkEditView): class ServiceBulkEditView(generic.BulkEditView):
queryset = Service.objects.prefetch_related('device', 'virtual_machine') queryset = Service.objects.prefetch_related('device', 'virtual_machine')
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet

View File

@ -0,0 +1,135 @@
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__ = (
'BigIDModel',
'ChangeLoggedModel',
'NestedGroupModel',
'OrganizationalModel',
'PrimaryModel',
)
#
# Base model classes
#
class BaseModel(
CustomFieldsMixin,
CustomLinksMixin,
CustomValidationMixin,
ExportTemplatesMixin,
JournalingMixin,
TagsMixin,
WebhooksMixin,
):
class Meta:
abstract = True
class BigIDModel(models.Model):
"""
Abstract base model for all data objects. Ensures the use of a 64-bit PK.
"""
id = models.BigAutoField(
primary_key=True
)
class Meta:
abstract = True
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
"""
Base model for all objects which support change logging.
"""
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
class PrimaryModel(BaseModel, ChangeLoggingMixin, BigIDModel):
"""
Primary models represent real objects within the infrastructure being modeled.
"""
objects = RestrictedQuerySet.as_manager()
class Meta:
abstract = True
class NestedGroupModel(BaseModel, ChangeLoggingMixin, 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(BaseModel, ChangeLoggingMixin, 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',)

View File

@ -1,34 +1,39 @@
import logging import logging
from django.contrib.contenttypes.fields import GenericRelation 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.serializers.json import DjangoJSONEncoder
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from extras.choices import ObjectChangeActionChoices from extras.choices import ObjectChangeActionChoices
from extras.utils import register_features
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.mptt import TreeManager
from utilities.querysets import RestrictedQuerySet
from utilities.utils import serialize_object from utilities.utils import serialize_object
__all__ = ( __all__ = (
'BigIDModel', 'ChangeLoggingMixin',
'ChangeLoggedModel', 'CustomFieldsMixin',
'NestedGroupModel', 'CustomLinksMixin',
'OrganizationalModel', 'CustomValidationMixin',
'PrimaryModel', 'ExportTemplatesMixin',
'JobResultsMixin',
'JournalingMixin',
'TagsMixin',
'WebhooksMixin',
) )
# #
# Mixins # Feature mixins
# #
class ChangeLoggingMixin(models.Model): 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( created = models.DateField(
auto_now_add=True, auto_now_add=True,
@ -74,7 +79,7 @@ class ChangeLoggingMixin(models.Model):
class CustomFieldsMixin(models.Model): class CustomFieldsMixin(models.Model):
""" """
Provides support for custom fields. Enables support for custom fields.
""" """
custom_field_data = models.JSONField( custom_field_data = models.JSONField(
encoder=DjangoJSONEncoder, encoder=DjangoJSONEncoder,
@ -88,13 +93,25 @@ class CustomFieldsMixin(models.Model):
@property @property
def cf(self): 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 return self.custom_field_data
def get_custom_fields(self): def get_custom_fields(self):
""" """
Return a dictionary of custom fields for a single object in the form {<field>: 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()
{<CustomField: Customer ID>: 'CYB01'}
```
""" """
from extras.models import CustomField from extras.models import CustomField
@ -128,9 +145,17 @@ class CustomFieldsMixin(models.Model):
raise ValidationError(f"Missing required custom field '{cf.name}'.") 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): 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: class Meta:
abstract = True abstract = True
@ -142,9 +167,41 @@ class CustomValidationMixin(models.Model):
post_clean.send(sender=self.__class__, instance=self) 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): 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( tags = TaggableManager(
through='extras.TaggedItem' through='extras.TaggedItem'
@ -154,113 +211,28 @@ class TagsMixin(models.Model):
abstract = True abstract = True
# class WebhooksMixin(models.Model):
# Base model classes
class BigIDModel(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: class Meta:
abstract = True abstract = True
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel): FEATURES_MAP = (
""" ('custom_fields', CustomFieldsMixin),
Base model for all objects which support change logging. ('custom_links', CustomLinksMixin),
""" ('export_templates', ExportTemplatesMixin),
objects = RestrictedQuerySet.as_manager() ('job_results', JobResultsMixin),
('journaling', JournalingMixin),
class Meta: ('tags', TagsMixin),
abstract = True ('webhooks', WebhooksMixin),
)
class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel): @receiver(class_prepared)
""" def _register_features(sender, **kwargs):
Primary models represent real objects within the infrastructure being modeled. features = {
""" feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
journal_entries = GenericRelation( }
to='extras.JournalEntry', register_features(sender, features)
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',)

View File

@ -264,6 +264,7 @@ IPAM_MENU = Menu(
label='Other', label='Other',
items=( items=(
get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'), get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
get_model_item('ipam', 'servicetemplate', 'Service Templates'),
get_model_item('ipam', 'service', 'Services'), get_model_item('ipam', 'service', 'Services'),
), ),
), ),

View File

@ -26,6 +26,16 @@ PREFERENCES = {
description='The number of objects to display per page', description='The number of objects to display per page',
coerce=lambda x: int(x) 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 # Miscellaneous
'data_format': UserPreference( 'data_format': UserPreference(

View File

@ -287,7 +287,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
def _update_objects(self, form, request): def _update_objects(self, form, request):
custom_fields = getattr(form, 'custom_fields', []) custom_fields = getattr(form, 'custom_fields', [])
standard_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') nullified_fields = request.POST.getlist('_nullify')
updated_objects = [] updated_objects = []

Some files were not shown because too many files have changed in this diff Show More