mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -06:00
Merge branch 'feature' of https://github.com/netbox-community/netbox into 7853-speed_duplex
This commit is contained in:
commit
375a140343
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.4
|
||||
placeholder: v3.1.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.1.4
|
||||
placeholder: v3.1.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -82,6 +82,10 @@ markdown-include
|
||||
# https://github.com/squidfunk/mkdocs-material
|
||||
mkdocs-material
|
||||
|
||||
# Introspection for embedded code
|
||||
# https://github.com/mkdocstrings/mkdocstrings
|
||||
mkdocstrings
|
||||
|
||||
# Library for manipulating IP prefixes and addresses
|
||||
# https://github.com/drkjam/netaddr
|
||||
netaddr
|
||||
@ -98,10 +102,6 @@ psycopg2-binary
|
||||
# https://github.com/yaml/pyyaml
|
||||
PyYAML
|
||||
|
||||
# In-memory key/value store used for caching and queuing
|
||||
# https://github.com/andymccurdy/redis-py
|
||||
redis
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core
|
||||
social-auth-core[all]
|
||||
|
@ -1,3 +1,4 @@
|
||||
# Service Mapping
|
||||
|
||||
{!models/ipam/servicetemplate.md!}
|
||||
{!models/ipam/service.md!}
|
||||
|
@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
|
||||
!!! info "IDE Integration"
|
||||
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
|
||||
|
||||
## Populating Demo Data
|
||||
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <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
|
||||
|
||||
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.
|
||||
|
@ -4,9 +4,11 @@ The `users.UserConfig` model holds individual preferences for each user in the f
|
||||
|
||||
## Available Preferences
|
||||
|
||||
| Name | Description |
|
||||
|-------------------------|-------------|
|
||||
| data_format | Preferred format when rendering raw data (JSON or YAML) |
|
||||
| pagination.per_page | The number of items to display per page of a paginated table |
|
||||
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
|
||||
| ui.colormode | Light or dark mode in the user interface |
|
||||
| Name | Description |
|
||||
|--------------------------|---------------------------------------------------------------|
|
||||
| data_format | Preferred format when rendering raw data (JSON or YAML) |
|
||||
| pagination.per_page | The number of items to display per page of a paginated table |
|
||||
| pagination.placement | Where to display the paginator controls relative to the table |
|
||||
| tables.${table}.columns | The ordered list of columns to display when viewing the table |
|
||||
| tables.${table}.ordering | A list of column names by which the table should be ordered |
|
||||
| ui.colormode | Light or dark mode in the user interface |
|
||||
|
@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
|
||||
| Application | Django/Python |
|
||||
| Database | PostgreSQL 10+ |
|
||||
| Task queuing | Redis/django-rq |
|
||||
| Live device access | NAPALM |
|
||||
| Live device access | NAPALM (optional) |
|
||||
|
||||
## Supported Python Versions
|
||||
|
||||
@ -58,4 +58,6 @@ NetBox supports Python 3.8, 3.9, and 3.10 environments.
|
||||
|
||||
## Getting Started
|
||||
|
||||
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
|
||||
Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
|
||||
|
||||
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.
|
||||
|
@ -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>
|
||||
```
|
||||
|
||||
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
Custom links appear as buttons in the top right corner of the page. Numeric weighting can be used to influence the ordering of links, and each link can be enabled or disabled individually.
|
||||
|
||||
!!! warning
|
||||
Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users.
|
||||
|
@ -3,7 +3,7 @@
|
||||
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are managed under Logging > Webhooks.
|
||||
|
||||
!!! warning
|
||||
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
|
||||
Webhooks support the inclusion of user-submitted code to generate URL, custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
|
||||
|
||||
## Configuration
|
||||
|
||||
@ -12,7 +12,7 @@ A webhook is a mechanism for conveying to some external system a change that too
|
||||
* **Enabled** - If unchecked, the webhook will be inactive.
|
||||
* **Events** - A webhook may trigger on any combination of create, update, and delete events. At least one event type must be selected.
|
||||
* **HTTP method** - The type of HTTP request to send. Options include `GET`, `POST`, `PUT`, `PATCH`, and `DELETE`.
|
||||
* **URL** - The fuly-qualified URL of the request to be sent. This may specify a destination port number if needed.
|
||||
* **URL** - The fully-qualified URL of the request to be sent. This may specify a destination port number if needed. Jinja2 templating is supported for this field.
|
||||
* **HTTP content type** - The value of the request's `Content-Type` header. (Defaults to `application/json`)
|
||||
* **Additional headers** - Any additional headers to include with the request (optional). Add one header per line in the format `Name: Value`. Jinja2 templating is supported for this field (see below).
|
||||
* **Body template** - The content of the request being sent (optional). Jinja2 templating is supported for this field (see below). If blank, NetBox will populate the request body with a raw dump of the webhook context. (If the HTTP cotent type is set to `application/json`, this will be formatted as a JSON object.)
|
||||
@ -23,7 +23,7 @@ A webhook is a mechanism for conveying to some external system a change that too
|
||||
|
||||
## Jinja2 Template Support
|
||||
|
||||
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
|
||||
[Jinja2 templating](https://jinja.palletsprojects.com/) is supported for the `URL`, `additional_headers` and `body_template` fields. This enables the user to convey object data in the request headers as well as to craft a customized request body. Request content can be crafted to enable the direct interaction with external systems by ensuring the outgoing message is in a format the receiver expects and understands.
|
||||
|
||||
For example, you might create a NetBox webhook to [trigger a Slack message](https://api.slack.com/messaging/webhooks) any time an IP address is created. You can accomplish this using the following configuration:
|
||||
|
||||
|
3
docs/models/ipam/servicetemplate.md
Normal file
3
docs/models/ipam/servicetemplate.md
Normal 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.
|
@ -1,5 +1,8 @@
|
||||
# Plugin Development
|
||||
|
||||
!!! info "Help Improve the NetBox Plugins Framework!"
|
||||
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
|
||||
|
||||
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
|
||||
|
||||
Plugins can do a lot, including:
|
||||
|
3
docs/plugins/development/index.md
Normal file
3
docs/plugins/development/index.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Plugins Development
|
||||
|
||||
TODO
|
64
docs/plugins/development/model-features.md
Normal file
64
docs/plugins/development/model-features.md
Normal 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
|
@ -1,6 +1,14 @@
|
||||
# Release Notes
|
||||
|
||||
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
|
||||
NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
|
||||
|
||||
* **Major** - Introduces or removes an entire API or other core functionality
|
||||
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
|
||||
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
|
||||
|
||||
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 3.1](./version-3.1.md) (December 2021)
|
||||
|
||||
|
@ -1,6 +1,52 @@
|
||||
# NetBox v3.1
|
||||
|
||||
## v3.1.5 (FUTURE)
|
||||
## v3.1.7 (FUTURE)
|
||||
|
||||
---
|
||||
|
||||
## v3.1.6 (2022-01-17)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
|
||||
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
|
||||
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
|
||||
* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation
|
||||
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
|
||||
* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations
|
||||
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
|
||||
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
|
||||
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
|
||||
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
|
||||
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
|
||||
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
|
||||
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
|
||||
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
|
||||
* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers
|
||||
* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form
|
||||
* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms
|
||||
|
||||
---
|
||||
|
||||
## v3.1.5 (2022-01-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#8231](https://github.com/netbox-community/netbox/issues/8231) - Use in-page dialogs for confirming object deletion
|
||||
* [#8244](https://github.com/netbox-community/netbox/issues/8244) - Add length & length unit fields to cable filter form
|
||||
* [#8252](https://github.com/netbox-community/netbox/issues/8252) - Linkify type and group columns in clusters table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#8213](https://github.com/netbox-community/netbox/issues/8213) - Fix ValueError exception under prefix IP addresses view
|
||||
* [#8224](https://github.com/netbox-community/netbox/issues/8224) - Fix KeyError exception when creating FHRP group with IP address and protocol "other"
|
||||
* [#8226](https://github.com/netbox-community/netbox/issues/8226) - Honor return URL after populating a device bay
|
||||
* [#8228](https://github.com/netbox-community/netbox/issues/8228) - Optional ChoiceVar fields should not force a selection
|
||||
* [#8255](https://github.com/netbox-community/netbox/issues/8255) - Fix bulk editing of authentication parameters for wireless LANs and links
|
||||
|
||||
---
|
||||
|
||||
|
@ -14,6 +14,10 @@
|
||||
|
||||
### 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))
|
||||
|
||||
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
|
||||
|
||||
* [#5429](https://github.com/netbox-community/netbox/issues/5429) - Enable toggling the placement of table paginators
|
||||
* [#6954](https://github.com/netbox-community/netbox/issues/6954) - Remember users' table ordering preferences
|
||||
* [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation
|
||||
* [#7679](https://github.com/netbox-community/netbox/issues/7679) - Add actions menu to all object tables
|
||||
* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks
|
||||
* [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form
|
||||
* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts
|
||||
* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components
|
||||
* [#7852](https://github.com/netbox-community/netbox/issues/7852) - Enable assigning interfaces to VRFs
|
||||
* [#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
|
||||
|
||||
@ -80,6 +90,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
|
||||
* `/api/dcim/module-bays/`
|
||||
* `/api/dcim/module-bay-templates/`
|
||||
* `/api/dcim/module-types/`
|
||||
* `/api/extras/service-templates/`
|
||||
* circuits.ProviderNetwork
|
||||
* Added `service_id` field
|
||||
* dcim.ConsolePort
|
||||
@ -105,6 +116,8 @@ Inventory item templates can be arranged hierarchically within a device type, an
|
||||
* Add `cluster_types` field
|
||||
* extras.CustomField
|
||||
* Added `object_type` field
|
||||
* extras.CustomLink
|
||||
* Added `enabled` field
|
||||
* ipam.VLANGroup
|
||||
* Added the `/availables-vlans/` endpoint
|
||||
* Added the `min_vid` and `max_vid` fields
|
||||
|
@ -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
|
20
mkdocs.yml
20
mkdocs.yml
@ -16,6 +16,21 @@ theme:
|
||||
toggle:
|
||||
icon: material/lightbulb
|
||||
name: Switch to Light Mode
|
||||
plugins:
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
setup_commands:
|
||||
- import os
|
||||
- import django
|
||||
- os.chdir('netbox/')
|
||||
- os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
- django.setup()
|
||||
rendering:
|
||||
heading_level: 3
|
||||
show_root_heading: true
|
||||
show_root_full_path: false
|
||||
show_root_toc_entry: false
|
||||
extra:
|
||||
social:
|
||||
- icon: fontawesome/brands/github
|
||||
@ -84,7 +99,10 @@ nav:
|
||||
- Webhooks: 'additional-features/webhooks.md'
|
||||
- Plugins:
|
||||
- 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:
|
||||
- Authentication: 'administration/authentication.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
|
@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
|
||||
fields = [
|
||||
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
||||
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
|
||||
'_occupied',
|
||||
'_occupied', 'created', 'last_updated',
|
||||
]
|
||||
|
@ -5,8 +5,8 @@ from django.urls import reverse
|
||||
|
||||
from circuits.choices import *
|
||||
from dcim.models import LinkTermination
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
|
||||
__all__ = (
|
||||
'Circuit',
|
||||
@ -15,7 +15,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class CircuitType(OrganizationalModel):
|
||||
"""
|
||||
Circuits can be organized by their functional role. For example, a user might wish to define CircuitTypes named
|
||||
@ -44,7 +43,6 @@ class CircuitType(OrganizationalModel):
|
||||
return reverse('circuits:circuittype', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Circuit(PrimaryModel):
|
||||
"""
|
||||
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')
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class CircuitTermination(ChangeLoggedModel, LinkTermination):
|
||||
class CircuitTermination(WebhooksMixin, ChangeLoggedModel, LinkTermination):
|
||||
circuit = models.ForeignKey(
|
||||
to='circuits.Circuit',
|
||||
on_delete=models.CASCADE,
|
||||
|
@ -3,7 +3,6 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import PrimaryModel
|
||||
|
||||
__all__ = (
|
||||
@ -12,7 +11,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Provider(PrimaryModel):
|
||||
"""
|
||||
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])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ProviderNetwork(PrimaryModel):
|
||||
"""
|
||||
This represents a provider network which exists outside of NetBox, the details of which are unknown or
|
||||
|
@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
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 *
|
||||
|
||||
|
||||
@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
#
|
||||
# Table columns
|
||||
#
|
||||
|
||||
|
||||
class CommitRateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Humanize the commit rate in the column view
|
||||
"""
|
||||
|
||||
template_code = """
|
||||
{% load helpers %}
|
||||
{{ record.commit_rate|humanize_speed }}
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(template_code=self.template_code, *args, **kwargs)
|
||||
|
||||
def value(self, value):
|
||||
return str(value) if value else None
|
||||
|
||||
#
|
||||
# Providers
|
||||
#
|
||||
|
||||
|
||||
class ProviderTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
name = tables.Column(
|
||||
@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
|
||||
model = Provider
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
|
||||
'comments', 'tags',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
|
||||
|
||||
@ -69,7 +90,9 @@ class ProviderNetworkTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ProviderNetwork
|
||||
fields = ('pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'provider', 'service_id', 'description', 'comments', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'provider', 'service_id', 'description')
|
||||
|
||||
|
||||
@ -88,12 +111,13 @@ class CircuitTypeTable(BaseTable):
|
||||
circuit_count = tables.Column(
|
||||
verbose_name='Circuits'
|
||||
)
|
||||
actions = ButtonsColumn(CircuitType)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CircuitType
|
||||
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug')
|
||||
|
||||
|
||||
#
|
||||
@ -119,6 +143,7 @@ class CircuitTable(BaseTable):
|
||||
template_code=CIRCUITTERMINATION_LINK,
|
||||
verbose_name='Side Z'
|
||||
)
|
||||
commit_rate = CommitRateColumn()
|
||||
comments = MarkdownColumn()
|
||||
tags = TagColumn(
|
||||
url_name='circuits:circuit_list'
|
||||
@ -128,7 +153,7 @@ class CircuitTable(BaseTable):
|
||||
model = Circuit
|
||||
fields = (
|
||||
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
|
||||
'commit_rate', 'description', 'comments', 'tags',
|
||||
'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||
|
@ -5,7 +5,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.tables import configure_table
|
||||
from utilities.utils import count_related
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import CircuitTerminationSideChoices
|
||||
@ -35,7 +35,7 @@ class ProviderView(generic.ObjectView):
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('provider',))
|
||||
paginate_table(circuits_table, request)
|
||||
configure_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
'circuits_table': circuits_table,
|
||||
@ -96,7 +96,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
'type', 'tenant', 'terminations__site'
|
||||
)
|
||||
circuits_table = tables.CircuitTable(circuits)
|
||||
paginate_table(circuits_table, request)
|
||||
configure_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
'circuits_table': circuits_table,
|
||||
@ -150,7 +150,7 @@ class CircuitTypeView(generic.ObjectView):
|
||||
def get_extra_context(self, request, instance):
|
||||
circuits = Circuit.objects.restrict(request.user, 'view').filter(type=instance)
|
||||
circuits_table = tables.CircuitTable(circuits, exclude=('type',))
|
||||
paginate_table(circuits_table, request)
|
||||
configure_table(circuits_table, request)
|
||||
|
||||
return {
|
||||
'circuits_table': circuits_table,
|
||||
|
@ -221,7 +221,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
|
||||
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
|
||||
'custom_fields',
|
||||
]
|
||||
|
||||
@ -914,7 +914,7 @@ class CableSerializer(PrimaryModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
|
||||
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
|
||||
'tags', 'custom_fields',
|
||||
'tags', 'custom_fields', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def _get_termination(self, obj, side):
|
||||
@ -1008,7 +1008,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = VirtualChassis
|
||||
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
@ -1027,7 +1030,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
|
||||
'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
|
||||
|
@ -793,6 +793,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
|
||||
TYPE_FLEXSTACK = 'cisco-flexstack'
|
||||
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
|
||||
TYPE_STACKWISE80 = 'cisco-stackwise-80'
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@ -927,6 +931,10 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
|
||||
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
|
||||
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
|
||||
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
|
@ -157,7 +157,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
model = Location
|
||||
field_groups = [
|
||||
['q'],
|
||||
['q', 'tag'],
|
||||
['region_id', 'site_group_id', 'site_id', 'parent_id'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
@ -678,7 +678,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
field_groups = [
|
||||
['q', 'tag'],
|
||||
['site_id', 'rack_id', 'device_id'],
|
||||
['type', 'status', 'color'],
|
||||
['type', 'status', 'color', 'length', 'length_unit'],
|
||||
['tenant_group_id', 'tenant_id'],
|
||||
]
|
||||
region_id = DynamicModelMultipleChoiceField(
|
||||
@ -703,6 +703,16 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
'site_id': '$site_id'
|
||||
}
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
'rack_id': '$rack_id',
|
||||
},
|
||||
label=_('Device')
|
||||
)
|
||||
type = forms.MultipleChoiceField(
|
||||
choices=add_blank_choice(CableTypeChoices),
|
||||
required=False,
|
||||
@ -716,15 +726,12 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
color = ColorField(
|
||||
required=False
|
||||
)
|
||||
device_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site_id',
|
||||
'tenant_id': '$tenant_id',
|
||||
'rack_id': '$rack_id',
|
||||
},
|
||||
label=_('Device')
|
||||
length = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
length_unit = forms.ChoiceField(
|
||||
choices=add_blank_choice(CableLengthUnitChoices),
|
||||
required=False
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
@ -11,7 +11,6 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node, path_node_to_object
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import BigIDModel, PrimaryModel
|
||||
from utilities.fields import ColorField
|
||||
from utilities.utils import to_meters
|
||||
@ -29,7 +28,6 @@ __all__ = (
|
||||
# Cables
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Cable(PrimaryModel):
|
||||
"""
|
||||
A physical connection between two endpoints.
|
||||
|
@ -1,4 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
@ -7,8 +7,8 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.ordering import naturalize_interface
|
||||
@ -32,7 +32,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class ComponentTemplateModel(ChangeLoggedModel):
|
||||
class ComponentTemplateModel(WebhooksMixin, ChangeLoggedModel):
|
||||
device_type = models.ForeignKey(
|
||||
to='dcim.DeviceType',
|
||||
on_delete=models.CASCADE,
|
||||
@ -135,7 +135,6 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
|
||||
return self.name
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
A template for a ConsolePort to be created for a new Device.
|
||||
@ -164,7 +163,6 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ConsoleServerPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
A template for a physical data interface on a new Device.
|
||||
@ -347,7 +342,6 @@ class InterfaceTemplate(ModularComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
Template for a pass-through port on the front of a new Device.
|
||||
@ -420,7 +414,6 @@ class FrontPortTemplate(ModularComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class RearPortTemplate(ModularComponentTemplateModel):
|
||||
"""
|
||||
Template for a pass-through port on the rear of a new Device.
|
||||
@ -460,7 +453,6 @@ class RearPortTemplate(ModularComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ModuleBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a ModuleBay to be created for a new parent Device.
|
||||
@ -486,7 +478,6 @@ class ModuleBayTemplate(ComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class DeviceBayTemplate(ComponentTemplateModel):
|
||||
"""
|
||||
A template for a DeviceBay to be created for a new parent Device.
|
||||
@ -511,7 +502,6 @@ class DeviceBayTemplate(ComponentTemplateModel):
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
|
||||
"""
|
||||
A template for an InventoryItem to be created for a new parent Device.
|
||||
|
@ -11,7 +11,6 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import MACAddressField, WWNField
|
||||
from dcim.svg import CableTraceSVG
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField, NaturalOrderingField
|
||||
@ -254,7 +253,6 @@ class PathEndpoint(models.Model):
|
||||
# Console components
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
"""
|
||||
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
|
||||
@ -282,7 +280,6 @@ class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
return reverse('dcim:consoleport', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
"""
|
||||
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
|
||||
@ -314,7 +311,6 @@ class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
# Power components
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
"""
|
||||
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
|
||||
@ -407,7 +403,6 @@ class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
}
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
|
||||
"""
|
||||
A physical power outlet (output) within a Device which provides power to a PowerPort.
|
||||
@ -522,7 +517,6 @@ class BaseInterface(models.Model):
|
||||
return self.fhrp_group_assignments.count()
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
|
||||
"""
|
||||
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
|
||||
@ -805,7 +799,6 @@ class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpo
|
||||
# Pass-through ports
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class FrontPort(ModularComponentModel, LinkTermination):
|
||||
"""
|
||||
A pass-through port on the front of a Device.
|
||||
@ -859,7 +852,6 @@ class FrontPort(ModularComponentModel, LinkTermination):
|
||||
})
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RearPort(ModularComponentModel, LinkTermination):
|
||||
"""
|
||||
A pass-through port on the rear of a Device.
|
||||
@ -903,7 +895,6 @@ class RearPort(ModularComponentModel, LinkTermination):
|
||||
# Bays
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ModuleBay(ComponentModel):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
@ -924,7 +915,6 @@ class ModuleBay(ComponentModel):
|
||||
return reverse('dcim:modulebay', kwargs={'pk': self.pk})
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class DeviceBay(ComponentModel):
|
||||
"""
|
||||
An empty space within a Device which can house a child device
|
||||
@ -975,7 +965,6 @@ class DeviceBay(ComponentModel):
|
||||
#
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class InventoryItemRole(OrganizationalModel):
|
||||
"""
|
||||
Inventory items may optionally be assigned a functional role.
|
||||
@ -1006,7 +995,6 @@ class InventoryItemRole(OrganizationalModel):
|
||||
return reverse('dcim:inventoryitemrole', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class InventoryItem(MPTTModel, ComponentModel):
|
||||
"""
|
||||
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
|
||||
|
@ -13,7 +13,6 @@ from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.models import ConfigContextModel
|
||||
from extras.querysets import ConfigContextModelQuerySet
|
||||
from extras.utils import extras_features
|
||||
from netbox.config import ConfigItem
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
@ -37,7 +36,6 @@ __all__ = (
|
||||
# Device Types
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Manufacturer(OrganizationalModel):
|
||||
"""
|
||||
A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
|
||||
@ -70,7 +68,6 @@ class Manufacturer(OrganizationalModel):
|
||||
return reverse('dcim:manufacturer', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class DeviceType(PrimaryModel):
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ModuleType(PrimaryModel):
|
||||
"""
|
||||
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
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class DeviceRole(OrganizationalModel):
|
||||
"""
|
||||
Devices are organized by functional role; for example, "Core Switch" or "File Server". Each DeviceRole is assigned a
|
||||
@ -525,7 +520,6 @@ class DeviceRole(OrganizationalModel):
|
||||
return reverse('dcim:devicerole', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Platform(OrganizationalModel):
|
||||
"""
|
||||
Platform refers to the software or firmware running on a Device. For example, "Cisco IOS-XR" or "Juniper Junos".
|
||||
@ -575,7 +569,6 @@ class Platform(OrganizationalModel):
|
||||
return reverse('dcim:platform', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Device(PrimaryModel, ConfigContextModel):
|
||||
"""
|
||||
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')
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Module(PrimaryModel, ConfigContextModel):
|
||||
"""
|
||||
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
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class VirtualChassis(PrimaryModel):
|
||||
"""
|
||||
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
|
||||
|
@ -6,7 +6,6 @@ from django.urls import reverse
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import PrimaryModel
|
||||
from utilities.validators import ExclusionValidator
|
||||
from .device_components import LinkTermination, PathEndpoint
|
||||
@ -21,7 +20,6 @@ __all__ = (
|
||||
# Power
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class PowerPanel(PrimaryModel):
|
||||
"""
|
||||
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):
|
||||
"""
|
||||
An electrical circuit delivered from a PowerPanel.
|
||||
|
@ -13,7 +13,6 @@ from django.urls import reverse
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.svg import RackElevationSVG
|
||||
from extras.utils import extras_features
|
||||
from netbox.config import get_config
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from utilities.choices import ColorChoices
|
||||
@ -34,7 +33,6 @@ __all__ = (
|
||||
# Racks
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RackRole(OrganizationalModel):
|
||||
"""
|
||||
Racks can be organized by functional role, similar to Devices.
|
||||
@ -65,7 +63,6 @@ class RackRole(OrganizationalModel):
|
||||
return reverse('dcim:rackrole', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Rack(PrimaryModel):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RackReservation(PrimaryModel):
|
||||
"""
|
||||
One or more reserved units within a Rack.
|
||||
|
@ -7,8 +7,6 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from dcim.fields import ASNField
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import NestedGroupModel, PrimaryModel
|
||||
from utilities.fields import NaturalOrderingField
|
||||
|
||||
@ -24,7 +22,6 @@ __all__ = (
|
||||
# Regions
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Region(NestedGroupModel):
|
||||
"""
|
||||
A region represents a geographic collection of sites. For example, you might create regions representing countries,
|
||||
@ -111,7 +108,6 @@ class Region(NestedGroupModel):
|
||||
# Site groups
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class SiteGroup(NestedGroupModel):
|
||||
"""
|
||||
A site group is an arbitrary grouping of sites. For example, you might have corporate sites and customer sites; and
|
||||
@ -198,7 +194,6 @@ class SiteGroup(NestedGroupModel):
|
||||
# Sites
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Site(PrimaryModel):
|
||||
"""
|
||||
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
|
||||
#
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Location(NestedGroupModel):
|
||||
"""
|
||||
A Location represents a subgroup of Racks and/or Devices within a Site. A Location may represent a building within a
|
||||
|
@ -19,7 +19,12 @@ __all__ = (
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
return device.name or str(device.device_type)
|
||||
if device.virtual_chassis:
|
||||
return f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
return device.name
|
||||
else:
|
||||
return str(device.device_type)
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
|
@ -56,7 +56,7 @@ class CableTable(BaseTable):
|
||||
model = Cable
|
||||
fields = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags',
|
||||
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
|
||||
|
@ -7,7 +7,7 @@ from dcim.models import (
|
||||
)
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import *
|
||||
@ -94,15 +94,14 @@ class DeviceRoleTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:devicerole_list'
|
||||
)
|
||||
actions = ButtonsColumn(DeviceRole)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = DeviceRole
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -127,16 +126,15 @@ class PlatformTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:platform_list'
|
||||
)
|
||||
actions = ButtonsColumn(Platform)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Platform
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
|
||||
'description', 'tags', 'actions',
|
||||
'description', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
|
||||
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description',
|
||||
)
|
||||
|
||||
|
||||
@ -207,7 +205,8 @@ class DeviceTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
|
||||
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
|
||||
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
|
||||
@ -313,7 +312,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
@ -324,10 +323,8 @@ class DeviceConsolePortTable(ConsolePortTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=CONSOLEPORT_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=CONSOLEPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -336,7 +333,7 @@ class DeviceConsolePortTable(ConsolePortTable):
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
@ -357,7 +354,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||
|
||||
@ -369,10 +366,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=CONSOLESERVERPORT_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=CONSOLESERVERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -381,7 +376,7 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
}
|
||||
@ -402,7 +397,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
|
||||
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
@ -414,10 +410,8 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=POWERPORT_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=POWERPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -428,7 +422,6 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
||||
'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
@ -453,7 +446,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
|
||||
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
|
||||
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
@ -464,10 +458,8 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutlet,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=POWEROUTLET_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=POWEROUTLET_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -477,7 +469,7 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
@ -535,6 +527,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans',
|
||||
'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -557,10 +550,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
linkify=True,
|
||||
verbose_name='LAG'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=Interface,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=INTERFACE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=INTERFACE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -575,7 +566,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
order_by = ('name',)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mtu', 'mode', 'description', 'ip_addresses',
|
||||
'cable', 'connection', 'actions',
|
||||
'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_interface_row_class,
|
||||
@ -607,6 +598,7 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
|
||||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||
@ -620,10 +612,8 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=FRONTPORT_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=FRONTPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -634,7 +624,6 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
|
||||
'actions',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
@ -657,7 +646,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
|
||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
||||
|
||||
@ -669,10 +658,8 @@ class DeviceRearPortTable(RearPortTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=RearPort,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=REARPORT_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=REARPORT_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -682,7 +669,7 @@ class DeviceRearPortTable(RearPortTable):
|
||||
'cable', 'cable_color', 'link_peer', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_cabletermination_row_class
|
||||
@ -709,7 +696,11 @@ class DeviceBayTable(DeviceComponentTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = DeviceBay
|
||||
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
@ -720,10 +711,8 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBay,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=DEVICEBAY_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=DEVICEBAY_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
@ -731,9 +720,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'label', 'status', 'installed_device', 'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'status', 'installed_device', 'description', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
|
||||
|
||||
|
||||
class ModuleBayTable(DeviceComponentTable):
|
||||
@ -758,16 +745,14 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
|
||||
|
||||
class DeviceModuleBayTable(ModuleBayTable):
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBay,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULEBAY_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=MODULEBAY_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ModuleBay
|
||||
fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions')
|
||||
default_columns = ('pk', 'name', 'label', 'description', 'installed_module')
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
@ -798,7 +783,7 @@ class InventoryItemTable(DeviceComponentTable):
|
||||
model = InventoryItem
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'component', 'label', 'role', 'manufacturer', 'part_id', 'serial',
|
||||
'asset_tag', 'description', 'discovered', 'tags',
|
||||
'asset_tag', 'description', 'discovered', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
|
||||
@ -812,10 +797,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
|
||||
order_by=Accessor('_name'),
|
||||
attrs={'td': {'class': 'text-nowrap'}}
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InventoryItem,
|
||||
buttons=('edit', 'delete')
|
||||
)
|
||||
actions = ActionsColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItem
|
||||
@ -824,7 +806,7 @@ class DeviceInventoryItemTable(InventoryItemTable):
|
||||
'description', 'discovered', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions',
|
||||
'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
|
||||
)
|
||||
|
||||
|
||||
@ -842,14 +824,13 @@ class InventoryItemRoleTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:inventoryitemrole_list'
|
||||
)
|
||||
actions = ButtonsColumn(InventoryItemRole)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = InventoryItemRole
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -875,5 +856,5 @@ class VirtualChassisTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VirtualChassis
|
||||
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')
|
||||
|
@ -6,7 +6,7 @@ from dcim.models import (
|
||||
InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
|
||||
)
|
||||
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
|
||||
|
||||
@ -48,16 +48,15 @@ class ManufacturerTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:manufacturer_list'
|
||||
)
|
||||
actions = ButtonsColumn(Manufacturer)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
|
||||
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
|
||||
)
|
||||
|
||||
|
||||
@ -88,7 +87,7 @@ class DeviceTypeTable(BaseTable):
|
||||
model = DeviceType
|
||||
fields = (
|
||||
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
|
||||
'airflow', 'comments', 'instance_count', 'tags',
|
||||
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',
|
||||
@ -113,10 +112,9 @@ class ComponentTemplateTable(BaseTable):
|
||||
|
||||
|
||||
class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsolePortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -126,10 +124,9 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ConsoleServerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -139,10 +136,9 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -152,10 +148,9 @@ class PowerPortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class PowerOutletTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=PowerOutletTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -168,10 +163,9 @@ class InterfaceTemplateTable(ComponentTemplateTable):
|
||||
mgmt_only = BooleanColumn(
|
||||
verbose_name='Management Only'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=InterfaceTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -185,10 +179,9 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
verbose_name='Position'
|
||||
)
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(
|
||||
model=FrontPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -199,10 +192,9 @@ class FrontPortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
class RearPortTemplateTable(ComponentTemplateTable):
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(
|
||||
model=RearPortTemplate,
|
||||
buttons=('edit', 'delete'),
|
||||
prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete'),
|
||||
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -212,9 +204,8 @@ class RearPortTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class ModuleBayTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=ModuleBayTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -224,9 +215,8 @@ class ModuleBayTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=DeviceBayTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(ComponentTemplateTable.Meta):
|
||||
@ -236,9 +226,8 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
|
||||
|
||||
|
||||
class InventoryItemTemplateTable(ComponentTemplateTable):
|
||||
actions = ButtonsColumn(
|
||||
model=InventoryItemTemplate,
|
||||
buttons=('edit', 'delete')
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete')
|
||||
)
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
|
@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = PowerPanel
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
|
||||
|
||||
|
||||
@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
|
||||
'comments', 'tags',
|
||||
'comments', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
@ -4,8 +4,8 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Rack, RackReservation, RackRole
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn,
|
||||
TagColumn, ToggleColumn, UtilizationColumn,
|
||||
BaseTable, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn, MarkdownColumn, TagColumn,
|
||||
ToggleColumn, UtilizationColumn,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
@ -27,12 +27,14 @@ class RackRoleTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rackrole_list'
|
||||
)
|
||||
actions = ButtonsColumn(RackRole)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackRole
|
||||
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -87,8 +89,9 @@ class RackTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Rack
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
|
||||
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
|
||||
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
|
||||
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
|
||||
'get_power_utilization', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
|
||||
@ -121,14 +124,11 @@ class RackReservationTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:rackreservation_list'
|
||||
)
|
||||
actions = ButtonsColumn(RackReservation)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RackReservation
|
||||
fields = (
|
||||
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description')
|
||||
|
@ -3,9 +3,9 @@ import django_tables2 as tables
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from tenancy.tables import TenantColumn
|
||||
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__ = (
|
||||
'LocationTable',
|
||||
@ -32,12 +32,13 @@ class RegionTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:region_list'
|
||||
)
|
||||
actions = ButtonsColumn(Region)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Region
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -57,12 +58,13 @@ class SiteGroupTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:sitegroup_list'
|
||||
)
|
||||
actions = ButtonsColumn(SiteGroup)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -98,6 +100,7 @@ class SiteTable(BaseTable):
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
|
||||
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'comments', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
|
||||
|
||||
@ -128,15 +131,14 @@ class LocationTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='dcim:location_list'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=Location,
|
||||
prepend_template=LOCATION_ELEVATIONS
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=LOCATION_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
|
||||
'actions',
|
||||
'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description')
|
||||
|
@ -87,7 +87,7 @@ POWERFEED_CABLETERMINATION = """
|
||||
<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">
|
||||
<i class="mdi mdi-server"></i>
|
||||
</a>
|
||||
@ -99,8 +99,8 @@ LOCATION_ELEVATIONS = """
|
||||
|
||||
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
|
||||
{% load helpers %}
|
||||
{% if perms.dcim.add_invnetoryitemtemplate %}
|
||||
<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">
|
||||
{% if perms.dcim.add_inventoryitemtemplate %}
|
||||
<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>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -20,7 +20,7 @@ from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.tables import configure_table
|
||||
from utilities.utils import count_related
|
||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
|
||||
from virtualization.models import VirtualMachine
|
||||
@ -165,7 +165,7 @@ class RegionView(generic.ObjectView):
|
||||
region=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites, exclude=('region',))
|
||||
paginate_table(sites_table, request)
|
||||
configure_table(sites_table, request)
|
||||
|
||||
return {
|
||||
'child_regions_table': child_regions_table,
|
||||
@ -250,7 +250,7 @@ class SiteGroupView(generic.ObjectView):
|
||||
group=instance
|
||||
)
|
||||
sites_table = tables.SiteTable(sites, exclude=('group',))
|
||||
paginate_table(sites_table, request)
|
||||
configure_table(sites_table, request)
|
||||
|
||||
return {
|
||||
'child_groups_table': child_groups_table,
|
||||
@ -422,7 +422,7 @@ class LocationView(generic.ObjectView):
|
||||
cumulative=True
|
||||
).filter(pk__in=location_ids).exclude(pk=instance.pk)
|
||||
child_locations_table = tables.LocationTable(child_locations)
|
||||
paginate_table(child_locations_table, request)
|
||||
configure_table(child_locations_table, request)
|
||||
|
||||
return {
|
||||
'rack_count': rack_count,
|
||||
@ -493,7 +493,7 @@ class RackRoleView(generic.ObjectView):
|
||||
)
|
||||
|
||||
racks_table = tables.RackTable(racks, exclude=('role', 'get_utilization', 'get_power_utilization'))
|
||||
paginate_table(racks_table, request)
|
||||
configure_table(racks_table, request)
|
||||
|
||||
return {
|
||||
'racks_table': racks_table,
|
||||
@ -743,7 +743,7 @@ class ManufacturerView(generic.ObjectView):
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes, exclude=('manufacturer',))
|
||||
paginate_table(devicetypes_table, request)
|
||||
configure_table(devicetypes_table, request)
|
||||
|
||||
return {
|
||||
'devicetypes_table': devicetypes_table,
|
||||
@ -1439,7 +1439,7 @@ class DeviceRoleView(generic.ObjectView):
|
||||
device_role=instance
|
||||
)
|
||||
devices_table = tables.DeviceTable(devices, exclude=('device_role',))
|
||||
paginate_table(devices_table, request)
|
||||
configure_table(devices_table, request)
|
||||
|
||||
return {
|
||||
'devices_table': devices_table,
|
||||
@ -1503,7 +1503,7 @@ class PlatformView(generic.ObjectView):
|
||||
platform=instance
|
||||
)
|
||||
devices_table = tables.DeviceTable(devices, exclude=('platform',))
|
||||
paginate_table(devices_table, request)
|
||||
configure_table(devices_table, request)
|
||||
|
||||
return {
|
||||
'devices_table': devices_table,
|
||||
@ -2379,8 +2379,9 @@ class DeviceBayPopulateView(generic.ObjectEditView):
|
||||
device_bay.installed_device = form.cleaned_data['installed_device']
|
||||
device_bay.save()
|
||||
messages.success(request, "Added {} to {}.".format(device_bay.installed_device, device_bay))
|
||||
return_url = self.get_return_url(request)
|
||||
|
||||
return redirect('dcim:device', pk=device_bay.device.pk)
|
||||
return redirect(return_url)
|
||||
|
||||
return render(request, 'dcim/devicebay_populate.html', {
|
||||
'device_bay': device_bay,
|
||||
|
@ -63,7 +63,7 @@ class WebhookSerializer(ValidatedModelSerializer):
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
|
||||
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
'conditions', 'ssl_verification', 'ca_file_path',
|
||||
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@ -79,14 +79,28 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
)
|
||||
type = ChoiceField(choices=CustomFieldTypeChoices)
|
||||
filter_logic = ChoiceField(choices=CustomFieldFilterLogicChoices, required=False)
|
||||
data_type = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
|
||||
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
'id', 'url', 'display', 'content_types', 'type', 'data_type', 'name', 'label', 'description', 'required',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
|
||||
'choices', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
def get_data_type(self, obj):
|
||||
types = CustomFieldTypeChoices
|
||||
if obj.type == types.TYPE_INTEGER:
|
||||
return 'integer'
|
||||
if obj.type == types.TYPE_BOOLEAN:
|
||||
return 'boolean'
|
||||
if obj.type in (types.TYPE_JSON, types.TYPE_OBJECT):
|
||||
return 'object'
|
||||
if obj.type in (types.TYPE_MULTISELECT, types.TYPE_MULTIOBJECT):
|
||||
return 'array'
|
||||
return 'string'
|
||||
|
||||
|
||||
#
|
||||
# Custom links
|
||||
@ -101,8 +115,8 @@ class CustomLinkSerializer(ValidatedModelSerializer):
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window',
|
||||
'id', 'url', 'display', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@ -120,7 +134,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
|
||||
model = ExportTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
|
||||
'file_extension', 'as_attachment',
|
||||
'file_extension', 'as_attachment', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
@ -134,7 +148,9 @@ class TagSerializer(ValidatedModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
|
@ -7,6 +7,7 @@ EXTRAS_FEATURES = [
|
||||
'custom_links',
|
||||
'export_templates',
|
||||
'job_results',
|
||||
'journaling',
|
||||
'tags',
|
||||
'webhooks'
|
||||
]
|
||||
|
@ -82,7 +82,9 @@ class CustomLinkFilterSet(BaseFilterSet):
|
||||
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
fields = ['id', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', 'new_window']
|
||||
fields = [
|
||||
'id', 'content_type', 'name', 'enabled', 'link_text', 'link_url', 'weight', 'group_name', 'new_window',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
|
@ -47,6 +47,10 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
new_window = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
|
@ -51,7 +51,8 @@ class CustomLinkCSVForm(CSVModelForm):
|
||||
class Meta:
|
||||
model = CustomLink
|
||||
fields = (
|
||||
'name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window', 'link_text', 'link_url',
|
||||
'name', 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'link_text',
|
||||
'link_url',
|
||||
)
|
||||
|
||||
|
||||
|
@ -4,7 +4,7 @@ from django.db.models import Q
|
||||
|
||||
from extras.choices import *
|
||||
from extras.models import *
|
||||
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
|
||||
from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
|
||||
|
||||
__all__ = (
|
||||
'CustomFieldModelCSVForm',
|
||||
@ -34,6 +34,9 @@ class CustomFieldsMixin:
|
||||
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
|
||||
return ContentType.objects.get_for_model(self.model)
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field()
|
||||
|
||||
@ -41,10 +44,7 @@ class CustomFieldsMixin:
|
||||
"""
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
content_type = self._get_content_type()
|
||||
|
||||
# Append form fields; assign initial values if modifying and existing object
|
||||
for customfield in CustomField.objects.filter(content_types=content_type):
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
field_name = f'cf_{customfield.name}'
|
||||
self.fields[field_name] = self._get_form_field(customfield)
|
||||
|
||||
@ -89,40 +89,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
|
||||
class CustomFieldModelBulkEditForm(BulkEditForm):
|
||||
class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False)
|
||||
|
||||
self.custom_fields = []
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
|
||||
for cf in custom_fields:
|
||||
def _append_customfield_fields(self):
|
||||
"""
|
||||
Append form fields for all CustomFields assigned to this object type.
|
||||
"""
|
||||
for customfield in self._get_custom_fields(self._get_content_type()):
|
||||
# Annotate non-required custom fields as nullable
|
||||
if not cf.required:
|
||||
self.nullable_fields.append(cf.name)
|
||||
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
|
||||
# Annotate this as a custom field
|
||||
self.custom_fields.append(cf.name)
|
||||
if not customfield.required:
|
||||
self.nullable_fields.append(customfield.name)
|
||||
|
||||
self.fields[customfield.name] = self._get_form_field(customfield)
|
||||
|
||||
# Annotate the field in the list of CustomField form fields
|
||||
self.custom_fields[customfield.name] = customfield
|
||||
|
||||
|
||||
class CustomFieldModelFilterForm(FilterForm):
|
||||
class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
label='Search'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
self.obj_type = ContentType.objects.get_for_model(self.model)
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Add all applicable CustomFields to the form
|
||||
self.custom_field_filters = []
|
||||
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
|
||||
def _get_custom_fields(self, content_type):
|
||||
return CustomField.objects.filter(content_types=content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
Q(type=CustomFieldTypeChoices.TYPE_JSON)
|
||||
)
|
||||
for cf in custom_fields:
|
||||
field_name = f'cf_{cf.name}'
|
||||
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
|
||||
self.custom_field_filters.append(field_name)
|
||||
|
||||
def _get_form_field(self, customfield):
|
||||
return customfield.to_form_field(set_initial=False, enforce_required=False)
|
||||
|
@ -58,15 +58,18 @@ class CustomFieldFilterForm(FilterForm):
|
||||
class CustomLinkFilterForm(FilterForm):
|
||||
field_groups = [
|
||||
['q'],
|
||||
['content_type', 'weight', 'new_window'],
|
||||
['content_type', 'enabled', 'new_window', 'weight'],
|
||||
]
|
||||
content_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.all(),
|
||||
limit_choices_to=FeatureQuery('custom_fields'),
|
||||
required=False
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
enabled = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=StaticSelect(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
new_window = forms.NullBooleanField(
|
||||
required=False,
|
||||
@ -74,6 +77,9 @@ class CustomLinkFilterForm(FilterForm):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
weight = forms.IntegerField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(FilterForm):
|
||||
|
@ -7,8 +7,8 @@ from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField,
|
||||
ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
|
||||
add_blank_choice, BootstrapMixin, CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField,
|
||||
DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@ -41,6 +41,10 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
widgets = {
|
||||
'type': StaticSelect(),
|
||||
'filter_logic': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
@ -53,10 +57,11 @@ class CustomLinkForm(BootstrapMixin, forms.ModelForm):
|
||||
model = CustomLink
|
||||
fields = '__all__'
|
||||
fieldsets = (
|
||||
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'new_window')),
|
||||
('Custom Link', ('name', 'content_type', 'weight', 'group_name', 'button_class', 'enabled', 'new_window')),
|
||||
('Templates', ('link_text', 'link_url')),
|
||||
)
|
||||
widgets = {
|
||||
'button_class': StaticSelect(),
|
||||
'link_text': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'link_url': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
@ -77,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
|
||||
model = ExportTemplate
|
||||
fields = '__all__'
|
||||
fieldsets = (
|
||||
('Custom Link', ('name', 'content_type', 'description')),
|
||||
('Export Template', ('name', 'content_type', 'description')),
|
||||
('Template', ('template_code',)),
|
||||
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
|
||||
)
|
||||
@ -96,8 +101,7 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
model = Webhook
|
||||
fields = '__all__'
|
||||
fieldsets = (
|
||||
('Webhook', ('name', 'enabled')),
|
||||
('Assigned Models', ('content_types',)),
|
||||
('Webhook', ('name', 'content_types', 'enabled')),
|
||||
('Events', ('type_create', 'type_update', 'type_delete')),
|
||||
('HTTP Request', (
|
||||
'payload_url', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
|
||||
@ -105,7 +109,13 @@ class WebhookForm(BootstrapMixin, forms.ModelForm):
|
||||
('Conditions', ('conditions',)),
|
||||
('SSL', ('ssl_verification', 'ca_file_path')),
|
||||
)
|
||||
labels = {
|
||||
'type_create': 'Creations',
|
||||
'type_update': 'Updates',
|
||||
'type_delete': 'Deletions',
|
||||
}
|
||||
widgets = {
|
||||
'http_method': StaticSelect(),
|
||||
'additional_headers': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
'body_template': forms.Textarea(attrs={'class': 'font-monospace'}),
|
||||
}
|
||||
|
18
netbox/extras/migrations/0070_customlink_enabled.py
Normal file
18
netbox/extras/migrations/0070_customlink_enabled.py
Normal 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),
|
||||
),
|
||||
]
|
@ -5,8 +5,8 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.querysets import ConfigContextQuerySet
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from utilities.utils import deepmerge
|
||||
|
||||
|
||||
@ -20,8 +20,7 @@ __all__ = (
|
||||
# Config contexts
|
||||
#
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ConfigContext(ChangeLoggedModel):
|
||||
class ConfigContext(WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
|
||||
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
|
||||
|
@ -12,12 +12,13 @@ from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from extras.choices import *
|
||||
from extras.utils import FeatureQuery, extras_features
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.models import ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities import filters
|
||||
from utilities.forms import (
|
||||
CSVChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, LaxURLField,
|
||||
StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||
)
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.validators import validate_regex
|
||||
@ -40,8 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
||||
return self.get_queryset().filter(content_types=content_type)
|
||||
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomField(ChangeLoggedModel):
|
||||
class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_types = models.ManyToManyField(
|
||||
to=ContentType,
|
||||
related_name='custom_fields',
|
||||
@ -283,7 +283,7 @@ class CustomField(ChangeLoggedModel):
|
||||
"""
|
||||
Return a form field suitable for setting a CustomField's value for an object.
|
||||
|
||||
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
|
||||
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
|
||||
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
|
||||
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
|
||||
"""
|
||||
@ -332,7 +332,7 @@ class CustomField(ChangeLoggedModel):
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelect()
|
||||
)
|
||||
else:
|
||||
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
|
||||
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
|
||||
field = field_class(
|
||||
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
|
||||
)
|
||||
|
@ -17,8 +17,9 @@ from rest_framework.utils.encoders import JSONEncoder
|
||||
from extras.choices import *
|
||||
from extras.constants import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.utils import extras_features, FeatureQuery, image_upload
|
||||
from extras.utils import FeatureQuery, image_upload
|
||||
from netbox.models import BigIDModel, ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, JobResultsMixin, WebhooksMixin
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import render_jinja2
|
||||
|
||||
@ -35,8 +36,7 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Webhook(ChangeLoggedModel):
|
||||
class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A Webhook defines a request that will be sent to a remote application when an object is created, updated, and/or
|
||||
delete in NetBox. The request will contain a representation of the object, which the remote application can act on.
|
||||
@ -68,7 +68,8 @@ class Webhook(ChangeLoggedModel):
|
||||
payload_url = models.CharField(
|
||||
max_length=500,
|
||||
verbose_name='URL',
|
||||
help_text="A POST will be sent to this URL when the webhook is called."
|
||||
help_text='This URL will be called using the HTTP method defined when the webhook is called. '
|
||||
'Jinja2 template processing is supported with the same context as the request body.'
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
@ -176,9 +177,14 @@ class Webhook(ChangeLoggedModel):
|
||||
else:
|
||||
return json.dumps(context, cls=JSONEncoder)
|
||||
|
||||
def render_payload_url(self, context):
|
||||
"""
|
||||
Render the payload URL.
|
||||
"""
|
||||
return render_jinja2(self.payload_url, context)
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class CustomLink(ChangeLoggedModel):
|
||||
|
||||
class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template
|
||||
code to be rendered with an object as context.
|
||||
@ -192,6 +198,9 @@ class CustomLink(ChangeLoggedModel):
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
enabled = models.BooleanField(
|
||||
default=True
|
||||
)
|
||||
link_text = models.CharField(
|
||||
max_length=500,
|
||||
help_text="Jinja2 template code for link text"
|
||||
@ -248,8 +257,7 @@ class CustomLink(ChangeLoggedModel):
|
||||
}
|
||||
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class ExportTemplate(ChangeLoggedModel):
|
||||
class ExportTemplate(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel):
|
||||
content_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE,
|
||||
@ -335,8 +343,7 @@ class ExportTemplate(ChangeLoggedModel):
|
||||
return response
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class ImageAttachment(ChangeLoggedModel):
|
||||
class ImageAttachment(WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
An uploaded image which is associated with an object.
|
||||
"""
|
||||
@ -414,8 +421,7 @@ class ImageAttachment(ChangeLoggedModel):
|
||||
return super().to_objectchange(action, related_object=self.parent)
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class JournalEntry(ChangeLoggedModel):
|
||||
class JournalEntry(WebhooksMixin, ChangeLoggedModel):
|
||||
"""
|
||||
A historical remark concerning an object; collectively, these form an object's journal. The journal is used to
|
||||
preserve historical context around an object, and complements NetBox's built-in change logging. For example, you
|
||||
@ -593,8 +599,7 @@ class ConfigRevision(models.Model):
|
||||
# Custom scripts & reports
|
||||
#
|
||||
|
||||
@extras_features('job_results')
|
||||
class Script(models.Model):
|
||||
class Script(JobResultsMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for custom scripts. Does not exist in the database.
|
||||
"""
|
||||
@ -606,8 +611,7 @@ class Script(models.Model):
|
||||
# Reports
|
||||
#
|
||||
|
||||
@extras_features('job_results')
|
||||
class Report(models.Model):
|
||||
class Report(JobResultsMixin, models.Model):
|
||||
"""
|
||||
Dummy model used to generate permissions for reports. Does not exist in the database.
|
||||
"""
|
||||
|
@ -3,8 +3,8 @@ from django.urls import reverse
|
||||
from django.utils.text import slugify
|
||||
from taggit.models import TagBase, GenericTaggedItemBase
|
||||
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import BigIDModel, ChangeLoggedModel
|
||||
from netbox.models.features import ExportTemplatesMixin, WebhooksMixin
|
||||
from utilities.choices import ColorChoices
|
||||
from utilities.fields import ColorField
|
||||
|
||||
@ -13,8 +13,7 @@ from utilities.fields import ColorField
|
||||
# Tags
|
||||
#
|
||||
|
||||
@extras_features('webhooks', 'export_templates')
|
||||
class Tag(ChangeLoggedModel, TagBase):
|
||||
class Tag(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel, TagBase):
|
||||
color = ColorField(
|
||||
default=ColorChoices.COLOR_GREY
|
||||
)
|
||||
|
@ -1,3 +1,8 @@
|
||||
import collections
|
||||
|
||||
from extras.constants import EXTRAS_FEATURES
|
||||
|
||||
|
||||
class Registry(dict):
|
||||
"""
|
||||
Central registry for registration of functionality. Once a store (key) is defined, it cannot be overwritten or
|
||||
@ -7,15 +12,19 @@ class Registry(dict):
|
||||
try:
|
||||
return super().__getitem__(key)
|
||||
except KeyError:
|
||||
raise KeyError("Invalid store: {}".format(key))
|
||||
raise KeyError(f"Invalid store: {key}")
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
if key in self:
|
||||
raise KeyError("Store already set: {}".format(key))
|
||||
raise KeyError(f"Store already set: {key}")
|
||||
super().__setitem__(key, value)
|
||||
|
||||
def __delitem__(self, key):
|
||||
raise TypeError("Cannot delete stores from registry")
|
||||
|
||||
|
||||
# Initialize the global registry
|
||||
registry = Registry()
|
||||
registry['model_features'] = {
|
||||
feature: collections.defaultdict(set) for feature in EXTRAS_FEATURES
|
||||
}
|
||||
|
@ -21,7 +21,7 @@ from extras.models import JobResult
|
||||
from ipam.formfields import IPAddressFormField, IPNetworkFormField
|
||||
from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator, prefix_validator
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms import add_blank_choice, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from .context_managers import change_logging
|
||||
from .forms import ScriptForm
|
||||
|
||||
@ -164,16 +164,22 @@ class ChoiceVar(ScriptVariable):
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
# Set field choices, adding a blank choice to avoid forced selections
|
||||
self.field_attrs['choices'] = add_blank_choice(choices)
|
||||
|
||||
|
||||
class MultiChoiceVar(ChoiceVar):
|
||||
class MultiChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Like ChoiceVar, but allows for the selection of multiple choices.
|
||||
"""
|
||||
form_field = forms.MultipleChoiceField
|
||||
|
||||
def __init__(self, choices, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Set field choices
|
||||
self.field_attrs['choices'] = choices
|
||||
|
||||
|
||||
class ObjectVar(ScriptVariable):
|
||||
"""
|
||||
|
@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
|
||||
ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ColorColumn, ContentTypeColumn, ContentTypesColumn,
|
||||
MarkdownColumn, ToggleColumn,
|
||||
)
|
||||
from .models import *
|
||||
@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
|
||||
'description', 'filter_logic', 'choices',
|
||||
'description', 'filter_logic', 'choices', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
|
||||
|
||||
@ -73,15 +73,16 @@ class CustomLinkTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
content_type = ContentTypeColumn()
|
||||
enabled = BooleanColumn()
|
||||
new_window = BooleanColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = CustomLink
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window',
|
||||
'pk', 'id', 'name', 'content_type', 'enabled', 'link_text', 'link_url', 'weight', 'group_name',
|
||||
'button_class', 'new_window', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
|
||||
default_columns = ('pk', 'name', 'content_type', 'enabled', 'group_name', 'button_class', 'new_window')
|
||||
|
||||
|
||||
#
|
||||
@ -100,6 +101,7 @@ class ExportTemplateTable(BaseTable):
|
||||
model = ExportTemplate
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
|
||||
@ -134,7 +136,7 @@ class WebhookTable(BaseTable):
|
||||
model = Webhook
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
|
||||
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
|
||||
@ -152,12 +154,11 @@ class TagTable(BaseTable):
|
||||
linkify=True
|
||||
)
|
||||
color = ColorColumn()
|
||||
actions = ButtonsColumn(Tag)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Tag
|
||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
|
||||
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions')
|
||||
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description')
|
||||
|
||||
|
||||
class TaggedItemTable(BaseTable):
|
||||
@ -193,7 +194,8 @@ class ConfigContextTable(BaseTable):
|
||||
model = ConfigContext
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
|
||||
'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')
|
||||
|
||||
@ -215,6 +217,7 @@ class ObjectChangeTable(BaseTable):
|
||||
template_code=OBJECTCHANGE_REQUEST_ID,
|
||||
verbose_name='Request ID'
|
||||
)
|
||||
actions = ActionsColumn(sequence=())
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ObjectChange
|
||||
@ -233,9 +236,6 @@ class ObjectJournalTable(BaseTable):
|
||||
comments = tables.TemplateColumn(
|
||||
template_code='{% load helpers %}{{ value|render_markdown|truncatewords_html:50 }}'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=JournalEntry
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = JournalEntry
|
||||
@ -261,6 +261,5 @@ class JournalEntryTable(ObjectJournalTable):
|
||||
'comments', 'actions'
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind',
|
||||
'comments', 'actions'
|
||||
'pk', 'created', 'created_by', 'assigned_object_type', 'assigned_object', 'kind', 'comments'
|
||||
)
|
||||
|
@ -36,7 +36,7 @@ def custom_links(context, obj):
|
||||
Render all applicable links for the given object.
|
||||
"""
|
||||
content_type = ContentType.objects.get_for_model(obj)
|
||||
custom_links = CustomLink.objects.filter(content_type=content_type)
|
||||
custom_links = CustomLink.objects.filter(content_type=content_type, enabled=True)
|
||||
if not custom_links:
|
||||
return ''
|
||||
|
||||
|
@ -139,24 +139,28 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
||||
{
|
||||
'content_type': 'dcim.site',
|
||||
'name': 'Custom Link 4',
|
||||
'enabled': True,
|
||||
'link_text': 'Link 4',
|
||||
'link_url': 'http://example.com/?4',
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.site',
|
||||
'name': 'Custom Link 5',
|
||||
'enabled': True,
|
||||
'link_text': 'Link 5',
|
||||
'link_url': 'http://example.com/?5',
|
||||
},
|
||||
{
|
||||
'content_type': 'dcim.site',
|
||||
'name': 'Custom Link 6',
|
||||
'enabled': False,
|
||||
'link_text': 'Link 6',
|
||||
'link_url': 'http://example.com/?6',
|
||||
},
|
||||
]
|
||||
bulk_update_data = {
|
||||
'new_window': True,
|
||||
'enabled': False,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@ -167,18 +171,21 @@ class CustomLinkTest(APIViewTestCases.APIViewTestCase):
|
||||
CustomLink(
|
||||
content_type=site_ct,
|
||||
name='Custom Link 1',
|
||||
enabled=True,
|
||||
link_text='Link 1',
|
||||
link_url='http://example.com/?1',
|
||||
),
|
||||
CustomLink(
|
||||
content_type=site_ct,
|
||||
name='Custom Link 2',
|
||||
enabled=True,
|
||||
link_text='Link 2',
|
||||
link_url='http://example.com/?2',
|
||||
),
|
||||
CustomLink(
|
||||
content_type=site_ct,
|
||||
name='Custom Link 3',
|
||||
enabled=False,
|
||||
link_text='Link 3',
|
||||
link_url='http://example.com/?3',
|
||||
),
|
||||
|
@ -378,9 +378,22 @@ class CustomFieldAPITest(APITestCase):
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_DATE, name='date_field', default='2020-01-01'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_URL, name='url_field', default='http://example.com/1'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_JSON, name='json_field', default='{"x": "y"}'),
|
||||
CustomField(type=CustomFieldTypeChoices.TYPE_SELECT, name='choice_field', default='Foo', choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_SELECT,
|
||||
name='select_field',
|
||||
default='Foo',
|
||||
choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
name='multiselect_field',
|
||||
default=['Foo'],
|
||||
choices=(
|
||||
'Foo', 'Bar', 'Baz'
|
||||
)
|
||||
),
|
||||
CustomField(
|
||||
type=CustomFieldTypeChoices.TYPE_OBJECT,
|
||||
name='object_field',
|
||||
@ -416,11 +429,37 @@ class CustomFieldAPITest(APITestCase):
|
||||
custom_fields[5].name: 'http://example.com/2',
|
||||
custom_fields[6].name: '{"foo": 1, "bar": 2}',
|
||||
custom_fields[7].name: 'Bar',
|
||||
custom_fields[8].name: vlans[1].pk,
|
||||
custom_fields[9].name: [vlans[2].pk, vlans[3].pk],
|
||||
custom_fields[8].name: ['Bar', 'Baz'],
|
||||
custom_fields[9].name: vlans[1].pk,
|
||||
custom_fields[10].name: [vlans[2].pk, vlans[3].pk],
|
||||
}
|
||||
sites[1].save()
|
||||
|
||||
def test_get_custom_fields(self):
|
||||
TYPES = {
|
||||
CustomFieldTypeChoices.TYPE_TEXT: 'string',
|
||||
CustomFieldTypeChoices.TYPE_LONGTEXT: 'string',
|
||||
CustomFieldTypeChoices.TYPE_INTEGER: 'integer',
|
||||
CustomFieldTypeChoices.TYPE_BOOLEAN: 'boolean',
|
||||
CustomFieldTypeChoices.TYPE_DATE: 'string',
|
||||
CustomFieldTypeChoices.TYPE_URL: 'string',
|
||||
CustomFieldTypeChoices.TYPE_JSON: 'object',
|
||||
CustomFieldTypeChoices.TYPE_SELECT: 'string',
|
||||
CustomFieldTypeChoices.TYPE_MULTISELECT: 'array',
|
||||
CustomFieldTypeChoices.TYPE_OBJECT: 'object',
|
||||
CustomFieldTypeChoices.TYPE_MULTIOBJECT: 'array',
|
||||
}
|
||||
|
||||
self.add_permissions('extras.view_customfield')
|
||||
url = reverse('extras-api:customfield-list')
|
||||
response = self.client.get(url, **self.header)
|
||||
self.assertEqual(response.data['count'], len(TYPES))
|
||||
|
||||
# Validate data types
|
||||
for customfield in response.data['results']:
|
||||
cf_type = customfield['type']['value']
|
||||
self.assertEqual(customfield['data_type'], TYPES[cf_type])
|
||||
|
||||
def test_get_single_object_without_custom_field_data(self):
|
||||
"""
|
||||
Validate that custom fields are present on an object even if it has no values defined.
|
||||
@ -439,7 +478,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'date_field': None,
|
||||
'url_field': None,
|
||||
'json_field': None,
|
||||
'choice_field': None,
|
||||
'select_field': None,
|
||||
'multiselect_field': None,
|
||||
'object_field': None,
|
||||
'multiobject_field': None,
|
||||
})
|
||||
@ -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']['url_field'], site2_cfvs['url_field'])
|
||||
self.assertEqual(response.data['custom_fields']['json_field'], site2_cfvs['json_field'])
|
||||
self.assertEqual(response.data['custom_fields']['choice_field'], site2_cfvs['choice_field'])
|
||||
self.assertEqual(response.data['custom_fields']['select_field'], site2_cfvs['select_field'])
|
||||
self.assertEqual(response.data['custom_fields']['multiselect_field'], site2_cfvs['multiselect_field'])
|
||||
self.assertEqual(response.data['custom_fields']['object_field']['id'], site2_cfvs['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response.data['custom_fields']['multiobject_field']],
|
||||
@ -495,7 +536,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['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(
|
||||
[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(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['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['multiobject_field'], cf_defaults['multiobject_field'])
|
||||
|
||||
@ -530,7 +573,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'date_field': '2020-01-02',
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'choice_field': 'Bar',
|
||||
'select_field': 'Bar',
|
||||
'multiselect_field': ['Bar', 'Baz'],
|
||||
'object_field': VLAN.objects.get(vid=2).pk,
|
||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
||||
},
|
||||
@ -551,7 +595,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['date_field'], data_cf['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], data_cf['choice_field'])
|
||||
self.assertEqual(response_cf['select_field'], data_cf['select_field'])
|
||||
self.assertEqual(response_cf['multiselect_field'], data_cf['multiselect_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], data_cf['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
@ -567,7 +612,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), data_cf['date_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], data_cf['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], data_cf['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], data_cf['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], data_cf['select_field'])
|
||||
self.assertEqual(site.custom_field_data['multiselect_field'], data_cf['multiselect_field'])
|
||||
self.assertEqual(site.custom_field_data['object_field'], data_cf['object_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], data_cf['multiobject_field'])
|
||||
|
||||
@ -611,7 +657,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['date_field'], cf_defaults['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], cf_defaults['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], cf_defaults['json_field'])
|
||||
self.assertEqual(response_cf['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(
|
||||
[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(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['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['multiobject_field'], cf_defaults['multiobject_field'])
|
||||
|
||||
@ -643,7 +691,8 @@ class CustomFieldAPITest(APITestCase):
|
||||
'date_field': '2020-01-02',
|
||||
'url_field': 'http://example.com/2',
|
||||
'json_field': '{"foo": 1, "bar": 2}',
|
||||
'choice_field': 'Bar',
|
||||
'select_field': 'Bar',
|
||||
'multiselect_field': ['Bar', 'Baz'],
|
||||
'object_field': VLAN.objects.get(vid=2).pk,
|
||||
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
|
||||
}
|
||||
@ -682,7 +731,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['date_field'], custom_field_data['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], custom_field_data['choice_field'])
|
||||
self.assertEqual(response_cf['select_field'], custom_field_data['select_field'])
|
||||
self.assertEqual(response_cf['multiselect_field'], custom_field_data['multiselect_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], custom_field_data['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
custom_field_data['multiobject_field']
|
||||
@ -697,7 +748,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(str(site.custom_field_data['date_field']), custom_field_data['date_field'])
|
||||
self.assertEqual(site.custom_field_data['url_field'], custom_field_data['url_field'])
|
||||
self.assertEqual(site.custom_field_data['json_field'], custom_field_data['json_field'])
|
||||
self.assertEqual(site.custom_field_data['choice_field'], custom_field_data['choice_field'])
|
||||
self.assertEqual(site.custom_field_data['select_field'], custom_field_data['select_field'])
|
||||
self.assertEqual(site.custom_field_data['multiselect_field'], custom_field_data['multiselect_field'])
|
||||
self.assertEqual(site.custom_field_data['object_field'], custom_field_data['object_field'])
|
||||
self.assertEqual(site.custom_field_data['multiobject_field'], custom_field_data['multiobject_field'])
|
||||
|
||||
def test_update_single_object_with_values(self):
|
||||
@ -728,7 +781,9 @@ class CustomFieldAPITest(APITestCase):
|
||||
self.assertEqual(response_cf['date_field'], original_cfvs['date_field'])
|
||||
self.assertEqual(response_cf['url_field'], original_cfvs['url_field'])
|
||||
self.assertEqual(response_cf['json_field'], original_cfvs['json_field'])
|
||||
self.assertEqual(response_cf['choice_field'], original_cfvs['choice_field'])
|
||||
self.assertEqual(response_cf['select_field'], original_cfvs['select_field'])
|
||||
self.assertEqual(response_cf['multiselect_field'], original_cfvs['multiselect_field'])
|
||||
self.assertEqual(response_cf['object_field']['id'], original_cfvs['object_field'])
|
||||
self.assertEqual(
|
||||
[obj['id'] for obj in response_cf['multiobject_field']],
|
||||
original_cfvs['multiobject_field']
|
||||
@ -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['url_field'], original_cfvs['url_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'])
|
||||
|
||||
def test_minimum_maximum_values_validation(self):
|
||||
@ -810,6 +867,9 @@ class CustomFieldImportTest(TestCase):
|
||||
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
|
||||
'Choice A', 'Choice B', 'Choice C',
|
||||
]),
|
||||
)
|
||||
for cf in custom_fields:
|
||||
cf.save()
|
||||
@ -820,19 +880,20 @@ class CustomFieldImportTest(TestCase):
|
||||
Import a Site in CSV format, including a value for each CustomField.
|
||||
"""
|
||||
data = (
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
|
||||
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
|
||||
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
|
||||
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
|
||||
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
|
||||
)
|
||||
csv_data = '\n'.join(','.join(row) for row in data)
|
||||
|
||||
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(Site.objects.count(), 3)
|
||||
|
||||
# Validate data for site 1
|
||||
site1 = Site.objects.get(name='Site 1')
|
||||
self.assertEqual(len(site1.custom_field_data), 8)
|
||||
self.assertEqual(len(site1.custom_field_data), 9)
|
||||
self.assertEqual(site1.custom_field_data['text'], 'ABC')
|
||||
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
|
||||
self.assertEqual(site1.custom_field_data['integer'], 123)
|
||||
@ -841,10 +902,11 @@ class CustomFieldImportTest(TestCase):
|
||||
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
|
||||
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
|
||||
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
|
||||
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
|
||||
|
||||
# Validate data for site 2
|
||||
site2 = Site.objects.get(name='Site 2')
|
||||
self.assertEqual(len(site2.custom_field_data), 8)
|
||||
self.assertEqual(len(site2.custom_field_data), 9)
|
||||
self.assertEqual(site2.custom_field_data['text'], 'DEF')
|
||||
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
|
||||
self.assertEqual(site2.custom_field_data['integer'], 456)
|
||||
@ -853,6 +915,7 @@ class CustomFieldImportTest(TestCase):
|
||||
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
|
||||
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
|
||||
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
|
||||
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
|
||||
|
||||
# No custom field data should be set for site 3
|
||||
site3 = Site.objects.get(name='Site 3')
|
||||
|
@ -100,6 +100,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
|
||||
CustomLink(
|
||||
name='Custom Link 1',
|
||||
content_type=content_types[0],
|
||||
enabled=True,
|
||||
weight=100,
|
||||
new_window=False,
|
||||
link_text='Link 1',
|
||||
@ -108,6 +109,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
|
||||
CustomLink(
|
||||
name='Custom Link 2',
|
||||
content_type=content_types[1],
|
||||
enabled=True,
|
||||
weight=200,
|
||||
new_window=False,
|
||||
link_text='Link 1',
|
||||
@ -116,6 +118,7 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
|
||||
CustomLink(
|
||||
name='Custom Link 3',
|
||||
content_type=content_types[2],
|
||||
enabled=False,
|
||||
weight=300,
|
||||
new_window=True,
|
||||
link_text='Link 1',
|
||||
@ -136,6 +139,12 @@ class CustomLinkTestCase(TestCase, BaseFilterSetTests):
|
||||
params = {'weight': [100, 200]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_enabled(self):
|
||||
params = {'enabled': True}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
params = {'enabled': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
def test_new_window(self):
|
||||
params = {'new_window': False}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
@ -59,14 +59,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
CustomLink.objects.bulk_create((
|
||||
CustomLink(name='Custom Link 1', content_type=site_ct, link_text='Link 1', link_url='http://example.com/?1'),
|
||||
CustomLink(name='Custom Link 2', content_type=site_ct, link_text='Link 2', link_url='http://example.com/?2'),
|
||||
CustomLink(name='Custom Link 3', content_type=site_ct, link_text='Link 3', link_url='http://example.com/?3'),
|
||||
CustomLink(name='Custom Link 1', content_type=site_ct, enabled=True, link_text='Link 1', link_url='http://example.com/?1'),
|
||||
CustomLink(name='Custom Link 2', content_type=site_ct, enabled=True, link_text='Link 2', link_url='http://example.com/?2'),
|
||||
CustomLink(name='Custom Link 3', content_type=site_ct, enabled=False, link_text='Link 3', link_url='http://example.com/?3'),
|
||||
))
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Custom Link X',
|
||||
'content_type': site_ct.pk,
|
||||
'enabled': False,
|
||||
'weight': 100,
|
||||
'button_class': CustomLinkButtonClassChoices.DEFAULT,
|
||||
'link_text': 'Link X',
|
||||
@ -74,14 +75,15 @@ class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,content_type,weight,button_class,link_text,link_url",
|
||||
"Custom Link 4,dcim.site,100,blue,Link 4,http://exmaple.com/?4",
|
||||
"Custom Link 5,dcim.site,100,blue,Link 5,http://exmaple.com/?5",
|
||||
"Custom Link 6,dcim.site,100,blue,Link 6,http://exmaple.com/?6",
|
||||
"name,content_type,enabled,weight,button_class,link_text,link_url",
|
||||
"Custom Link 4,dcim.site,True,100,blue,Link 4,http://exmaple.com/?4",
|
||||
"Custom Link 5,dcim.site,True,100,blue,Link 5,http://exmaple.com/?5",
|
||||
"Custom Link 6,dcim.site,False,100,blue,Link 6,http://exmaple.com/?6",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'button_class': CustomLinkButtonClassChoices.CYAN,
|
||||
'enabled': False,
|
||||
'weight': 200,
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,3 @@
|
||||
import collections
|
||||
|
||||
from django.db.models import Q
|
||||
from django.utils.deconstruct import deconstructible
|
||||
from taggit.managers import _TaggableManager
|
||||
@ -57,21 +55,9 @@ class FeatureQuery:
|
||||
return query
|
||||
|
||||
|
||||
def extras_features(*features):
|
||||
"""
|
||||
Decorator used to register extras provided features to a model
|
||||
"""
|
||||
def wrapper(model_class):
|
||||
# Initialize the model_features store if not already defined
|
||||
if 'model_features' not in registry:
|
||||
registry['model_features'] = {
|
||||
f: collections.defaultdict(list) for f in EXTRAS_FEATURES
|
||||
}
|
||||
for feature in features:
|
||||
if feature in EXTRAS_FEATURES:
|
||||
app_label, model_name = model_class._meta.label_lower.split('.')
|
||||
registry['model_features'][feature][app_label].append(model_name)
|
||||
else:
|
||||
raise ValueError('{} is not a valid extras feature!'.format(feature))
|
||||
return model_class
|
||||
return wrapper
|
||||
def register_features(model, features):
|
||||
for feature in features:
|
||||
if feature not in EXTRAS_FEATURES:
|
||||
raise ValueError(f"{feature} is not a valid extras feature!")
|
||||
app_label, model_name = model._meta.label_lower.split('.')
|
||||
registry['model_features'][feature][app_label].add(model_name)
|
||||
|
@ -11,7 +11,7 @@ from rq import Worker
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.tables import configure_table
|
||||
from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin
|
||||
from . import filtersets, forms, tables
|
||||
@ -215,7 +215,7 @@ class TagView(generic.ObjectView):
|
||||
data=tagged_items,
|
||||
orderable=False
|
||||
)
|
||||
paginate_table(taggeditem_table, request)
|
||||
configure_table(taggeditem_table, request)
|
||||
|
||||
object_types = [
|
||||
{
|
||||
@ -451,7 +451,7 @@ class ObjectChangeLogView(View):
|
||||
data=objectchanges,
|
||||
orderable=False
|
||||
)
|
||||
paginate_table(objectchanges_table, request)
|
||||
configure_table(objectchanges_table, request)
|
||||
|
||||
# Default to using "<app>/<model>.html" as the template, if it exists. Otherwise,
|
||||
# fall back to using base.html.
|
||||
@ -571,7 +571,7 @@ class ObjectJournalView(View):
|
||||
assigned_object_id=obj.pk
|
||||
)
|
||||
journalentry_table = tables.ObjectJournalTable(journalentries)
|
||||
paginate_table(journalentry_table, request)
|
||||
configure_table(journalentry_table, request)
|
||||
|
||||
if request.user.has_perm('extras.add_journalentry'):
|
||||
form = forms.JournalEntryForm(
|
||||
|
@ -67,7 +67,7 @@ def process_webhook(webhook, model_name, event, data, snapshots, timestamp, user
|
||||
# Prepare the HTTP request
|
||||
params = {
|
||||
'method': webhook.http_method,
|
||||
'url': webhook.payload_url,
|
||||
'url': webhook.render_payload_url(context),
|
||||
'headers': headers,
|
||||
'data': body.encode('utf8'),
|
||||
}
|
||||
|
@ -15,6 +15,7 @@ __all__ = [
|
||||
'NestedRoleSerializer',
|
||||
'NestedRouteTargetSerializer',
|
||||
'NestedServiceSerializer',
|
||||
'NestedServiceTemplateSerializer',
|
||||
'NestedVLANGroupSerializer',
|
||||
'NestedVLANSerializer',
|
||||
'NestedVRFSerializer',
|
||||
@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
|
||||
# Services
|
||||
#
|
||||
|
||||
class NestedServiceTemplateSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceTemplate
|
||||
fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
|
||||
|
||||
|
||||
class NestedServiceSerializer(WritableNestedSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
|
||||
|
||||
|
@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer):
|
||||
# Services
|
||||
#
|
||||
|
||||
class ServiceTemplateSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
|
||||
protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = [
|
||||
'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
|
||||
class ServiceSerializer(PrimaryModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
|
||||
device = NestedDeviceSerializer(required=False, allow_null=True)
|
||||
|
@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet)
|
||||
router.register('vlans', views.VLANViewSet)
|
||||
|
||||
# Services
|
||||
router.register('service-templates', views.ServiceTemplateViewSet)
|
||||
router.register('services', views.ServiceViewSet)
|
||||
|
||||
app_name = 'ipam-api'
|
||||
|
@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet):
|
||||
filterset_class = filtersets.VLANFilterSet
|
||||
|
||||
|
||||
class ServiceViewSet(ModelViewSet):
|
||||
class ServiceTemplateViewSet(CustomFieldModelViewSet):
|
||||
queryset = ServiceTemplate.objects.prefetch_related('tags')
|
||||
serializer_class = serializers.ServiceTemplateSerializer
|
||||
filterset_class = filtersets.ServiceTemplateFilterSet
|
||||
|
||||
|
||||
class ServiceViewSet(CustomFieldModelViewSet):
|
||||
queryset = Service.objects.prefetch_related(
|
||||
'device', 'virtual_machine', 'tags', 'ipaddresses'
|
||||
)
|
||||
|
@ -65,6 +65,7 @@ FHRP_PROTOCOL_ROLE_MAPPINGS = {
|
||||
FHRPGroupProtocolChoices.PROTOCOL_HSRP: IPAddressRoleChoices.ROLE_HSRP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_GLBP: IPAddressRoleChoices.ROLE_GLBP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_CARP: IPAddressRoleChoices.ROLE_CARP,
|
||||
FHRPGroupProtocolChoices.PROTOCOL_OTHER: IPAddressRoleChoices.ROLE_VIP,
|
||||
}
|
||||
|
||||
|
||||
|
@ -29,6 +29,7 @@ __all__ = (
|
||||
'RoleFilterSet',
|
||||
'RouteTargetFilterSet',
|
||||
'ServiceFilterSet',
|
||||
'ServiceTemplateFilterSet',
|
||||
'VLANFilterSet',
|
||||
'VLANGroupFilterSet',
|
||||
'VRFFilterSet',
|
||||
@ -854,6 +855,28 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
|
||||
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):
|
||||
q = django_filters.CharFilter(
|
||||
method='search',
|
||||
|
@ -23,6 +23,7 @@ __all__ = (
|
||||
'RoleBulkEditForm',
|
||||
'RouteTargetBulkEditForm',
|
||||
'ServiceBulkEditForm',
|
||||
'ServiceTemplateBulkEditForm',
|
||||
'VLANBulkEditForm',
|
||||
'VLANGroupBulkEditForm',
|
||||
'VRFBulkEditForm',
|
||||
@ -433,9 +434,9 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
]
|
||||
|
||||
|
||||
class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Service.objects.all(),
|
||||
queryset=ServiceTemplate.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
protocol = forms.ChoiceField(
|
||||
@ -459,3 +460,10 @@ class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
|
||||
nullable_fields = [
|
||||
'description',
|
||||
]
|
||||
|
||||
|
||||
class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=Service.objects.all(),
|
||||
widget=forms.MultipleHiddenInput()
|
||||
)
|
||||
|
@ -21,6 +21,7 @@ __all__ = (
|
||||
'RoleCSVForm',
|
||||
'RouteTargetCSVForm',
|
||||
'ServiceCSVForm',
|
||||
'ServiceTemplateCSVForm',
|
||||
'VLANCSVForm',
|
||||
'VLANGroupCSVForm',
|
||||
'VRFCSVForm',
|
||||
@ -392,6 +393,17 @@ class VLANCSVForm(CustomFieldModelCSVForm):
|
||||
}
|
||||
|
||||
|
||||
class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
|
||||
protocol = CSVChoiceField(
|
||||
choices=ServiceProtocolChoices,
|
||||
help_text='IP protocol'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ('name', 'protocol', 'ports', 'description')
|
||||
|
||||
|
||||
class ServiceCSVForm(CustomFieldModelCSVForm):
|
||||
device = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
|
@ -24,6 +24,7 @@ __all__ = (
|
||||
'RoleFilterForm',
|
||||
'RouteTargetFilterForm',
|
||||
'ServiceFilterForm',
|
||||
'ServiceTemplateFilterForm',
|
||||
'VLANFilterForm',
|
||||
'VLANGroupFilterForm',
|
||||
'VRFFilterForm',
|
||||
@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceFilterForm(CustomFieldModelFilterForm):
|
||||
model = Service
|
||||
class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
|
||||
model = ServiceTemplate
|
||||
field_groups = (
|
||||
('q', 'tag'),
|
||||
('protocol', 'port'),
|
||||
@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm):
|
||||
required=False,
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
class ServiceFilterForm(ServiceTemplateFilterForm):
|
||||
model = Service
|
||||
|
@ -31,6 +31,8 @@ __all__ = (
|
||||
'RoleForm',
|
||||
'RouteTargetForm',
|
||||
'ServiceForm',
|
||||
'ServiceCreateForm',
|
||||
'ServiceTemplateForm',
|
||||
'VLANForm',
|
||||
'VLANGroupForm',
|
||||
'VRFForm',
|
||||
@ -580,7 +582,7 @@ class FHRPGroupForm(CustomFieldModelForm):
|
||||
vrf=self.cleaned_data['ip_vrf'],
|
||||
address=self.cleaned_data['ip_address'],
|
||||
status=self.cleaned_data['ip_status'],
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS[self.cleaned_data['protocol']],
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.save()
|
||||
@ -592,6 +594,8 @@ class FHRPGroupForm(CustomFieldModelForm):
|
||||
return instance
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
ip_vrf = self.cleaned_data.get('ip_vrf')
|
||||
ip_address = self.cleaned_data.get('ip_address')
|
||||
ip_status = self.cleaned_data.get('ip_status')
|
||||
@ -628,8 +632,7 @@ class FHRPGroupAssignmentForm(BootstrapMixin, forms.ModelForm):
|
||||
class VLANGroupForm(CustomFieldModelForm):
|
||||
scope_type = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
widget=StaticSelect
|
||||
required=False
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
@ -814,6 +817,27 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
|
||||
}
|
||||
|
||||
|
||||
class ServiceTemplateForm(CustomFieldModelForm):
|
||||
ports = NumericArrayField(
|
||||
base_field=forms.IntegerField(
|
||||
min_value=SERVICE_PORT_MIN,
|
||||
max_value=SERVICE_PORT_MAX
|
||||
),
|
||||
help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ServiceTemplate
|
||||
fields = ('name', 'protocol', 'ports', 'description', 'tags')
|
||||
widgets = {
|
||||
'protocol': StaticSelect(),
|
||||
}
|
||||
|
||||
|
||||
class ServiceForm(CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
@ -857,3 +881,36 @@ class ServiceForm(CustomFieldModelForm):
|
||||
'protocol': StaticSelect(),
|
||||
'ipaddresses': StaticSelectMultiple(),
|
||||
}
|
||||
|
||||
|
||||
class ServiceCreateForm(ServiceForm):
|
||||
service_template = DynamicModelChoiceField(
|
||||
queryset=ServiceTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta(ServiceForm.Meta):
|
||||
fields = [
|
||||
'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
|
||||
'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Fields which may be populated from a ServiceTemplate are not required
|
||||
for field in ('name', 'protocol', 'ports'):
|
||||
self.fields[field].required = False
|
||||
del(self.fields[field].widget.attrs['required'])
|
||||
|
||||
def clean(self):
|
||||
if self.cleaned_data['service_template']:
|
||||
# Create a new Service from the specified template
|
||||
service_template = self.cleaned_data['service_template']
|
||||
self.cleaned_data['name'] = service_template.name
|
||||
self.cleaned_data['protocol'] = service_template.protocol
|
||||
self.cleaned_data['ports'] = service_template.ports
|
||||
if not self.cleaned_data['description']:
|
||||
self.cleaned_data['description'] = service_template.description
|
||||
elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
|
||||
raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")
|
||||
|
@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType):
|
||||
service = ObjectField(ServiceType)
|
||||
service_list = ObjectListField(ServiceType)
|
||||
|
||||
service_template = ObjectField(ServiceTemplateType)
|
||||
service_template_list = ObjectListField(ServiceTemplateType)
|
||||
|
||||
fhrp_group = ObjectField(FHRPGroupType)
|
||||
fhrp_group_list = ObjectListField(FHRPGroupType)
|
||||
|
||||
|
@ -16,6 +16,7 @@ __all__ = (
|
||||
'RoleType',
|
||||
'RouteTargetType',
|
||||
'ServiceType',
|
||||
'ServiceTemplateType',
|
||||
'VLANType',
|
||||
'VLANGroupType',
|
||||
'VRFType',
|
||||
@ -120,6 +121,14 @@ class ServiceType(PrimaryObjectType):
|
||||
filterset_class = filtersets.ServiceFilterSet
|
||||
|
||||
|
||||
class ServiceTemplateType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
model = models.ServiceTemplate
|
||||
fields = '__all__'
|
||||
filterset_class = filtersets.ServiceTemplateFilterSet
|
||||
|
||||
|
||||
class VLANType(PrimaryObjectType):
|
||||
|
||||
class Meta:
|
||||
|
33
netbox/ipam/migrations/0055_servicetemplate.py
Normal file
33
netbox/ipam/migrations/0055_servicetemplate.py
Normal 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',),
|
||||
},
|
||||
),
|
||||
]
|
@ -16,6 +16,7 @@ __all__ = (
|
||||
'Role',
|
||||
'RouteTarget',
|
||||
'Service',
|
||||
'ServiceTemplate',
|
||||
'VLAN',
|
||||
'VLANGroup',
|
||||
'VRF',
|
||||
|
@ -4,8 +4,8 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from netbox.models.features import WebhooksMixin
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
|
||||
@ -15,7 +15,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class FHRPGroup(PrimaryModel):
|
||||
"""
|
||||
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])
|
||||
|
||||
|
||||
@extras_features('webhooks')
|
||||
class FHRPGroupAssignment(ChangeLoggedModel):
|
||||
class FHRPGroupAssignment(WebhooksMixin, ChangeLoggedModel):
|
||||
interface_type = models.ForeignKey(
|
||||
to=ContentType,
|
||||
on_delete=models.CASCADE
|
||||
|
@ -9,7 +9,6 @@ from django.utils.functional import cached_property
|
||||
|
||||
from dcim.fields import ASNField
|
||||
from dcim.models import Device
|
||||
from extras.utils import extras_features
|
||||
from netbox.models import OrganizationalModel, PrimaryModel
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
@ -54,7 +53,6 @@ class GetAvailablePrefixesMixin:
|
||||
return available_prefixes.iter_cidrs()[0]
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RIR(OrganizationalModel):
|
||||
"""
|
||||
A Regional Internet Registry (RIR) is responsible for the allocation of a large portion of the global IP address
|
||||
@ -90,7 +88,6 @@ class RIR(OrganizationalModel):
|
||||
return reverse('ipam:rir', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class ASN(PrimaryModel):
|
||||
"""
|
||||
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'
|
||||
|
||||
def __str__(self):
|
||||
return f'AS{self.asn}'
|
||||
return f'AS{self.asn_with_asdot}'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:asn', args=[self.pk])
|
||||
|
||||
@property
|
||||
def asn_asdot(self):
|
||||
"""
|
||||
Return ASDOT notation for AS numbers greater than 16 bits.
|
||||
"""
|
||||
if self.asn > 65535:
|
||||
return f'{self.asn // 65536}.{self.asn % 65536}'
|
||||
return self.asn
|
||||
|
||||
@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):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Role(OrganizationalModel):
|
||||
"""
|
||||
A Role represents the functional role of a Prefix or VLAN; for example, "Customer," "Infrastructure," or
|
||||
@ -266,7 +280,6 @@ class Role(OrganizationalModel):
|
||||
return reverse('ipam:role', args=[self.pk])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class IPRange(PrimaryModel):
|
||||
"""
|
||||
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)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class IPAddress(PrimaryModel):
|
||||
"""
|
||||
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
||||
|
@ -4,7 +4,6 @@ from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.utils import extras_features
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from netbox.models import PrimaryModel
|
||||
@ -13,11 +12,57 @@ from utilities.utils import array_to_string
|
||||
|
||||
__all__ = (
|
||||
'Service',
|
||||
'ServiceTemplate',
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class Service(PrimaryModel):
|
||||
class ServiceBase(models.Model):
|
||||
protocol = models.CharField(
|
||||
max_length=50,
|
||||
choices=ServiceProtocolChoices
|
||||
)
|
||||
ports = ArrayField(
|
||||
base_field=models.PositiveIntegerField(
|
||||
validators=[
|
||||
MinValueValidator(SERVICE_PORT_MIN),
|
||||
MaxValueValidator(SERVICE_PORT_MAX)
|
||||
]
|
||||
),
|
||||
verbose_name='Port numbers'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
|
||||
|
||||
@property
|
||||
def port_list(self):
|
||||
return array_to_string(self.ports)
|
||||
|
||||
|
||||
class ServiceTemplate(ServiceBase, 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
|
||||
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
||||
@ -40,36 +85,16 @@ class Service(PrimaryModel):
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
protocol = models.CharField(
|
||||
max_length=50,
|
||||
choices=ServiceProtocolChoices
|
||||
)
|
||||
ports = ArrayField(
|
||||
base_field=models.PositiveIntegerField(
|
||||
validators=[
|
||||
MinValueValidator(SERVICE_PORT_MIN),
|
||||
MaxValueValidator(SERVICE_PORT_MAX)
|
||||
]
|
||||
),
|
||||
verbose_name='Port numbers'
|
||||
)
|
||||
ipaddresses = models.ManyToManyField(
|
||||
to='ipam.IPAddress',
|
||||
related_name='services',
|
||||
blank=True,
|
||||
verbose_name='IP addresses'
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('ipam:service', args=[self.pk])
|
||||
|
||||
@ -85,7 +110,3 @@ class Service(PrimaryModel):
|
||||
raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
|
||||
if not self.device and not self.virtual_machine:
|
||||
raise ValidationError("A service must be associated with either a device or a virtual machine.")
|
||||
|
||||
@property
|
||||
def port_list(self):
|
||||
return array_to_string(self.ports)
|
||||
|
@ -6,7 +6,6 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from dcim.models import Interface
|
||||
from extras.utils import extras_features
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.querysets import VLANQuerySet
|
||||
@ -20,7 +19,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class VLANGroup(OrganizationalModel):
|
||||
"""
|
||||
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
|
||||
@ -118,7 +116,6 @@ class VLANGroup(OrganizationalModel):
|
||||
return None
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class VLAN(PrimaryModel):
|
||||
"""
|
||||
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
from extras.utils import extras_features
|
||||
from ipam.constants import *
|
||||
from netbox.models import PrimaryModel
|
||||
|
||||
@ -12,7 +11,6 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class VRF(PrimaryModel):
|
||||
"""
|
||||
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])
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
class RouteTarget(PrimaryModel):
|
||||
"""
|
||||
A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.
|
||||
|
@ -1,6 +1,6 @@
|
||||
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 *
|
||||
|
||||
__all__ = (
|
||||
@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable):
|
||||
model = FHRPGroup
|
||||
fields = (
|
||||
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
|
||||
'tags',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
|
||||
|
||||
@ -58,9 +58,8 @@ class FHRPGroupAssignmentTable(BaseTable):
|
||||
group = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=FHRPGroupAssignment,
|
||||
buttons=('edit', 'delete', 'foo')
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit', 'delete')
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
@ -2,12 +2,11 @@ import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from ipam.models import *
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn, UtilizationColumn,
|
||||
BaseTable, BooleanColumn, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn, UtilizationColumn,
|
||||
)
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'AggregateTable',
|
||||
@ -89,12 +88,14 @@ class RIRTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='ipam:rir_list'
|
||||
)
|
||||
actions = ButtonsColumn(RIR)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RIR
|
||||
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -104,19 +105,20 @@ class RIRTable(BaseTable):
|
||||
class ASNTable(BaseTable):
|
||||
pk = ToggleColumn()
|
||||
asn = tables.Column(
|
||||
accessor=tables.A('asn_asdot'),
|
||||
linkify=True
|
||||
)
|
||||
|
||||
site_count = LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'asn_id': 'pk'},
|
||||
verbose_name='Sites'
|
||||
)
|
||||
actions = ButtonsColumn(ASN)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = ASN
|
||||
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions')
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
|
||||
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'created', 'last_updated', 'actions')
|
||||
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant')
|
||||
|
||||
|
||||
#
|
||||
@ -147,7 +149,10 @@ class AggregateTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Aggregate
|
||||
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
|
||||
|
||||
|
||||
@ -173,12 +178,14 @@ class RoleTable(BaseTable):
|
||||
tags = TagColumn(
|
||||
url_name='ipam:role_list'
|
||||
)
|
||||
actions = ButtonsColumn(Role)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Role
|
||||
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
|
||||
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -264,8 +271,8 @@ class PrefixTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Prefix
|
||||
fields = (
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
|
||||
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
|
||||
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
|
||||
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
|
||||
@ -306,7 +313,7 @@ class IPRangeTable(BaseTable):
|
||||
model = IPRange
|
||||
fields = (
|
||||
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
'utilization', 'tags',
|
||||
'utilization', 'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
|
||||
@ -364,7 +371,7 @@ class IPAddressTable(BaseTable):
|
||||
model = IPAddress
|
||||
fields = (
|
||||
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
|
||||
'tags',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',
|
||||
@ -405,9 +412,6 @@ class AssignedIPAddressesTable(BaseTable):
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = TenantColumn()
|
||||
actions = ButtonsColumn(
|
||||
model=IPAddress
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = IPAddress
|
||||
|
@ -5,12 +5,27 @@ from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
'ServiceTable',
|
||||
'ServiceTemplateTable',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
class ServiceTemplateTable(BaseTable):
|
||||
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):
|
||||
pk = ToggleColumn()
|
||||
@ -21,9 +36,8 @@ class ServiceTable(BaseTable):
|
||||
linkify=True,
|
||||
order_by=('device', 'virtual_machine')
|
||||
)
|
||||
ports = tables.TemplateColumn(
|
||||
template_code='{{ record.port_list }}',
|
||||
verbose_name='Ports'
|
||||
ports = tables.Column(
|
||||
accessor=tables.A('port_list')
|
||||
)
|
||||
tags = TagColumn(
|
||||
url_name='ipam:service_list'
|
||||
@ -31,5 +45,8 @@ class ServiceTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Service
|
||||
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')
|
||||
|
@ -5,7 +5,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
ActionsColumn, BaseTable, BooleanColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
@ -38,7 +38,7 @@ VLAN_PREFIXES = """
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
VLANGROUP_BUTTONS = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% if next_vid and perms.ipam.add_vlan %}
|
||||
<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(
|
||||
url_name='ipam:vlangroup_list'
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=VLANGroup,
|
||||
prepend_template=VLANGROUP_ADD_VLAN
|
||||
actions = ActionsColumn(
|
||||
extra_buttons=VLANGROUP_BUTTONS
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLANGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
|
||||
'tags', 'actions',
|
||||
'tags', 'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
|
||||
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description')
|
||||
|
||||
|
||||
#
|
||||
@ -128,7 +127,10 @@ class VLANTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VLAN
|
||||
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
|
||||
fields = (
|
||||
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
|
||||
'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
row_attrs = {
|
||||
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',
|
||||
@ -153,7 +155,9 @@ class VLANDevicesTable(VLANMembersTable):
|
||||
device = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(Interface, buttons=['edit'])
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit',)
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
@ -165,7 +169,9 @@ class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
virtual_machine = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(VMInterface, buttons=['edit'])
|
||||
actions = ActionsColumn(
|
||||
sequence=('edit',)
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
|
@ -47,7 +47,8 @@ class VRFTable(BaseTable):
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VRF
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
|
||||
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
|
||||
'tags', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
|
||||
|
||||
@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = RouteTarget
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
|
||||
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
|
||||
default_columns = ('pk', 'name', 'tenant', 'description')
|
||||
|
@ -832,6 +832,41 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
|
||||
self.assertTrue(content['detail'].startswith('Unable to delete object.'))
|
||||
|
||||
|
||||
class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
|
||||
model = ServiceTemplate
|
||||
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
bulk_update_data = {
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
service_templates = (
|
||||
ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1, 2]),
|
||||
ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3, 4]),
|
||||
ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[5, 6]),
|
||||
)
|
||||
ServiceTemplate.objects.bulk_create(service_templates)
|
||||
|
||||
cls.create_data = [
|
||||
{
|
||||
'name': 'Service Template 4',
|
||||
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
|
||||
'ports': [7, 8],
|
||||
},
|
||||
{
|
||||
'name': 'Service Template 5',
|
||||
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
|
||||
'ports': [9, 10],
|
||||
},
|
||||
{
|
||||
'name': 'Service Template 6',
|
||||
'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
|
||||
'ports': [11, 12],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class ServiceTest(APIViewTestCases.APIViewTestCase):
|
||||
model = Service
|
||||
brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
|
||||
|
@ -1307,6 +1307,35 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) # 5 scoped + 1 global
|
||||
|
||||
|
||||
class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
filterset = ServiceTemplateFilterSet
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
service_templates = (
|
||||
ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
|
||||
ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
|
||||
ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
|
||||
ServiceTemplate(name='Service Template 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
|
||||
ServiceTemplate(name='Service Template 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
|
||||
ServiceTemplate(name='Service Template 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
|
||||
)
|
||||
ServiceTemplate.objects.bulk_create(service_templates)
|
||||
|
||||
def test_name(self):
|
||||
params = {'name': ['Service Template 1', 'Service Template 2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_protocol(self):
|
||||
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_port(self):
|
||||
params = {'port': '1001'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
|
||||
|
||||
class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
queryset = Service.objects.all()
|
||||
filterset = ServiceFilterSet
|
||||
|
@ -1,5 +1,7 @@
|
||||
import datetime
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from netaddr import IPNetwork
|
||||
|
||||
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
|
||||
@ -222,6 +224,21 @@ class AggregateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_aggregate_prefixes(self):
|
||||
rir = RIR.objects.first()
|
||||
aggregate = Aggregate.objects.create(prefix=IPNetwork('192.168.0.0/16'), rir=rir)
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('192.168.1.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.2.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.3.0/24')),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
self.assertEqual(aggregate.get_child_prefixes().count(), 3)
|
||||
|
||||
url = reverse('ipam:aggregate_prefixes', kwargs={'pk': aggregate.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class RoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
model = Role
|
||||
@ -319,6 +336,48 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_prefixes(self):
|
||||
prefixes = (
|
||||
Prefix(prefix=IPNetwork('192.168.0.0/16')),
|
||||
Prefix(prefix=IPNetwork('192.168.1.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.2.0/24')),
|
||||
Prefix(prefix=IPNetwork('192.168.3.0/24')),
|
||||
)
|
||||
Prefix.objects.bulk_create(prefixes)
|
||||
self.assertEqual(prefixes[0].get_child_prefixes().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_prefixes', kwargs={'pk': prefixes[0].pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_ipranges(self):
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
|
||||
ip_ranges = (
|
||||
IPRange(start_address='192.168.0.1/24', end_address='192.168.0.100/24', size=99),
|
||||
IPRange(start_address='192.168.1.1/24', end_address='192.168.1.100/24', size=99),
|
||||
IPRange(start_address='192.168.2.1/24', end_address='192.168.2.100/24', size=99),
|
||||
)
|
||||
IPRange.objects.bulk_create(ip_ranges)
|
||||
self.assertEqual(prefix.get_child_ranges().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_ipranges', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_prefix_ipaddresses(self):
|
||||
prefix = Prefix.objects.create(prefix=IPNetwork('192.168.0.0/16'))
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.1/16')),
|
||||
IPAddress(address=IPNetwork('192.168.0.2/16')),
|
||||
IPAddress(address=IPNetwork('192.168.0.3/16')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
self.assertEqual(prefix.get_child_ips().count(), 3)
|
||||
|
||||
url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPRange
|
||||
@ -377,6 +436,24 @@ class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_iprange_ipaddresses(self):
|
||||
iprange = IPRange.objects.create(
|
||||
start_address=IPNetwork('192.168.0.1/24'),
|
||||
end_address=IPNetwork('192.168.0.100/24'),
|
||||
size=99
|
||||
)
|
||||
ip_addresses = (
|
||||
IPAddress(address=IPNetwork('192.168.0.1/24')),
|
||||
IPAddress(address=IPNetwork('192.168.0.2/24')),
|
||||
IPAddress(address=IPNetwork('192.168.0.3/24')),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
self.assertEqual(iprange.get_child_ips().count(), 3)
|
||||
|
||||
url = reverse('ipam:iprange_ipaddresses', kwargs={'pk': iprange.pk})
|
||||
self.assertHttpStatus(self.client.get(url), 200)
|
||||
|
||||
|
||||
class IPAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = IPAddress
|
||||
@ -564,6 +641,41 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
|
||||
class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = ServiceTemplate
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
ServiceTemplate.objects.bulk_create([
|
||||
ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||
ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
|
||||
ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
|
||||
])
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
cls.form_data = {
|
||||
'name': 'Service Template X',
|
||||
'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
|
||||
'ports': '104,105',
|
||||
'description': 'A new service template',
|
||||
'tags': [t.pk for t in tags],
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,protocol,ports,description",
|
||||
"Service Template 4,tcp,1,First service template",
|
||||
"Service Template 5,tcp,2,Second service template",
|
||||
"Service Template 6,tcp,3,Third service template",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
|
||||
'ports': '106,107',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
|
||||
class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = Service
|
||||
|
||||
@ -607,3 +719,30 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
'ports': '106,107',
|
||||
'description': 'New description',
|
||||
}
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_create_from_template(self):
|
||||
self.add_permissions('ipam.add_service')
|
||||
|
||||
device = Device.objects.first()
|
||||
service_template = ServiceTemplate.objects.create(
|
||||
name='HTTP',
|
||||
protocol=ServiceProtocolChoices.PROTOCOL_TCP,
|
||||
ports=[80],
|
||||
description='Hypertext transfer protocol'
|
||||
)
|
||||
|
||||
request = {
|
||||
'path': self._get_url('add'),
|
||||
'data': {
|
||||
'device': device.pk,
|
||||
'service_template': service_template.pk,
|
||||
},
|
||||
}
|
||||
self.assertHttpStatus(self.client.post(**request), 302)
|
||||
instance = self._get_queryset().order_by('pk').last()
|
||||
self.assertEqual(instance.device, device)
|
||||
self.assertEqual(instance.name, service_template.name)
|
||||
self.assertEqual(instance.protocol, service_template.protocol)
|
||||
self.assertEqual(instance.ports, service_template.ports)
|
||||
self.assertEqual(instance.description, service_template.description)
|
||||
|
@ -162,9 +162,21 @@ urlpatterns = [
|
||||
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}),
|
||||
|
||||
# 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
|
||||
path('services/', views.ServiceListView.as_view(), name='service_list'),
|
||||
path('services/add/', views.ServiceEditView.as_view(), name='service_add'),
|
||||
path('services/add/', views.ServiceCreateView.as_view(), name='service_add'),
|
||||
path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
|
||||
path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
|
||||
path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),
|
||||
|
@ -8,7 +8,7 @@ from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.models import Interface, Site
|
||||
from dcim.tables import SiteTable
|
||||
from netbox.views import generic
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.tables import configure_table
|
||||
from utilities.utils import count_related
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
@ -161,7 +161,7 @@ class RIRView(generic.ObjectView):
|
||||
rir=instance
|
||||
)
|
||||
aggregates_table = tables.AggregateTable(aggregates, exclude=('rir', 'utilization'))
|
||||
paginate_table(aggregates_table, request)
|
||||
configure_table(aggregates_table, request)
|
||||
|
||||
return {
|
||||
'aggregates_table': aggregates_table,
|
||||
@ -219,7 +219,7 @@ class ASNView(generic.ObjectView):
|
||||
def get_extra_context(self, request, instance):
|
||||
sites = instance.sites.restrict(request.user, 'view')
|
||||
sites_table = SiteTable(sites)
|
||||
paginate_table(sites_table, request)
|
||||
configure_table(sites_table, request)
|
||||
|
||||
return {
|
||||
'sites_table': sites_table,
|
||||
@ -356,7 +356,7 @@ class RoleView(generic.ObjectView):
|
||||
)
|
||||
|
||||
prefixes_table = tables.PrefixTable(prefixes, exclude=('role', 'utilization'))
|
||||
paginate_table(prefixes_table, request)
|
||||
configure_table(prefixes_table, request)
|
||||
|
||||
return {
|
||||
'prefixes_table': prefixes_table,
|
||||
@ -505,9 +505,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
|
||||
template_name = 'ipam/prefix/ip_addresses.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related(
|
||||
'vrf', 'role', 'tenant',
|
||||
)
|
||||
return parent.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf', 'tenant')
|
||||
|
||||
def prep_table_data(self, request, queryset, parent):
|
||||
show_available = bool(request.GET.get('show_available', 'true') == 'true')
|
||||
@ -531,7 +529,6 @@ class PrefixEditView(generic.ObjectEditView):
|
||||
|
||||
class PrefixDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Prefix.objects.all()
|
||||
template_name = 'ipam/prefix_delete.html'
|
||||
|
||||
|
||||
class PrefixBulkImportView(generic.BulkImportView):
|
||||
@ -664,7 +661,7 @@ class IPAddressView(generic.ObjectView):
|
||||
vrf=instance.vrf, address__net_contained_or_equal=str(instance.address)
|
||||
)
|
||||
related_ips_table = tables.IPAddressTable(related_ips, orderable=False)
|
||||
paginate_table(related_ips_table, request)
|
||||
configure_table(related_ips_table, request)
|
||||
|
||||
return {
|
||||
'parent_prefixes_table': parent_prefixes_table,
|
||||
@ -800,7 +797,7 @@ class VLANGroupView(generic.ObjectView):
|
||||
vlans_table = tables.VLANTable(vlans, exclude=('site', 'group', 'prefixes'))
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
vlans_table.columns.show('pk')
|
||||
paginate_table(vlans_table, request)
|
||||
configure_table(vlans_table, request)
|
||||
|
||||
# Compile permissions list for rendering the object table
|
||||
permissions = {
|
||||
@ -1031,6 +1028,49 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.VLANTable
|
||||
|
||||
|
||||
#
|
||||
# Service templates
|
||||
#
|
||||
|
||||
class ServiceTemplateListView(generic.ObjectListView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
filterset = filtersets.ServiceTemplateFilterSet
|
||||
filterset_form = forms.ServiceTemplateFilterForm
|
||||
table = tables.ServiceTemplateTable
|
||||
|
||||
|
||||
class ServiceTemplateView(generic.ObjectView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
|
||||
|
||||
class ServiceTemplateEditView(generic.ObjectEditView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
model_form = forms.ServiceTemplateForm
|
||||
|
||||
|
||||
class ServiceTemplateDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
|
||||
|
||||
class ServiceTemplateBulkImportView(generic.BulkImportView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
model_form = forms.ServiceTemplateCSVForm
|
||||
table = tables.ServiceTemplateTable
|
||||
|
||||
|
||||
class ServiceTemplateBulkEditView(generic.BulkEditView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
filterset = filtersets.ServiceTemplateFilterSet
|
||||
table = tables.ServiceTemplateTable
|
||||
form = forms.ServiceTemplateBulkEditForm
|
||||
|
||||
|
||||
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
filterset = filtersets.ServiceTemplateFilterSet
|
||||
table = tables.ServiceTemplateTable
|
||||
|
||||
|
||||
#
|
||||
# Services
|
||||
#
|
||||
@ -1047,22 +1087,28 @@ class ServiceView(generic.ObjectView):
|
||||
queryset = Service.objects.prefetch_related('ipaddresses')
|
||||
|
||||
|
||||
class ServiceCreateView(generic.ObjectEditView):
|
||||
queryset = Service.objects.all()
|
||||
model_form = forms.ServiceCreateForm
|
||||
template_name = 'ipam/service_create.html'
|
||||
|
||||
|
||||
class ServiceEditView(generic.ObjectEditView):
|
||||
queryset = Service.objects.prefetch_related('ipaddresses')
|
||||
model_form = forms.ServiceForm
|
||||
template_name = 'ipam/service_edit.html'
|
||||
|
||||
|
||||
class ServiceDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Service.objects.all()
|
||||
|
||||
|
||||
class ServiceBulkImportView(generic.BulkImportView):
|
||||
queryset = Service.objects.all()
|
||||
model_form = forms.ServiceCSVForm
|
||||
table = tables.ServiceTable
|
||||
|
||||
|
||||
class ServiceDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Service.objects.all()
|
||||
|
||||
|
||||
class ServiceBulkEditView(generic.BulkEditView):
|
||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
||||
filterset = filtersets.ServiceFilterSet
|
||||
|
135
netbox/netbox/models/__init__.py
Normal file
135
netbox/netbox/models/__init__.py
Normal 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',)
|
@ -1,34 +1,39 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db.models.signals import class_prepared
|
||||
from django.dispatch import receiver
|
||||
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import models
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
from taggit.managers import TaggableManager
|
||||
|
||||
from extras.choices import ObjectChangeActionChoices
|
||||
from extras.utils import register_features
|
||||
from netbox.signals import post_clean
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.utils import serialize_object
|
||||
|
||||
__all__ = (
|
||||
'BigIDModel',
|
||||
'ChangeLoggedModel',
|
||||
'NestedGroupModel',
|
||||
'OrganizationalModel',
|
||||
'PrimaryModel',
|
||||
'ChangeLoggingMixin',
|
||||
'CustomFieldsMixin',
|
||||
'CustomLinksMixin',
|
||||
'CustomValidationMixin',
|
||||
'ExportTemplatesMixin',
|
||||
'JobResultsMixin',
|
||||
'JournalingMixin',
|
||||
'TagsMixin',
|
||||
'WebhooksMixin',
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Mixins
|
||||
# Feature mixins
|
||||
#
|
||||
|
||||
class ChangeLoggingMixin(models.Model):
|
||||
"""
|
||||
Provides change logging support.
|
||||
Provides change logging support for a model. Adds the `created` and `last_updated` fields.
|
||||
"""
|
||||
created = models.DateField(
|
||||
auto_now_add=True,
|
||||
@ -74,7 +79,7 @@ class ChangeLoggingMixin(models.Model):
|
||||
|
||||
class CustomFieldsMixin(models.Model):
|
||||
"""
|
||||
Provides support for custom fields.
|
||||
Enables support for custom fields.
|
||||
"""
|
||||
custom_field_data = models.JSONField(
|
||||
encoder=DjangoJSONEncoder,
|
||||
@ -88,13 +93,25 @@ class CustomFieldsMixin(models.Model):
|
||||
@property
|
||||
def cf(self):
|
||||
"""
|
||||
Convenience wrapper for custom field data.
|
||||
A pass-through convenience alias for accessing `custom_field_data` (read-only).
|
||||
|
||||
```python
|
||||
>>> tenant = Tenant.objects.first()
|
||||
>>> tenant.cf
|
||||
{'cust_id': 'CYB01'}
|
||||
```
|
||||
"""
|
||||
return self.custom_field_data
|
||||
|
||||
def get_custom_fields(self):
|
||||
"""
|
||||
Return a dictionary of custom fields for a single object in the form {<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
|
||||
|
||||
@ -128,9 +145,17 @@ class CustomFieldsMixin(models.Model):
|
||||
raise ValidationError(f"Missing required custom field '{cf.name}'.")
|
||||
|
||||
|
||||
class CustomLinksMixin(models.Model):
|
||||
"""
|
||||
Enables support for custom links.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class CustomValidationMixin(models.Model):
|
||||
"""
|
||||
Enables user-configured validation rules for built-in models by extending the clean() method.
|
||||
Enables user-configured validation rules for models.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
@ -142,9 +167,41 @@ class CustomValidationMixin(models.Model):
|
||||
post_clean.send(sender=self.__class__, instance=self)
|
||||
|
||||
|
||||
class ExportTemplatesMixin(models.Model):
|
||||
"""
|
||||
Enables support for export templates.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class JobResultsMixin(models.Model):
|
||||
"""
|
||||
Enables support for job results.
|
||||
"""
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class JournalingMixin(models.Model):
|
||||
"""
|
||||
Enables support for object journaling. Adds a generic relation (`journal_entries`)
|
||||
to NetBox's JournalEntry model.
|
||||
"""
|
||||
journal_entries = GenericRelation(
|
||||
to='extras.JournalEntry',
|
||||
object_id_field='assigned_object_id',
|
||||
content_type_field='assigned_object_type'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class TagsMixin(models.Model):
|
||||
"""
|
||||
Enable the assignment of Tags.
|
||||
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
|
||||
which is a `TaggableManager` instance.
|
||||
"""
|
||||
tags = TaggableManager(
|
||||
through='extras.TaggedItem'
|
||||
@ -154,113 +211,28 @@ class TagsMixin(models.Model):
|
||||
abstract = True
|
||||
|
||||
|
||||
#
|
||||
# Base model classes
|
||||
|
||||
class BigIDModel(models.Model):
|
||||
class WebhooksMixin(models.Model):
|
||||
"""
|
||||
Abstract base model for all data objects. Ensures the use of a 64-bit PK.
|
||||
Enables support for webhooks.
|
||||
"""
|
||||
id = models.BigAutoField(
|
||||
primary_key=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, BigIDModel):
|
||||
"""
|
||||
Base model for all objects which support change logging.
|
||||
"""
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
FEATURES_MAP = (
|
||||
('custom_fields', CustomFieldsMixin),
|
||||
('custom_links', CustomLinksMixin),
|
||||
('export_templates', ExportTemplatesMixin),
|
||||
('job_results', JobResultsMixin),
|
||||
('journaling', JournalingMixin),
|
||||
('tags', TagsMixin),
|
||||
('webhooks', WebhooksMixin),
|
||||
)
|
||||
|
||||
|
||||
class PrimaryModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
|
||||
"""
|
||||
Primary models represent real objects within the infrastructure being modeled.
|
||||
"""
|
||||
journal_entries = GenericRelation(
|
||||
to='extras.JournalEntry',
|
||||
object_id_field='assigned_object_id',
|
||||
content_type_field='assigned_object_type'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class NestedGroupModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel, MPTTModel):
|
||||
"""
|
||||
Base model for objects which are used to form a hierarchy (regions, locations, etc.). These models nest
|
||||
recursively using MPTT. Within each parent, each child instance must have a unique name.
|
||||
"""
|
||||
parent = TreeForeignKey(
|
||||
to='self',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='children',
|
||||
blank=True,
|
||||
null=True,
|
||||
db_index=True
|
||||
)
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = TreeManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
class MPTTMeta:
|
||||
order_insertion_by = ('name',)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An MPTT model cannot be its own parent
|
||||
if self.pk and self.parent_id == self.pk:
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
|
||||
class OrganizationalModel(ChangeLoggingMixin, CustomFieldsMixin, CustomValidationMixin, TagsMixin, BigIDModel):
|
||||
"""
|
||||
Organizational models are those which are used solely to categorize and qualify other objects, and do not convey
|
||||
any real information about the infrastructure being modeled (for example, functional device roles). Organizational
|
||||
models provide the following standard attributes:
|
||||
- Unique name
|
||||
- Unique slug (automatically derived from name)
|
||||
- Optional description
|
||||
"""
|
||||
name = models.CharField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
slug = models.SlugField(
|
||||
max_length=100,
|
||||
unique=True
|
||||
)
|
||||
description = models.CharField(
|
||||
max_length=200,
|
||||
blank=True
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
ordering = ('name',)
|
||||
@receiver(class_prepared)
|
||||
def _register_features(sender, **kwargs):
|
||||
features = {
|
||||
feature for feature, cls in FEATURES_MAP if issubclass(sender, cls)
|
||||
}
|
||||
register_features(sender, features)
|
@ -264,6 +264,7 @@ IPAM_MENU = Menu(
|
||||
label='Other',
|
||||
items=(
|
||||
get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
|
||||
get_model_item('ipam', 'servicetemplate', 'Service Templates'),
|
||||
get_model_item('ipam', 'service', 'Services'),
|
||||
),
|
||||
),
|
||||
|
@ -26,6 +26,16 @@ PREFERENCES = {
|
||||
description='The number of objects to display per page',
|
||||
coerce=lambda x: int(x)
|
||||
),
|
||||
'pagination.placement': UserPreference(
|
||||
label='Paginator placement',
|
||||
choices=(
|
||||
('bottom', 'Bottom'),
|
||||
('top', 'Top'),
|
||||
('both', 'Both'),
|
||||
),
|
||||
description='Where the paginator controls will be displayed relative to a table',
|
||||
default='bottom'
|
||||
),
|
||||
|
||||
# Miscellaneous
|
||||
'data_format': UserPreference(
|
||||
|
@ -287,7 +287,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
def _update_objects(self, form, request):
|
||||
custom_fields = getattr(form, 'custom_fields', [])
|
||||
standard_fields = [
|
||||
field for field in form.fields if field not in custom_fields + ['pk']
|
||||
field for field in form.fields if field not in list(custom_fields) + ['pk']
|
||||
]
|
||||
nullified_fields = request.POST.getlist('_nullify')
|
||||
updated_objects = []
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user