mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-26 17:26:10 -06:00
9608 fix merge
This commit is contained in:
commit
7c5aeab347
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.4.4
|
||||
placeholder: v3.4.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.4.4
|
||||
placeholder: v3.4.6
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation.
|
||||
Available as open source software under the Apache 2.0 license, NetBox serves
|
||||
as the cornerstone for network automation in thousands of organizations.
|
||||
|
||||
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
|
||||
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
|
||||
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
|
||||
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
|
||||
* **Organization:** Manage tenant and contact assignments natively.
|
||||
* **Powerful search:** Easily find anything you need using a single global search function.
|
||||
|
@ -2,6 +2,10 @@
|
||||
# https://github.com/mozilla/bleach
|
||||
bleach<6.0
|
||||
|
||||
# Python client for Amazon AWS API
|
||||
# https://github.com/boto/boto3
|
||||
boto3
|
||||
|
||||
# The Python web framework on which NetBox is built
|
||||
# https://github.com/django/django
|
||||
Django<4.2
|
||||
@ -66,6 +70,10 @@ djangorestframework
|
||||
# https://github.com/tfranzel/drf-spectacular
|
||||
drf-spectacular
|
||||
|
||||
# RSS feed parser
|
||||
# https://github.com/kurtmckee/feedparser
|
||||
feedparser
|
||||
|
||||
# Django wrapper for Graphene (GraphQL support)
|
||||
# https://github.com/graphql-python/graphene-django
|
||||
graphene_django
|
||||
|
@ -1,3 +1,12 @@
|
||||
<VirtualHost *:80>
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
ServerName netbox.example.com
|
||||
|
||||
RewriteEngine On
|
||||
RewriteCond %{HTTPS} !=on
|
||||
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
|
||||
</VirtualHost>
|
||||
|
||||
<VirtualHost *:443>
|
||||
ProxyPreserveHost On
|
||||
|
||||
|
@ -69,11 +69,11 @@ By default, NetBox will permit users to create duplicate prefixes and IP address
|
||||
|
||||
---
|
||||
|
||||
## FILE_UPLOAD_MAX_MEMORY_SIZE
|
||||
## `FILE_UPLOAD_MAX_MEMORY_SIZE`
|
||||
|
||||
Default: 2621440 (i.e. 2.5 MB).
|
||||
Default: `2621440` (2.5 MB).
|
||||
|
||||
The maximum size (in bytes) that an upload will be before it gets streamed to the file system. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
The maximum amount (in bytes) of uploaded data that will be held in memory before being written to the filesystem. Changing this setting can be useful for example to be able to upload files bigger than 2.5MB to custom scripts for processing.
|
||||
|
||||
---
|
||||
|
||||
|
@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
|
||||
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
|
||||
|
||||
```no-highlight
|
||||
sudo a2enmod ssl proxy proxy_http headers
|
||||
sudo a2enmod ssl proxy proxy_http headers rewrite
|
||||
sudo a2ensite netbox
|
||||
sudo systemctl restart apache2
|
||||
```
|
||||
|
@ -14,15 +14,17 @@ The type of data source. Supported options include:
|
||||
|
||||
* Local directory
|
||||
* git repository
|
||||
* Amazon S3 bucket
|
||||
|
||||
### URL
|
||||
|
||||
The URL identifying the remote source. Some examples are included below.
|
||||
|
||||
| Type | Example URL |
|
||||
|------|-------------|
|
||||
| Local | file:///var/my/data/source/ |
|
||||
| git | https://https://github.com/my-organization/my-repo |
|
||||
|-----------|----------------------------------------------------|
|
||||
| Local | file:///path/to/my/data/ |
|
||||
| git | https://github.com/my-organization/my-repo |
|
||||
| Amazon S3 | https://s3.us-east-2.amazonaws.com/my-bucket-name/ |
|
||||
|
||||
### Status
|
||||
|
||||
|
@ -82,6 +82,10 @@ The default value to populate for the custom field when creating new objects (op
|
||||
|
||||
For choice and multi-choice custom fields only. A comma-delimited list of the available choices.
|
||||
|
||||
### Cloneable
|
||||
|
||||
If enabled, values from this field will be automatically pre-populated when cloning existing objects.
|
||||
|
||||
### Minimum Value
|
||||
|
||||
For numeric custom fields only. The minimum valid value (optional).
|
||||
|
@ -1,18 +1,46 @@
|
||||
# NetBox v3.4
|
||||
|
||||
## v3.4.5 (FUTURE)
|
||||
## v3.4.6 (2023-03-13)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
|
||||
* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
|
||||
* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
|
||||
* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
|
||||
* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
|
||||
* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
|
||||
* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
|
||||
* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
|
||||
* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
|
||||
* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
|
||||
* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
|
||||
* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
|
||||
* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
|
||||
* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
|
||||
* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
|
||||
* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
|
||||
* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
|
||||
|
||||
---
|
||||
|
||||
## v3.4.5 (2023-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges
|
||||
* [#11592](https://github.com/netbox-community/netbox/issues/11592) - Introduce `FILE_UPLOAD_MAX_MEMORY_SIZE` configuration parameter
|
||||
* [#11685](https://github.com/netbox-community/netbox/issues/11685) - Match on containing prefixes and aggregates when querying for IP addresses using global search
|
||||
* [#11787](https://github.com/netbox-community/netbox/issues/11787) - Upgrade script will automatically rebuild missing search cache
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation
|
||||
* [#11226](https://github.com/netbox-community/netbox/issues/11226) - Ensure scripts and reports within submodules are automatically reloaded
|
||||
* [#11335](https://github.com/netbox-community/netbox/issues/11335) - Avoid exception when rendering change log after uninstalling a plugin
|
||||
* [#11459](https://github.com/netbox-community/netbox/issues/11459) - Enable evaluating null values in custom validation rules
|
||||
* [#11473](https://github.com/netbox-community/netbox/issues/11473) - GraphQL requests specifying an invalid filter should return an empty queryset
|
||||
* [#11582](https://github.com/netbox-community/netbox/issues/11582) - Ensure form validation errors are displayed when adding virtual chassis members
|
||||
@ -20,6 +48,7 @@
|
||||
* [#11683](https://github.com/netbox-community/netbox/issues/11683) - Fix CSV header attribute detection when auto-detecting import format
|
||||
* [#11711](https://github.com/netbox-community/netbox/issues/11711) - Fix CSV import for multiple-object custom fields
|
||||
* [#11723](https://github.com/netbox-community/netbox/issues/11723) - Circuit terminations should link to their associated circuits (rather than site or provider network)
|
||||
* [#11775](https://github.com/netbox-community/netbox/issues/11775) - Skip checking for old search cache records when creating a new object
|
||||
* [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
|
||||
|
||||
---
|
||||
|
@ -10,7 +10,7 @@ The static home view has been replaced with a fully customizable dashboard. User
|
||||
|
||||
#### Remote Data Sources ([#11558](https://github.com/netbox-community/netbox/issues/11558))
|
||||
|
||||
NetBox now has the ability to synchronize arbitrary data from external sources through the new [DataSource](../models/core/datasource.md) and [DataFile](../models/core/datafile.md) models. Synchronized files are stored in the PostgreSQL database, and may be referenced and consumed by other NetBox models, such as export templates and config contexts. Currently, replication from local filesystem paths and from git repositories is supported, and we expect to add support for additional backends in the near future.
|
||||
NetBox now has the ability to synchronize arbitrary data from external sources through the new [DataSource](../models/core/datasource.md) and [DataFile](../models/core/datafile.md) models. Synchronized files are stored in the PostgreSQL database, and may be referenced and consumed by other NetBox models, such as export templates and config contexts. Currently, replication from local filesystem paths, git repositories, and Amazon S3 buckets is supported, and we expect to introduce additional backends in the near future.
|
||||
|
||||
#### Configuration Template Rendering ([#11559](https://github.com/netbox-community/netbox/issues/11559))
|
||||
|
||||
@ -28,9 +28,11 @@ A new ASN range model has been introduced to facilitate the provisioning of new
|
||||
|
||||
* [#7947](https://github.com/netbox-community/netbox/issues/7947) - Enable marking IP ranges as fully utilized
|
||||
* [#8272](https://github.com/netbox-community/netbox/issues/8272) - Support bridge relationships among device type interfaces
|
||||
* [#8749](https://github.com/netbox-community/netbox/issues/8749) - Support replicating custom field values when cloning an object
|
||||
* [#8958](https://github.com/netbox-community/netbox/issues/8958) - Changes in background job status can trigger webhooks
|
||||
* [#9073](https://github.com/netbox-community/netbox/issues/9073) - Enable syncing config context data from remote sources
|
||||
* [#9653](https://github.com/netbox-community/netbox/issues/9653) - Enable setting a default platform for device types
|
||||
* [#10054](https://github.com/netbox-community/netbox/issues/10054) - Introduce advanced object selector for UI forms
|
||||
* [#10374](https://github.com/netbox-community/netbox/issues/10374) - Require unique tenant names & slugs per group (not globally)
|
||||
* [#10729](https://github.com/netbox-community/netbox/issues/10729) - Add date & time custom field type
|
||||
* [#11254](https://github.com/netbox-community/netbox/issues/11254) - Introduce the `X-Request-ID` HTTP header to annotate the unique ID of each request for change logging
|
||||
@ -39,6 +41,8 @@ A new ASN range model has been introduced to facilitate the provisioning of new
|
||||
* [#11584](https://github.com/netbox-community/netbox/issues/11584) - Add a list view for contact assignments
|
||||
* [#11625](https://github.com/netbox-community/netbox/issues/11625) - Add HTMX support to ObjectEditView
|
||||
* [#11693](https://github.com/netbox-community/netbox/issues/11693) - Enable syncing export template content from remote sources
|
||||
* [#11780](https://github.com/netbox-community/netbox/issues/11780) - Enable loading import data from remote sources
|
||||
* [#11968](https://github.com/netbox-community/netbox/issues/11968) - Add navigation menu buttons to create device & VM components
|
||||
|
||||
### Other Changes
|
||||
|
||||
|
@ -34,7 +34,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@ -62,7 +61,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
@ -123,7 +121,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from circuits.models import *
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from dcim.models import Site
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from tenancy.forms import TenancyForm
|
||||
@ -114,50 +114,22 @@ class CircuitTerminationForm(NetBoxModelForm):
|
||||
'provider_id': '$provider',
|
||||
},
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
},
|
||||
required=False
|
||||
)
|
||||
provider_network_provider = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
required=False,
|
||||
label='Provider',
|
||||
initial_params={
|
||||
'networks': 'provider_network'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
provider_network = DynamicModelChoiceField(
|
||||
queryset=ProviderNetwork.objects.all(),
|
||||
query_params={
|
||||
'provider_id': '$provider_network_provider',
|
||||
},
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = CircuitTermination
|
||||
fields = [
|
||||
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider',
|
||||
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info',
|
||||
'description', 'tags',
|
||||
'provider', 'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed',
|
||||
'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
|
||||
]
|
||||
widgets = {
|
||||
'port_speed': SelectSpeedWidget(),
|
||||
|
@ -10,10 +10,12 @@ from utilities.choices import ChoiceSet
|
||||
class DataSourceTypeChoices(ChoiceSet):
|
||||
LOCAL = 'local'
|
||||
GIT = 'git'
|
||||
AMAZON_S3 = 'amazon-s3'
|
||||
|
||||
CHOICES = (
|
||||
(LOCAL, _('Local'), 'gray'),
|
||||
(GIT, _('Git'), 'blue'),
|
||||
(AMAZON_S3, _('Amazon S3'), 'blue'),
|
||||
)
|
||||
|
||||
|
||||
|
@ -1,9 +1,14 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote, urlunparse, urlparse
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config as Boto3Config
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
@ -115,3 +120,70 @@ class GitBackend(DataBackend):
|
||||
yield local_path.name
|
||||
|
||||
local_path.cleanup()
|
||||
|
||||
|
||||
@register_backend(DataSourceTypeChoices.AMAZON_S3)
|
||||
class S3Backend(DataBackend):
|
||||
parameters = {
|
||||
'aws_access_key_id': forms.CharField(
|
||||
label=_('AWS access key ID'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
),
|
||||
'aws_secret_access_key': forms.CharField(
|
||||
label=_('AWS secret access key'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
),
|
||||
}
|
||||
|
||||
REGION_REGEX = r's3\.([a-z0-9-]+)\.amazonaws\.com'
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
local_path = tempfile.TemporaryDirectory()
|
||||
|
||||
# Build the S3 configuration
|
||||
s3_config = Boto3Config(
|
||||
proxies=settings.HTTP_PROXIES,
|
||||
)
|
||||
|
||||
# Initialize the S3 resource and bucket
|
||||
aws_access_key_id = self.params.get('aws_access_key_id')
|
||||
aws_secret_access_key = self.params.get('aws_secret_access_key')
|
||||
s3 = boto3.resource(
|
||||
's3',
|
||||
region_name=self._region_name,
|
||||
aws_access_key_id=aws_access_key_id,
|
||||
aws_secret_access_key=aws_secret_access_key,
|
||||
config=s3_config
|
||||
)
|
||||
bucket = s3.Bucket(self._bucket_name)
|
||||
|
||||
# Download all files within the specified path
|
||||
for obj in bucket.objects.filter(Prefix=self._remote_path):
|
||||
local_filename = os.path.join(local_path.name, obj.key)
|
||||
# Build local path
|
||||
Path(os.path.dirname(local_filename)).mkdir(parents=True, exist_ok=True)
|
||||
bucket.download_file(obj.key, local_filename)
|
||||
|
||||
yield local_path.name
|
||||
|
||||
local_path.cleanup()
|
||||
|
||||
@property
|
||||
def _region_name(self):
|
||||
domain = urlparse(self.url).netloc
|
||||
if m := re.match(self.REGION_REGEX, domain):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
@property
|
||||
def _bucket_name(self):
|
||||
url_path = urlparse(self.url).path.lstrip('/')
|
||||
return url_path.split('/')[0]
|
||||
|
||||
@property
|
||||
def _remote_path(self):
|
||||
url_path = urlparse(self.url).path.lstrip('/')
|
||||
if '/' in url_path:
|
||||
return url_path.split('/', 1)[1]
|
||||
return ''
|
||||
|
@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_STACKWISE160 = 'cisco-stackwise-160'
|
||||
TYPE_STACKWISE320 = 'cisco-stackwise-320'
|
||||
TYPE_STACKWISE480 = 'cisco-stackwise-480'
|
||||
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
|
||||
TYPE_JUNIPER_VCP = 'juniper-vcp'
|
||||
TYPE_SUMMITSTACK = 'extreme-summitstack'
|
||||
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
|
||||
@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
|
||||
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
|
||||
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
|
||||
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
|
||||
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
|
||||
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
|
||||
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
|
||||
@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
|
||||
TYPE_1_8023AF = 'type1-ieee802.3af'
|
||||
TYPE_2_8023AT = 'type2-ieee802.3at'
|
||||
TYPE_2_8023AZ = 'type2-ieee802.3az'
|
||||
TYPE_3_8023BT = 'type3-ieee802.3bt'
|
||||
TYPE_4_8023BT = 'type4-ieee802.3bt'
|
||||
|
||||
@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet):
|
||||
(
|
||||
(TYPE_1_8023AF, '802.3af (Type 1)'),
|
||||
(TYPE_2_8023AT, '802.3at (Type 2)'),
|
||||
(TYPE_2_8023AZ, '802.3az (Type 2)'),
|
||||
(TYPE_3_8023BT, '802.3bt (Type 3)'),
|
||||
(TYPE_4_8023BT, '802.3bt (Type 4)'),
|
||||
)
|
||||
|
@ -1004,7 +1004,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
Q(comments__icontains=value)
|
||||
Q(comments__icontains=value) |
|
||||
Q(primary_ip4__address__startswith=value) |
|
||||
Q(primary_ip6__address__startswith=value)
|
||||
).distinct()
|
||||
|
||||
def _has_primary_ip(self, queryset, name, value):
|
||||
@ -1748,6 +1750,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
|
||||
|
||||
|
||||
class CableTerminationFilterSet(BaseFilterSet):
|
||||
termination_type = ContentTypeFilter()
|
||||
|
||||
class Meta:
|
||||
model = CableTermination
|
||||
|
@ -137,7 +137,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -302,7 +301,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -337,7 +335,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -400,7 +397,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -434,7 +430,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -549,7 +544,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -592,7 +586,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -639,7 +632,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -663,7 +655,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -709,7 +700,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -767,7 +757,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label=_('Comments')
|
||||
)
|
||||
|
||||
|
@ -469,11 +469,14 @@ class DeviceImportForm(BaseDeviceImportForm):
|
||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
||||
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
|
||||
|
||||
# Limit rack queryset by assigned site and group
|
||||
# Limit rack queryset by assigned site and location
|
||||
params = {
|
||||
f"site__{self.fields['site'].to_field_name}": data.get('site'),
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
}
|
||||
if 'location' in data:
|
||||
params.update({
|
||||
f"location__{self.fields['location'].to_field_name}": data.get('location'),
|
||||
})
|
||||
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
|
||||
|
||||
# Limit device bay queryset by parent device
|
||||
|
@ -15,68 +15,16 @@ def get_cable_form(a_type, b_type):
|
||||
|
||||
for cable_end, term_cls in (('a', a_type), ('b', b_type)):
|
||||
|
||||
attrs[f'termination_{cable_end}_region'] = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
label=_('Region'),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_sitegroup'] = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
label=_('Site group'),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_site'] = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
label=_('Site'),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': f'$termination_{cable_end}_region',
|
||||
'group_id': f'$termination_{cable_end}_sitegroup',
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_location'] = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
label=_('Location'),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site'
|
||||
}
|
||||
)
|
||||
|
||||
# Device component
|
||||
if hasattr(term_cls, 'device'):
|
||||
|
||||
attrs[f'termination_{cable_end}_rack'] = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack'),
|
||||
required=False,
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'devices': f'$termination_{cable_end}_device'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
}
|
||||
)
|
||||
attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label=_('Device'),
|
||||
required=False,
|
||||
selector=True,
|
||||
initial_params={
|
||||
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
'rack_id': f'$termination_{cable_end}_rack',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
@ -96,12 +44,9 @@ def get_cable_form(a_type, b_type):
|
||||
queryset=PowerPanel.objects.all(),
|
||||
label=_('Power Panel'),
|
||||
required=False,
|
||||
selector=True,
|
||||
initial_params={
|
||||
'powerfeeds__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
'location_id': f'$termination_{cable_end}_location',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
@ -116,23 +61,12 @@ def get_cable_form(a_type, b_type):
|
||||
# CircuitTermination
|
||||
elif term_cls == CircuitTermination:
|
||||
|
||||
attrs[f'termination_{cable_end}_provider'] = DynamicModelChoiceField(
|
||||
queryset=Provider.objects.all(),
|
||||
label=_('Provider'),
|
||||
initial_params={
|
||||
'circuits': f'$termination_{cable_end}_circuit'
|
||||
},
|
||||
required=False
|
||||
)
|
||||
attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
|
||||
queryset=Circuit.objects.all(),
|
||||
label=_('Circuit'),
|
||||
selector=True,
|
||||
initial_params={
|
||||
'terminations__in': f'${cable_end}_terminations'
|
||||
},
|
||||
query_params={
|
||||
'provider_id': f'$termination_{cable_end}_provider',
|
||||
'site_id': f'$termination_{cable_end}_site',
|
||||
}
|
||||
)
|
||||
attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
|
||||
|
@ -14,9 +14,9 @@ from tenancy.forms import TenancyForm
|
||||
from utilities.forms import (
|
||||
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
|
||||
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK,
|
||||
SlugField, SelectSpeedWidget,
|
||||
SlugField, SelectSpeedWidget
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from virtualization.models import Cluster
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from .common import InterfaceCommonForm, ModuleCommonForm
|
||||
|
||||
@ -157,26 +157,9 @@ class SiteForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
parent = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@ -188,17 +171,14 @@ class LocationForm(TenancyForm, NetBoxModelForm):
|
||||
slug = SlugField()
|
||||
|
||||
fieldsets = (
|
||||
('Location', (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Location', ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Location
|
||||
fields = (
|
||||
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant',
|
||||
'tags',
|
||||
'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags',
|
||||
)
|
||||
|
||||
|
||||
@ -219,26 +199,9 @@ class RackRoleForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class RackForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@ -256,48 +219,16 @@ class RackForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status',
|
||||
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth',
|
||||
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
|
||||
'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
units = NumericArrayField(
|
||||
base_field=forms.IntegerField(),
|
||||
@ -311,15 +242,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')),
|
||||
('Reservation', ('rack', 'units', 'user', 'description', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = RackReservation
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant',
|
||||
'description', 'comments', 'tags',
|
||||
'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -441,26 +371,9 @@ class PlatformForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@ -491,43 +404,21 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
}
|
||||
)
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'device_types': '$device_type'
|
||||
}
|
||||
)
|
||||
device_type = DynamicModelChoiceField(
|
||||
queryset=DeviceType.objects.all(),
|
||||
query_params={
|
||||
'manufacturer_id': '$manufacturer'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
device_role = DynamicModelChoiceField(
|
||||
queryset=DeviceRole.objects.all()
|
||||
)
|
||||
platform = DynamicModelChoiceField(
|
||||
queryset=Platform.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'manufacturer_id': ['$manufacturer', 'null']
|
||||
}
|
||||
)
|
||||
cluster_group = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
initial_params={
|
||||
'clusters': '$cluster'
|
||||
}
|
||||
required=False
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$cluster_group'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
comments = CommentField()
|
||||
local_context_data = JSONField(
|
||||
@ -536,7 +427,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
)
|
||||
virtual_chassis = DynamicModelChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
vc_position = forms.IntegerField(
|
||||
required=False,
|
||||
@ -556,10 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Device
|
||||
fields = [
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack',
|
||||
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6',
|
||||
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority',
|
||||
'description', 'config_template', 'comments', 'tags', 'local_context_data'
|
||||
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
|
||||
'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
|
||||
'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
|
||||
'local_context_data'
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -632,18 +524,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
manufacturer = DynamicModelChoiceField(
|
||||
queryset=Manufacturer.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'module_types': '$module_type'
|
||||
}
|
||||
)
|
||||
module_type = DynamicModelChoiceField(
|
||||
queryset=ModuleType.objects.all(),
|
||||
query_params={
|
||||
'manufacturer_id': '$manufacturer'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
comments = CommentField()
|
||||
replicate_components = forms.BooleanField(
|
||||
@ -651,7 +534,6 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
initial=True,
|
||||
help_text=_("Automatically populate components associated with this module type")
|
||||
)
|
||||
|
||||
adopt_components = forms.BooleanField(
|
||||
required=False,
|
||||
initial=False,
|
||||
@ -659,9 +541,7 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Module', (
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
|
||||
)),
|
||||
('Module', ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')),
|
||||
('Hardware', (
|
||||
'serial', 'asset_tag', 'replicate_components', 'adopt_components',
|
||||
)),
|
||||
@ -670,8 +550,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = Module
|
||||
fields = [
|
||||
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags',
|
||||
'replicate_components', 'adopt_components', 'description', 'comments',
|
||||
'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'tags', 'replicate_components',
|
||||
'adopt_components', 'description', 'comments',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -702,26 +582,9 @@ class CableForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class PowerPanelForm(NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
@ -733,80 +596,38 @@ class PowerPanelForm(NetBoxModelForm):
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')),
|
||||
('Power Panel', ('site', 'location', 'name', 'description', 'tags')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerPanel
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags',
|
||||
'site', 'location', 'name', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
class PowerFeedForm(NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites__powerpanel': '$power_panel'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'powerpanel': '$power_panel'
|
||||
},
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
power_panel = DynamicModelChoiceField(
|
||||
queryset=PowerPanel.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'location_id': '$location',
|
||||
'site_id': '$site'
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
('Power Panel', ('region', 'site', 'power_panel')),
|
||||
('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
|
||||
('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
|
||||
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PowerFeed
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type',
|
||||
'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments',
|
||||
'tags',
|
||||
'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
|
||||
'max_utilization', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
|
||||
@ -878,43 +699,12 @@ class DeviceVCMembershipForm(forms.ModelForm):
|
||||
|
||||
|
||||
class VCMemberSelectForm(BootstrapMixin, forms.Form):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'rack_id': '$rack',
|
||||
'virtual_chassis_id': 'null',
|
||||
}
|
||||
},
|
||||
selector=True
|
||||
)
|
||||
|
||||
def clean_device(self):
|
||||
@ -1150,7 +940,8 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
|
||||
|
||||
class DeviceComponentForm(NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all()
|
||||
queryset=Device.objects.all(),
|
||||
selector=True
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -1592,53 +1383,9 @@ class InventoryItemRoleForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'racks': '$rack'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
'rack_id': '$rack',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
primary_ip4 = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
@ -1660,14 +1407,13 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')),
|
||||
('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
|
||||
('Virtual Device Context', ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
|
||||
('Tenancy', ('tenant_group', 'tenant'))
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VirtualDeviceContext
|
||||
fields = [
|
||||
'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier',
|
||||
'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags'
|
||||
'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
|
||||
'comments', 'tags'
|
||||
]
|
||||
|
@ -10,3 +10,11 @@ class CabledObjectMixin:
|
||||
|
||||
def resolve_link_peers(self, info):
|
||||
return self.link_peers
|
||||
|
||||
|
||||
class PathEndpointMixin:
|
||||
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
|
||||
|
||||
def resolve_connected_endpoints(self, info):
|
||||
# Handle empty values
|
||||
return self.connected_endpoints or None
|
||||
|
@ -7,7 +7,7 @@ from extras.graphql.mixins import (
|
||||
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
|
||||
from netbox.graphql.scalars import BigInt
|
||||
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
|
||||
from .mixins import CabledObjectMixin
|
||||
from .mixins import CabledObjectMixin, PathEndpointMixin
|
||||
|
||||
__all__ = (
|
||||
'CableType',
|
||||
@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
|
||||
filterset_class = filtersets.CableTerminationFilterSet
|
||||
|
||||
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsolePort
|
||||
@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
|
||||
return self.type or None
|
||||
|
||||
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.ConsoleServerPort
|
||||
@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
|
||||
filterset_class = filtersets.FrontPortTemplateFilterSet
|
||||
|
||||
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
|
||||
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.Interface
|
||||
@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
|
||||
filterset_class = filtersets.PlatformFilterSet
|
||||
|
||||
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerFeed
|
||||
@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
|
||||
filterset_class = filtersets.PowerFeedFilterSet
|
||||
|
||||
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerOutlet
|
||||
@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
|
||||
filterset_class = filtersets.PowerPanelFilterSet
|
||||
|
||||
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin):
|
||||
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
|
||||
|
||||
class Meta:
|
||||
model = models.PowerPort
|
||||
|
@ -600,6 +600,7 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
}
|
||||
|
||||
|
||||
|
@ -99,8 +99,9 @@ class CustomFieldSerializer(ValidatedModelSerializer):
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
|
||||
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight',
|
||||
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated',
|
||||
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
|
||||
'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
|
||||
'last_updated',
|
||||
]
|
||||
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
|
@ -48,8 +48,12 @@ DEFAULT_DASHBOARD = [
|
||||
}
|
||||
},
|
||||
{
|
||||
'widget': 'extras.ChangeLogWidget',
|
||||
'widget': 'extras.ObjectListWidget',
|
||||
'width': 12,
|
||||
'height': 6,
|
||||
'title': 'Change Log',
|
||||
'config': {
|
||||
'model': 'extras.objectchange',
|
||||
}
|
||||
},
|
||||
]
|
||||
|
@ -1,20 +1,25 @@
|
||||
import uuid
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
|
||||
import feedparser
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from utilities.forms import BootstrapMixin
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import content_type_identifier, content_type_name
|
||||
from utilities.utils import content_type_identifier, content_type_name, get_viewname
|
||||
from .utils import register_widget
|
||||
|
||||
__all__ = (
|
||||
'ChangeLogWidget',
|
||||
'DashboardWidget',
|
||||
'NoteWidget',
|
||||
'ObjectCountsWidget',
|
||||
'ObjectListWidget',
|
||||
'RSSFeedWidget',
|
||||
)
|
||||
|
||||
|
||||
@ -27,6 +32,7 @@ def get_content_type_labels():
|
||||
|
||||
class DashboardWidget:
|
||||
default_title = None
|
||||
default_config = {}
|
||||
description = None
|
||||
width = 4
|
||||
height = 3
|
||||
@ -36,7 +42,7 @@ class DashboardWidget:
|
||||
|
||||
def __init__(self, id=None, title=None, color=None, config=None, width=None, height=None, x=None, y=None):
|
||||
self.id = id or str(uuid.uuid4())
|
||||
self.config = config or {}
|
||||
self.config = config or self.default_config
|
||||
self.title = title or self.default_title
|
||||
self.color = color
|
||||
if width:
|
||||
@ -72,6 +78,7 @@ class DashboardWidget:
|
||||
|
||||
@register_widget
|
||||
class NoteWidget(DashboardWidget):
|
||||
default_title = _('Note')
|
||||
description = _('Display some arbitrary custom content. Markdown is supported.')
|
||||
|
||||
class ConfigForm(BootstrapMixin, forms.Form):
|
||||
@ -85,7 +92,7 @@ class NoteWidget(DashboardWidget):
|
||||
|
||||
@register_widget
|
||||
class ObjectCountsWidget(DashboardWidget):
|
||||
default_title = _('Objects')
|
||||
default_title = _('Object Counts')
|
||||
description = _('Display a set of NetBox models and the number of objects created for each type.')
|
||||
template_name = 'extras/dashboard/widgets/objectcounts.html'
|
||||
|
||||
@ -108,12 +115,79 @@ class ObjectCountsWidget(DashboardWidget):
|
||||
|
||||
|
||||
@register_widget
|
||||
class ChangeLogWidget(DashboardWidget):
|
||||
default_title = _('Change Log')
|
||||
description = _('Display the most recent records from the global change log.')
|
||||
template_name = 'extras/dashboard/widgets/changelog.html'
|
||||
class ObjectListWidget(DashboardWidget):
|
||||
default_title = _('Object List')
|
||||
description = _('Display an arbitrary list of objects.')
|
||||
template_name = 'extras/dashboard/widgets/objectlist.html'
|
||||
width = 12
|
||||
height = 4
|
||||
|
||||
class ConfigForm(BootstrapMixin, forms.Form):
|
||||
model = forms.ChoiceField(
|
||||
choices=get_content_type_labels
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
return render_to_string(self.template_name, {})
|
||||
app_label, model_name = self.config['model'].split('.')
|
||||
content_type = ContentType.objects.get_by_natural_key(app_label, model_name)
|
||||
viewname = get_viewname(content_type.model_class(), action='list')
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'viewname': viewname,
|
||||
})
|
||||
|
||||
|
||||
@register_widget
|
||||
class RSSFeedWidget(DashboardWidget):
|
||||
default_title = _('RSS Feed')
|
||||
default_config = {
|
||||
'max_entries': 10,
|
||||
'cache_timeout': 3600, # seconds
|
||||
}
|
||||
description = _('Embed an RSS feed from an external website.')
|
||||
template_name = 'extras/dashboard/widgets/rssfeed.html'
|
||||
width = 6
|
||||
height = 4
|
||||
|
||||
class ConfigForm(BootstrapMixin, forms.Form):
|
||||
feed_url = forms.URLField(
|
||||
label=_('Feed URL')
|
||||
)
|
||||
max_entries = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=1000,
|
||||
help_text=_('The maximum number of objects to display')
|
||||
)
|
||||
cache_timeout = forms.IntegerField(
|
||||
min_value=600, # 10 minutes
|
||||
max_value=86400, # 24 hours
|
||||
help_text=_('How long to stored the cached content (in seconds)')
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
url = self.config['feed_url']
|
||||
feed = self.get_feed()
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'url': url,
|
||||
'feed': feed,
|
||||
})
|
||||
|
||||
@cached_property
|
||||
def cache_key(self):
|
||||
url = self.config['feed_url']
|
||||
url_checksum = sha256(url.encode('utf-8')).hexdigest()
|
||||
return f'dashboard_rss_{url_checksum}'
|
||||
|
||||
def get_feed(self):
|
||||
# Fetch RSS content from cache
|
||||
if feed_content := cache.get(self.cache_key):
|
||||
feed = feedparser.FeedParserDict(feed_content)
|
||||
else:
|
||||
feed = feedparser.parse(self.config['feed_url'])
|
||||
# Cap number of entries
|
||||
max_entries = self.config.get('max_entries')
|
||||
feed['entries'] = feed['entries'][:max_entries]
|
||||
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
|
||||
|
||||
return feed
|
||||
|
@ -78,7 +78,7 @@ class CustomFieldFilterSet(BaseFilterSet):
|
||||
model = CustomField
|
||||
fields = [
|
||||
'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility',
|
||||
'weight', 'description',
|
||||
'weight', 'is_cloneable', 'description',
|
||||
]
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
@ -220,6 +220,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
|
||||
class JournalEntryFilterSet(NetBoxModelFilterSet):
|
||||
created = django_filters.DateTimeFromToRangeFilter()
|
||||
assigned_object_type = ContentTypeFilter()
|
||||
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
created_by_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
@ -504,6 +507,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=ContentType.objects.all()
|
||||
)
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label=_('User (ID)'),
|
||||
|
@ -2,6 +2,7 @@ from .model_forms import *
|
||||
from .filtersets import *
|
||||
from .bulk_edit import *
|
||||
from .bulk_import import *
|
||||
from .misc import *
|
||||
from .mixins import *
|
||||
from .config import *
|
||||
from .scripts import *
|
||||
|
@ -44,6 +44,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
|
||||
required=False,
|
||||
initial=''
|
||||
)
|
||||
is_cloneable = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=BulkEditNullBooleanSelect()
|
||||
)
|
||||
|
||||
nullable_fields = ('group_name', 'description',)
|
||||
|
||||
|
@ -51,7 +51,7 @@ class CustomFieldImportForm(CSVModelForm):
|
||||
fields = (
|
||||
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
|
||||
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
|
||||
'validation_regex', 'ui_visibility',
|
||||
'validation_regex', 'ui_visibility', 'is_cloneable',
|
||||
)
|
||||
|
||||
|
||||
|
@ -37,7 +37,9 @@ __all__ = (
|
||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility')),
|
||||
('Attributes', (
|
||||
'type', 'content_type_id', 'group_name', 'weight', 'required', 'ui_visibility', 'is_cloneable',
|
||||
)),
|
||||
)
|
||||
content_type_id = ContentTypeMultipleChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
@ -66,6 +68,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
required=False,
|
||||
label=_('UI visibility')
|
||||
)
|
||||
is_cloneable = forms.NullBooleanField(
|
||||
required=False,
|
||||
widget=forms.Select(
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class JobResultFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
14
netbox/extras/forms/misc.py
Normal file
14
netbox/extras/forms/misc.py
Normal file
@ -0,0 +1,14 @@
|
||||
from django import forms
|
||||
|
||||
__all__ = (
|
||||
'RenderMarkdownForm',
|
||||
)
|
||||
|
||||
|
||||
class RenderMarkdownForm(forms.Form):
|
||||
"""
|
||||
Provides basic validation for markup to be rendered.
|
||||
"""
|
||||
text = forms.CharField(
|
||||
required=False
|
||||
)
|
@ -47,7 +47,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
|
||||
('Custom Field', (
|
||||
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description',
|
||||
)),
|
||||
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight')),
|
||||
('Behavior', ('search_weight', 'filter_logic', 'ui_visibility', 'weight', 'is_cloneable')),
|
||||
('Values', ('default', 'choices')),
|
||||
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
|
||||
)
|
||||
|
@ -15,6 +15,11 @@ class Command(BaseCommand):
|
||||
nargs='*',
|
||||
help='One or more apps or models to reindex',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--lazy',
|
||||
action='store_true',
|
||||
help="For each model, reindex objects only if no cache entries already exist"
|
||||
)
|
||||
|
||||
def _get_indexers(self, *model_names):
|
||||
indexers = {}
|
||||
@ -60,7 +65,8 @@ class Command(BaseCommand):
|
||||
raise CommandError("No indexers found!")
|
||||
self.stdout.write(f'Reindexing {len(indexers)} models.')
|
||||
|
||||
# Clear all cached values for the specified models
|
||||
# Clear all cached values for the specified models (if not being lazy)
|
||||
if not kwargs['lazy']:
|
||||
self.stdout.write('Clearing cached values... ', ending='')
|
||||
self.stdout.flush()
|
||||
content_types = [
|
||||
@ -76,11 +82,18 @@ class Command(BaseCommand):
|
||||
model_name = model._meta.model_name
|
||||
self.stdout.write(f' {app_label}.{model_name}... ', ending='')
|
||||
self.stdout.flush()
|
||||
|
||||
if kwargs['lazy']:
|
||||
content_type = ContentType.objects.get_for_model(model)
|
||||
if cached_count := search_backend.count(object_types=[content_type]):
|
||||
self.stdout.write(f'Skipping (found {cached_count} existing).')
|
||||
continue
|
||||
|
||||
i = search_backend.cache(model.objects.iterator(), remove_existing=False)
|
||||
if i:
|
||||
self.stdout.write(f'{i} entries cached.')
|
||||
else:
|
||||
self.stdout.write(f'None found.')
|
||||
self.stdout.write(f'No objects found.')
|
||||
|
||||
msg = f'Completed.'
|
||||
if total_count := search_backend.size:
|
||||
|
18
netbox/extras/migrations/0089_customfield_is_cloneable.py
Normal file
18
netbox/extras/migrations/0089_customfield_is_cloneable.py
Normal file
@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.1.2 on 2022-11-17 18:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0088_jobresult_webhooks'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='customfield',
|
||||
name='is_cloneable',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
@ -163,13 +163,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
verbose_name='UI visibility',
|
||||
help_text=_('Specifies the visibility of custom field in the UI')
|
||||
)
|
||||
is_cloneable = models.BooleanField(
|
||||
default=False,
|
||||
verbose_name='Cloneable',
|
||||
help_text='If true, this field will be copied over when cloning objects.'
|
||||
)
|
||||
|
||||
objects = CustomFieldManager()
|
||||
|
||||
clone_fields = (
|
||||
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
|
||||
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
|
||||
'ui_visibility',
|
||||
'ui_visibility', 'is_cloneable',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
|
||||
|
||||
def _load_resource(self, name):
|
||||
# Import from the configured path, if defined.
|
||||
if getattr(self, name):
|
||||
return import_string(f"{self.__module__}.{self.name}")
|
||||
if path := getattr(self, name, None):
|
||||
return import_string(f"{self.__module__}.{path}")
|
||||
|
||||
# Fall back to the resource's default path. Return None if the module has not been provided.
|
||||
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'
|
||||
|
@ -1,5 +1,6 @@
|
||||
from netbox.navigation import MenuGroup
|
||||
from utilities.choices import ButtonColorChoices
|
||||
from django.utils.text import slugify
|
||||
|
||||
__all__ = (
|
||||
'PluginMenu',
|
||||
@ -21,7 +22,7 @@ class PluginMenu:
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.label.replace(' ', '_')
|
||||
return slugify(self.label)
|
||||
|
||||
|
||||
class PluginMenuItem:
|
||||
|
@ -29,12 +29,14 @@ class CustomFieldTable(NetBoxTable):
|
||||
content_types = columns.ContentTypesColumn()
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
is_cloneable = columns.BooleanColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = CustomField
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
|
||||
'search_weight', 'filter_logic', 'ui_visibility', 'weight', 'choices', 'created', 'last_updated',
|
||||
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
|
||||
|
||||
|
@ -548,7 +548,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
def test_assigned_object_type(self):
|
||||
params = {'assigned_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_assigned_object(self):
|
||||
@ -922,7 +922,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
|
||||
def test_changed_object_type(self):
|
||||
params = {'changed_object_type': 'dcim.site'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
def test_changed_object_type_id(self):
|
||||
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
|
||||
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
@ -107,4 +107,6 @@ urlpatterns = [
|
||||
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
|
||||
|
||||
# Markdown
|
||||
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
|
||||
]
|
||||
|
@ -2,7 +2,7 @@ from django.contrib import messages
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Count, Q
|
||||
from django.http import Http404, HttpResponseForbidden, HttpResponse
|
||||
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.views.generic import View
|
||||
@ -14,6 +14,7 @@ from extras.dashboard.utils import get_widget_class
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
from utilities.htmx import is_htmx
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
||||
from . import filtersets, forms, tables
|
||||
@ -685,7 +686,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
|
||||
widget_form = DashboardWidgetAddForm(initial=initial)
|
||||
widget_name = get_field_value(widget_form, 'widget_class')
|
||||
widget_class = get_widget_class(widget_name)
|
||||
config_form = widget_class.ConfigForm(prefix='config')
|
||||
config_form = widget_class.ConfigForm(initial=widget_class.default_config, prefix='config')
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'widget_class': widget_class,
|
||||
@ -1076,3 +1077,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = JobResult.objects.all()
|
||||
filterset = filtersets.JobResultFilterSet
|
||||
table = tables.JobResultTable
|
||||
|
||||
|
||||
#
|
||||
# Markdown
|
||||
#
|
||||
|
||||
class RenderMarkdownView(View):
|
||||
|
||||
def post(self, request):
|
||||
form = forms.RenderMarkdownForm(request.POST)
|
||||
if not form.is_valid():
|
||||
HttpResponseBadRequest()
|
||||
rendered = render_markdown(form.cleaned_data['text'])
|
||||
|
||||
return HttpResponse(rendered)
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
|
||||
import secrets
|
||||
|
||||
|
@ -18,6 +18,7 @@ from virtualization.models import VirtualMachine, VMInterface
|
||||
from .choices import *
|
||||
from .models import *
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
__all__ = (
|
||||
'AggregateFilterSet',
|
||||
@ -625,7 +626,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
return queryset.none()
|
||||
return queryset.filter(q)
|
||||
|
||||
def parse_inet_addresses(self, value):
|
||||
'''
|
||||
Parse networks or IP addresses and cast to a format
|
||||
acceptable by the Postgres inet type.
|
||||
|
||||
Skips invalid values.
|
||||
'''
|
||||
parsed = []
|
||||
for addr in value:
|
||||
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
|
||||
parsed.append(addr)
|
||||
continue
|
||||
try:
|
||||
network = netaddr.IPNetwork(addr)
|
||||
parsed.append(str(network))
|
||||
except (AddrFormatError, ValueError):
|
||||
continue
|
||||
return parsed
|
||||
|
||||
def filter_address(self, queryset, name, value):
|
||||
# Let's first parse the addresses passed
|
||||
# as argument. If they are all invalid,
|
||||
# we return an empty queryset
|
||||
value = self.parse_inet_addresses(value)
|
||||
if (len(value) == 0):
|
||||
return queryset.none()
|
||||
|
||||
try:
|
||||
return queryset.filter(address__net_in=value)
|
||||
except ValidationError:
|
||||
|
@ -49,7 +49,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -70,7 +69,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -139,7 +137,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -168,7 +165,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -249,7 +245,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -292,7 +287,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -338,7 +332,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -381,7 +374,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -463,7 +455,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -494,7 +485,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
@ -523,7 +513,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
comments = CommentField(
|
||||
widget=forms.Textarea,
|
||||
label='Comments'
|
||||
)
|
||||
|
||||
|
@ -200,40 +200,11 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
}
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$site_group',
|
||||
}
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('VLAN group'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site': '$site'
|
||||
},
|
||||
initial_params={
|
||||
'vlans': '$vlan'
|
||||
}
|
||||
selector=True,
|
||||
null_option='None'
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
@ -241,7 +212,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
label=_('VLAN'),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'group_id': '$vlan_group',
|
||||
}
|
||||
)
|
||||
role = DynamicModelChoiceField(
|
||||
@ -252,7 +222,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
fieldsets = (
|
||||
('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')),
|
||||
('Site/VLAN Assignment', ('region', 'site_group', 'site', 'vlan_group', 'vlan')),
|
||||
('Site/VLAN Assignment', ('site', 'vlan')),
|
||||
('Tenancy', ('tenant_group', 'tenant')),
|
||||
)
|
||||
|
||||
@ -329,65 +299,22 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
label=_('Region'),
|
||||
initial_params={
|
||||
'sites': '$nat_site'
|
||||
}
|
||||
)
|
||||
nat_site_group = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group'),
|
||||
initial_params={
|
||||
'sites': '$nat_site'
|
||||
}
|
||||
)
|
||||
nat_site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
label=_('Site'),
|
||||
query_params={
|
||||
'region_id': '$nat_region',
|
||||
'group_id': '$nat_site_group',
|
||||
}
|
||||
)
|
||||
nat_rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
label=_('Rack'),
|
||||
null_option='None',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
nat_device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
label=_('Device'),
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'rack_id': '$nat_rack',
|
||||
}
|
||||
)
|
||||
nat_cluster = DynamicModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster')
|
||||
selector=True,
|
||||
label=_('Device')
|
||||
)
|
||||
nat_virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
label=_('Virtual Machine'),
|
||||
query_params={
|
||||
'cluster_id': '$nat_cluster',
|
||||
}
|
||||
selector=True,
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
nat_vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_inside = DynamicModelChoiceField(
|
||||
@ -409,9 +336,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device',
|
||||
'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description',
|
||||
'comments', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine',
|
||||
'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -567,6 +493,7 @@ class FHRPGroupForm(NetBoxModelForm):
|
||||
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
|
||||
assigned_object=instance
|
||||
)
|
||||
ipaddress.populate_custom_field_defaults()
|
||||
ipaddress.save()
|
||||
|
||||
# Check that the new IPAddress conforms with any assigned object-level permissions
|
||||
@ -713,58 +640,18 @@ class VLANGroupForm(NetBoxModelForm):
|
||||
|
||||
|
||||
class VLANForm(TenancyForm, NetBoxModelForm):
|
||||
# VLANGroup assignment fields
|
||||
scope_type = forms.ChoiceField(
|
||||
choices=(
|
||||
('', ''),
|
||||
('dcim.region', 'Region'),
|
||||
('dcim.sitegroup', 'Site group'),
|
||||
('dcim.site', 'Site'),
|
||||
('dcim.location', 'Location'),
|
||||
('dcim.rack', 'Rack'),
|
||||
('virtualization.clustergroup', 'Cluster group'),
|
||||
('virtualization.cluster', 'Cluster'),
|
||||
),
|
||||
required=False,
|
||||
label=_('Group scope')
|
||||
)
|
||||
group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'scope_type': '$scope_type',
|
||||
},
|
||||
selector=True,
|
||||
label=_('VLAN Group')
|
||||
)
|
||||
|
||||
# Site assignment fields
|
||||
region = DynamicModelChoiceField(
|
||||
queryset=Region.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
},
|
||||
label=_('Region')
|
||||
)
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'sites': '$site'
|
||||
},
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
null_option='None',
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$sitegroup',
|
||||
}
|
||||
selector=True
|
||||
)
|
||||
|
||||
# Other fields
|
||||
role = DynamicModelChoiceField(
|
||||
queryset=Role.objects.all(),
|
||||
required=False
|
||||
@ -803,11 +690,13 @@ class ServiceTemplateForm(NetBoxModelForm):
|
||||
class ServiceForm(NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
selector=True
|
||||
)
|
||||
ports = NumericArrayField(
|
||||
base_field=forms.IntegerField(
|
||||
@ -907,43 +796,21 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
label=_('L2VPN'),
|
||||
fetch_trigger='open'
|
||||
)
|
||||
device_vlan = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
label=_("Available on Device"),
|
||||
required=False,
|
||||
query_params={}
|
||||
)
|
||||
vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'available_on_device': '$device_vlan'
|
||||
},
|
||||
selector=True,
|
||||
label=_('VLAN')
|
||||
)
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
query_params={}
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
query_params={}
|
||||
selector=True
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_machine_id': '$virtual_machine'
|
||||
},
|
||||
selector=True,
|
||||
label=_('Interface')
|
||||
)
|
||||
|
||||
@ -957,7 +824,6 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
||||
|
||||
if instance:
|
||||
if type(instance.assigned_object) is Interface:
|
||||
initial['device'] = instance.assigned_object.parent
|
||||
initial['interface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is VLAN:
|
||||
initial['vlan'] = instance.assigned_object
|
||||
|
@ -27,6 +27,28 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
class IPAddressFamilyType(graphene.ObjectType):
|
||||
|
||||
value = graphene.Int()
|
||||
label = graphene.String()
|
||||
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
self.label = f'IPv{value}'
|
||||
|
||||
|
||||
class BaseIPAddressFamilyType:
|
||||
"""
|
||||
Base type for models that need to expose their IPAddress family type.
|
||||
"""
|
||||
family = graphene.Field(IPAddressFamilyType)
|
||||
|
||||
def resolve_family(self, _):
|
||||
# Note that self, is an instance of models.IPAddress
|
||||
# thus resolves to the address family value.
|
||||
return IPAddressFamilyType(self.family)
|
||||
|
||||
|
||||
class ASNType(NetBoxObjectType):
|
||||
asn = graphene.Field(BigInt)
|
||||
|
||||
@ -44,7 +66,7 @@ class ASNRangeType(NetBoxObjectType):
|
||||
filterset_class = filtersets.ASNRangeFilterSet
|
||||
|
||||
|
||||
class AggregateType(NetBoxObjectType):
|
||||
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Aggregate
|
||||
@ -72,7 +94,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
|
||||
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
|
||||
|
||||
|
||||
class IPAddressType(NetBoxObjectType):
|
||||
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
|
||||
|
||||
class Meta:
|
||||
@ -95,7 +117,7 @@ class IPRangeType(NetBoxObjectType):
|
||||
return self.role or None
|
||||
|
||||
|
||||
class PrefixType(NetBoxObjectType):
|
||||
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
|
||||
|
||||
class Meta:
|
||||
model = models.Prefix
|
||||
|
31
netbox/ipam/migrations/0064_clear_search_cache.py
Normal file
31
netbox/ipam/migrations/0064_clear_search_cache.py
Normal file
@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def clear_cache(apps, schema_editor):
|
||||
"""
|
||||
Clear existing CachedValues referencing IPAddressFields or IPNetworkFields. (#11658
|
||||
introduced new cache record types for these.)
|
||||
"""
|
||||
ContentType = apps.get_model('contenttypes', 'ContentType')
|
||||
CachedValue = apps.get_model('extras', 'CachedValue')
|
||||
|
||||
for model_name in ('Aggregate', 'IPAddress', 'IPRange', 'Prefix'):
|
||||
try:
|
||||
content_type = ContentType.objects.get(app_label='ipam', model=model_name.lower())
|
||||
CachedValue.objects.filter(object_type=content_type).delete()
|
||||
except ContentType.DoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0063_standardize_description_comments'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
code=clear_cache,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
@ -12,7 +12,7 @@ class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('tenancy', '0009_standardize_description_comments'),
|
||||
('extras', '0087_dashboard'),
|
||||
('ipam', '0063_standardize_description_comments'),
|
||||
('ipam', '0064_clear_search_cache'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ipam', '0064_asnrange'),
|
||||
('ipam', '0065_asnrange'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -10,6 +10,7 @@ from ipam.models import *
|
||||
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
@ -927,6 +928,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for valid edge cases. Note that Postgres inet type
|
||||
# only accepts netmasks in the int form, so the filterset
|
||||
# casts netmasks in the xxx.xxx.xxx.xxx format.
|
||||
params = {'address': ['24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||
params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
# Check for invalid input.
|
||||
params = {'address': ['/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
params = {'address': ['10.0.0.1/255.255.999.0']} # Invalid netmask
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
|
||||
|
||||
# Check for partially invalid input.
|
||||
params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_mask_length(self):
|
||||
params = {'mask_length': '24'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
|
||||
|
@ -990,7 +990,6 @@ class FHRPGroupView(generic.ObjectView):
|
||||
class FHRPGroupEditView(generic.ObjectEditView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
form = forms.FHRPGroupForm
|
||||
template_name = 'ipam/fhrpgroup_edit.html'
|
||||
|
||||
def get_return_url(self, request, obj=None):
|
||||
return_url = super().get_return_url(request, obj)
|
||||
|
@ -107,6 +107,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
|
||||
# r'^(https?://)?(\w+\.)?example\.com$',
|
||||
]
|
||||
|
||||
# The name to use for the CSRF token cookie.
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
# Set to True to enable server debugging. WARNING: Debugging introduces a substantial performance penalty and may reveal
|
||||
# sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
|
||||
# on a production system.
|
||||
@ -127,6 +130,9 @@ EMAIL = {
|
||||
'FROM_EMAIL': '',
|
||||
}
|
||||
|
||||
# Localization
|
||||
ENABLE_LOCALIZATION = False
|
||||
|
||||
# Exempt certain models from the enforcement of view permissions. Models listed here will be viewable by all users and
|
||||
# by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
|
||||
EXEMPT_VIEW_PERMISSIONS = [
|
||||
@ -168,16 +174,6 @@ LOGOUT_REDIRECT_URL = 'home'
|
||||
# the default value of this setting is derived from the installed location.
|
||||
# MEDIA_ROOT = '/opt/netbox/netbox/media'
|
||||
|
||||
# By default uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
# STORAGE_CONFIG = {
|
||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
||||
# }
|
||||
|
||||
# Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
|
||||
METRICS_ENABLED = False
|
||||
|
||||
@ -217,13 +213,6 @@ RQ_DEFAULT_TIMEOUT = 300
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
||||
# The maximum size (in bytes) that an upload will be before it gets streamed to the file system.
|
||||
# Useful to be able to upload files bigger than 2.5Mbyte to custom scripts for processing.
|
||||
# FILE_UPLOAD_MAX_MEMORY_SIZE = 2621440
|
||||
|
||||
# The name to use for the csrf token cookie.
|
||||
CSRF_COOKIE_NAME = 'csrftoken'
|
||||
|
||||
# The name to use for the session cookie.
|
||||
SESSION_COOKIE_NAME = 'sessionid'
|
||||
|
||||
@ -232,8 +221,15 @@ SESSION_COOKIE_NAME = 'sessionid'
|
||||
# database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
|
||||
SESSION_FILE_PATH = None
|
||||
|
||||
# Localization
|
||||
ENABLE_LOCALIZATION = False
|
||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
# STORAGE_CONFIG = {
|
||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
||||
# }
|
||||
|
||||
# Time zone (default: UTC)
|
||||
TIME_ZONE = 'UTC'
|
||||
|
@ -121,6 +121,12 @@ class CloningMixin(models.Model):
|
||||
if is_taggable(self):
|
||||
attrs['tags'] = [tag.pk for tag in self.tags.all()]
|
||||
|
||||
# Include any cloneable custom fields
|
||||
if hasattr(self, 'custom_field_data'):
|
||||
for field in self.get_custom_fields():
|
||||
if field.is_cloneable:
|
||||
attrs[f'cf_{field.name}'] = self.custom_field_data.get(field.name)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
@ -219,6 +225,13 @@ class CustomFieldsMixin(models.Model):
|
||||
|
||||
return dict(groups)
|
||||
|
||||
def populate_custom_field_defaults(self):
|
||||
"""
|
||||
Apply the default value for each custom field
|
||||
"""
|
||||
for cf in self.custom_fields:
|
||||
self.custom_field_data[cf.name] = cf.default
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
from extras.models import CustomField
|
||||
|
@ -78,16 +78,16 @@ DEVICES_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Device Components'),
|
||||
items=(
|
||||
get_model_item('dcim', 'interface', _('Interfaces'), actions=['import']),
|
||||
get_model_item('dcim', 'frontport', _('Front Ports'), actions=['import']),
|
||||
get_model_item('dcim', 'rearport', _('Rear Ports'), actions=['import']),
|
||||
get_model_item('dcim', 'consoleport', _('Console Ports'), actions=['import']),
|
||||
get_model_item('dcim', 'consoleserverport', _('Console Server Ports'), actions=['import']),
|
||||
get_model_item('dcim', 'powerport', _('Power Ports'), actions=['import']),
|
||||
get_model_item('dcim', 'poweroutlet', _('Power Outlets'), actions=['import']),
|
||||
get_model_item('dcim', 'modulebay', _('Module Bays'), actions=['import']),
|
||||
get_model_item('dcim', 'devicebay', _('Device Bays'), actions=['import']),
|
||||
get_model_item('dcim', 'inventoryitem', _('Inventory Items'), actions=['import']),
|
||||
get_model_item('dcim', 'interface', _('Interfaces')),
|
||||
get_model_item('dcim', 'frontport', _('Front Ports')),
|
||||
get_model_item('dcim', 'rearport', _('Rear Ports')),
|
||||
get_model_item('dcim', 'consoleport', _('Console Ports')),
|
||||
get_model_item('dcim', 'consoleserverport', _('Console Server Ports')),
|
||||
get_model_item('dcim', 'powerport', _('Power Ports')),
|
||||
get_model_item('dcim', 'poweroutlet', _('Power Outlets')),
|
||||
get_model_item('dcim', 'modulebay', _('Module Bays')),
|
||||
get_model_item('dcim', 'devicebay', _('Device Bays')),
|
||||
get_model_item('dcim', 'inventoryitem', _('Inventory Items')),
|
||||
get_model_item('dcim', 'inventoryitemrole', _('Inventory Item Roles')),
|
||||
),
|
||||
),
|
||||
@ -216,7 +216,7 @@ VIRTUALIZATION_MENU = Menu(
|
||||
label=_('Virtual Machines'),
|
||||
items=(
|
||||
get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')),
|
||||
get_model_item('virtualization', 'vminterface', _('Interfaces'), actions=['import']),
|
||||
get_model_item('virtualization', 'vminterface', _('Interfaces')),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
|
@ -24,7 +24,7 @@ PREFERENCES = {
|
||||
'pagination.per_page': UserPreference(
|
||||
label=_('Page length'),
|
||||
choices=get_page_lengths(),
|
||||
description=_('The number of objects to display per page'),
|
||||
description=_('The default number of objects to display per page'),
|
||||
coerce=lambda x: int(x)
|
||||
),
|
||||
'pagination.placement': UserPreference(
|
||||
|
@ -54,11 +54,11 @@ class SearchBackend:
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def caching_handler(self, sender, instance, **kwargs):
|
||||
def caching_handler(self, sender, instance, created, **kwargs):
|
||||
"""
|
||||
Receiver for the post_save signal, responsible for caching object creation/changes.
|
||||
"""
|
||||
self.cache(instance)
|
||||
self.cache(instance, remove_existing=not created)
|
||||
|
||||
def removal_handler(self, sender, instance, **kwargs):
|
||||
"""
|
||||
@ -80,7 +80,13 @@ class SearchBackend:
|
||||
|
||||
def clear(self, object_types=None):
|
||||
"""
|
||||
Delete *all* cached data.
|
||||
Delete *all* cached data (optionally filtered by object type).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def count(self, object_types=None):
|
||||
"""
|
||||
Return a count of all cache entries (optionally filtered by object type).
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
@ -218,6 +224,12 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
# Call _raw_delete() on the queryset to avoid first loading instances into memory
|
||||
return qs._raw_delete(using=qs.db)
|
||||
|
||||
def count(self, object_types=None):
|
||||
qs = CachedValue.objects.all()
|
||||
if object_types:
|
||||
qs = qs.filter(object_type__in=object_types)
|
||||
return qs.count()
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return CachedValue.objects.count()
|
||||
|
@ -1,5 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import django_tables2 as tables
|
||||
from django.conf import settings
|
||||
@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
|
||||
from django.template import Context, Template
|
||||
from django.urls import reverse
|
||||
from django.utils.dateparse import parse_date
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from django.utils.html import escape
|
||||
from django.utils.formats import date_format
|
||||
from django.utils.safestring import mark_safe
|
||||
@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
|
||||
|
||||
model = table.Meta.model
|
||||
request = getattr(table, 'context', {}).get('request')
|
||||
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
|
||||
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
|
||||
html = ''
|
||||
|
||||
# Compile actions menu
|
||||
|
@ -9,7 +9,7 @@ from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_a
|
||||
from netbox.api.views import APIRootView, StatusView
|
||||
from netbox.graphql.schema import schema
|
||||
from netbox.graphql.views import GraphQLView
|
||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView
|
||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||
from users.views import LoginView, LogoutView
|
||||
from .admin import admin_site
|
||||
|
||||
@ -36,6 +36,9 @@ _patterns = [
|
||||
path('virtualization/', include('virtualization.urls')),
|
||||
path('wireless/', include('wireless.urls')),
|
||||
|
||||
# HTMX views
|
||||
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
|
||||
|
||||
# API
|
||||
path('api/', APIRootView.as_view(), name='api-root'),
|
||||
path('api/circuits/', include('circuits.api.urls')),
|
||||
|
@ -16,10 +16,10 @@ from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import ExportTemplate
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.choices import ImportFormatChoices
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, ImportForm, restrict_form_fields
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||
from utilities.forms.bulk_import import ImportForm
|
||||
from utilities.htmx import is_embedded, is_htmx
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.views import GetReturnURLMixin
|
||||
|
56
netbox/netbox/views/htmx.py
Normal file
56
netbox/netbox/views/htmx.py
Normal file
@ -0,0 +1,56 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.http import Http404
|
||||
from django.shortcuts import render
|
||||
from django.utils.module_loading import import_string
|
||||
from django.views.generic import View
|
||||
|
||||
|
||||
class ObjectSelectorView(View):
|
||||
template_name = 'htmx/object_selector.html'
|
||||
|
||||
def get(self, request):
|
||||
model = self._get_model(request.GET.get('_model', ''))
|
||||
|
||||
form_class = self._get_form_class(model)
|
||||
form = form_class(request.GET)
|
||||
|
||||
if '_search' in request.GET:
|
||||
# Return only search results
|
||||
filterset = self._get_filterset_class(model)
|
||||
|
||||
queryset = model.objects.restrict(request.user)
|
||||
if filterset:
|
||||
queryset = filterset(request.GET, queryset, request=request).qs
|
||||
|
||||
return render(request, 'htmx/object_selector_results.html', {
|
||||
'results': queryset[:100],
|
||||
})
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'form': form,
|
||||
'model': model,
|
||||
'target_id': request.GET.get('target'),
|
||||
})
|
||||
|
||||
def _get_model(self, label):
|
||||
try:
|
||||
app_label, model_name = label.split('.')
|
||||
content_type = ContentType.objects.get_by_natural_key(app_label, model_name)
|
||||
except (ValueError, ObjectDoesNotExist):
|
||||
raise Http404
|
||||
return content_type.model_class()
|
||||
|
||||
def _get_form_class(self, model):
|
||||
if hasattr(self, 'form_class'):
|
||||
return self.form_class
|
||||
app_label = model._meta.app_label
|
||||
class_name = f'{model.__name__}FilterForm'
|
||||
return import_string(f'{app_label}.forms.{class_name}')
|
||||
|
||||
def _get_filterset_class(self, model):
|
||||
if hasattr(self, 'filterset_class'):
|
||||
return self.filterset_class
|
||||
app_label = model._meta.app_label
|
||||
class_name = f'{model.__name__}FilterSet'
|
||||
return import_string(f'{app_label}.filtersets.{class_name}')
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
|
||||
import { initReslug } from './reslug';
|
||||
import { initSelectAll } from './selectAll';
|
||||
import { initSelectMultiple } from './selectMultiple';
|
||||
import { initMarkdownPreviews } from './markdownPreview';
|
||||
|
||||
export function initButtons(): void {
|
||||
for (const func of [
|
||||
@ -13,6 +14,7 @@ export function initButtons(): void {
|
||||
initSelectAll,
|
||||
initSelectMultiple,
|
||||
initMoveButtons,
|
||||
initMarkdownPreviews,
|
||||
]) {
|
||||
func();
|
||||
}
|
||||
|
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
45
netbox/project-static/src/buttons/markdownPreview.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { isTruthy } from 'src/util';
|
||||
|
||||
/**
|
||||
* interface for htmx configRequest event
|
||||
*/
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'htmx:configRequest': CustomEvent<{
|
||||
parameters: Record<string, string>;
|
||||
headers: Record<string, string>;
|
||||
}>;
|
||||
}
|
||||
}
|
||||
|
||||
function initMarkdownPreview(markdownWidget: HTMLDivElement) {
|
||||
const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement;
|
||||
const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
|
||||
const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
|
||||
|
||||
/**
|
||||
* Make sure the textarea has style attribute height
|
||||
* So that it can be copied over to preview div.
|
||||
*/
|
||||
if (!isTruthy(textarea.style.height)) {
|
||||
const { height } = textarea.getBoundingClientRect();
|
||||
textarea.style.height = `${height}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the value of the textarea to the body of the htmx request
|
||||
* and copy the height of text are to the preview div
|
||||
*/
|
||||
previewButton.addEventListener('htmx:configRequest', e => {
|
||||
e.detail.parameters = { text: textarea.value || '' };
|
||||
e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN;
|
||||
preview.style.minHeight = textarea.style.height;
|
||||
preview.innerHTML = '';
|
||||
});
|
||||
}
|
||||
|
||||
export function initMarkdownPreviews(): void {
|
||||
for (const markdownWidget of document.querySelectorAll<HTMLDivElement>('.markdown-widget')) {
|
||||
initMarkdownPreview(markdownWidget);
|
||||
}
|
||||
}
|
@ -1,9 +1,10 @@
|
||||
import { getElements, isTruthy } from './util';
|
||||
import { initButtons } from './buttons';
|
||||
import { initSelect } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
|
||||
function initDepedencies(): void {
|
||||
for (const init of [initButtons, initSelect]) {
|
||||
for (const init of [initButtons, initSelect, initObjectSelector]) {
|
||||
init();
|
||||
}
|
||||
}
|
||||
|
32
netbox/project-static/src/objectSelector.ts
Normal file
32
netbox/project-static/src/objectSelector.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { getElements } from './util';
|
||||
|
||||
function handleSelection(link: HTMLAnchorElement): void {
|
||||
const selector_results = document.getElementById('selector_results');
|
||||
if (selector_results == null) {
|
||||
return
|
||||
}
|
||||
const target_id = selector_results.getAttribute('data-selector-target');
|
||||
if (target_id == null) {
|
||||
return
|
||||
}
|
||||
const target = document.getElementById(target_id);
|
||||
if (target == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const label = link.getAttribute('data-label');
|
||||
const value = link.getAttribute('data-value');
|
||||
|
||||
//@ts-ignore
|
||||
target.slim.setData([
|
||||
{text: label, value: value}
|
||||
]);
|
||||
|
||||
}
|
||||
|
||||
|
||||
export function initObjectSelector(): void {
|
||||
for (const element of getElements<HTMLAnchorElement>('#selector_results a')) {
|
||||
element.addEventListener('click', () => handleSelection(element));
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||
|
||||
type InterfaceState = 'enabled' | 'disabled';
|
||||
type ShowHide = 'show' | 'hide';
|
||||
|
||||
function isShowHide(value: unknown): value is ShowHide {
|
||||
@ -27,54 +26,23 @@ class ButtonState {
|
||||
* Underlying Button DOM Element
|
||||
*/
|
||||
public button: HTMLButtonElement;
|
||||
/**
|
||||
* Table rows with `data-enabled` set to `"enabled"`
|
||||
*/
|
||||
private enabledRows: NodeListOf<HTMLTableRowElement>;
|
||||
/**
|
||||
* Table rows with `data-enabled` set to `"disabled"`
|
||||
*/
|
||||
private disabledRows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(button: HTMLButtonElement, table: HTMLTableElement) {
|
||||
/**
|
||||
* Table rows provided in constructor
|
||||
*/
|
||||
private rows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(button: HTMLButtonElement, rows: NodeListOf<HTMLTableRowElement>) {
|
||||
this.button = button;
|
||||
this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]');
|
||||
this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
|
||||
this.rows = rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* This button's controlled type. For example, a button with the class `toggle-disabled` has
|
||||
* directive 'disabled' because it controls the visibility of rows with
|
||||
* `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
|
||||
* `data-enabled="enabled"`.
|
||||
* Remove visibility of button state rows.
|
||||
*/
|
||||
private get directive(): InterfaceState {
|
||||
if (this.button.classList.contains('toggle-disabled')) {
|
||||
return 'disabled';
|
||||
} else if (this.button.classList.contains('toggle-enabled')) {
|
||||
return 'enabled';
|
||||
}
|
||||
// If this class has been instantiated but doesn't contain these classes, it's probably because
|
||||
// the classes are missing in the HTML template.
|
||||
console.warn(this.button);
|
||||
throw new Error('Toggle button does not contain expected class');
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of rows with `data-enabled="enabled"`.
|
||||
*/
|
||||
private toggleEnabledRows(): void {
|
||||
for (const row of this.enabledRows) {
|
||||
row.classList.toggle('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility of rows with `data-enabled="disabled"`.
|
||||
*/
|
||||
private toggleDisabledRows(): void {
|
||||
for (const row of this.disabledRows) {
|
||||
row.classList.toggle('d-none');
|
||||
private hideRows(): void {
|
||||
for (const row of this.rows) {
|
||||
row.classList.add('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,17 +79,6 @@ class ButtonState {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visibility for the rows this element controls.
|
||||
*/
|
||||
private toggleRows(): void {
|
||||
if (this.directive === 'enabled') {
|
||||
this.toggleEnabledRows();
|
||||
} else if (this.directive === 'disabled') {
|
||||
this.toggleDisabledRows();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle the DOM element's `data-state` attribute.
|
||||
*/
|
||||
@ -139,17 +96,20 @@ class ButtonState {
|
||||
private toggle(): void {
|
||||
this.toggleState();
|
||||
this.toggleButton();
|
||||
this.toggleRows();
|
||||
}
|
||||
|
||||
/**
|
||||
* When the button is clicked, toggle all controlled elements.
|
||||
* When the button is clicked, toggle all controlled elements and hide rows based on
|
||||
* buttonstate.
|
||||
*/
|
||||
public handleClick(event: Event): void {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
if (button.isEqualNode(this.button)) {
|
||||
this.toggle();
|
||||
}
|
||||
if (this.buttonState === 'hide') {
|
||||
this.hideRows();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,14 +134,25 @@ class TableState {
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private disabledButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Instance of ButtonState for the 'show/hide virtual rows' button.
|
||||
*/
|
||||
// @ts-expect-error null handling is performed in the constructor
|
||||
private virtualButton: ButtonState;
|
||||
|
||||
/**
|
||||
* Underlying DOM Table Caption Element.
|
||||
*/
|
||||
private caption: Nullable<HTMLTableCaptionElement> = null;
|
||||
|
||||
/**
|
||||
* All table rows in table
|
||||
*/
|
||||
private rows: NodeListOf<HTMLTableRowElement>;
|
||||
|
||||
constructor(table: HTMLTableElement) {
|
||||
this.table = table;
|
||||
|
||||
this.rows = this.table.querySelectorAll('tr');
|
||||
try {
|
||||
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
@ -191,6 +162,10 @@ class TableState {
|
||||
this.table,
|
||||
'button.toggle-disabled',
|
||||
);
|
||||
const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
|
||||
this.table,
|
||||
'button.toggle-virtual',
|
||||
);
|
||||
|
||||
const caption = this.table.querySelector('caption');
|
||||
this.caption = caption;
|
||||
@ -203,13 +178,28 @@ class TableState {
|
||||
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
|
||||
}
|
||||
|
||||
if (toggleVirtualButton === null) {
|
||||
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
|
||||
}
|
||||
|
||||
// Attach event listeners to the buttons elements.
|
||||
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
|
||||
|
||||
// Instantiate ButtonState for each button for state management.
|
||||
this.enabledButton = new ButtonState(toggleEnabledButton, this.table);
|
||||
this.disabledButton = new ButtonState(toggleDisabledButton, this.table);
|
||||
this.enabledButton = new ButtonState(
|
||||
toggleEnabledButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'),
|
||||
);
|
||||
this.disabledButton = new ButtonState(
|
||||
toggleDisabledButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]'),
|
||||
);
|
||||
this.virtualButton = new ButtonState(
|
||||
toggleVirtualButton,
|
||||
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof TableStateError) {
|
||||
// This class is useless for tables that don't have toggle buttons.
|
||||
@ -246,37 +236,42 @@ class TableState {
|
||||
private toggleCaption(): void {
|
||||
const showEnabled = this.enabledButton.buttonState === 'show';
|
||||
const showDisabled = this.disabledButton.buttonState === 'show';
|
||||
const showVirtual = this.virtualButton.buttonState === 'show';
|
||||
|
||||
if (showEnabled && !showDisabled) {
|
||||
if (showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled Interfaces';
|
||||
} else if (showEnabled && showDisabled) {
|
||||
} else if (showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && showDisabled) {
|
||||
} else if (!showEnabled && showDisabled && !showVirtual) {
|
||||
this.captionText = 'Showing Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled) {
|
||||
this.captionText = 'Hiding Enabled & Disabled Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && !showVirtual) {
|
||||
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
|
||||
} else if (!showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Virtual Interfaces';
|
||||
} else if (showEnabled && !showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled & Virtual Interfaces';
|
||||
} else if (showEnabled && showDisabled && showVirtual) {
|
||||
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
|
||||
} else {
|
||||
this.captionText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When toggle buttons are clicked, pass the event to the relevant button's handler and update
|
||||
* this instance's state.
|
||||
* When toggle buttons are clicked, reapply visability all rows and
|
||||
* pass the event to all button handlers
|
||||
*
|
||||
* @param event onClick event for toggle buttons.
|
||||
* @param instance Instance of TableState (`this` cannot be used since that's context-specific).
|
||||
*/
|
||||
public handleClick(event: Event, instance: TableState): void {
|
||||
const button = event.currentTarget as HTMLButtonElement;
|
||||
const enabled = button.isEqualNode(instance.enabledButton.button);
|
||||
const disabled = button.isEqualNode(instance.disabledButton.button);
|
||||
|
||||
if (enabled) {
|
||||
instance.enabledButton.handleClick(event);
|
||||
} else if (disabled) {
|
||||
instance.disabledButton.handleClick(event);
|
||||
for (const row of this.rows) {
|
||||
row.classList.remove('d-none');
|
||||
}
|
||||
|
||||
instance.enabledButton.handleClick(event);
|
||||
instance.disabledButton.handleClick(event);
|
||||
instance.virtualButton.handleClick(event);
|
||||
instance.toggleCaption();
|
||||
}
|
||||
}
|
||||
|
@ -236,12 +236,12 @@ table {
|
||||
}
|
||||
|
||||
th.asc > a::after {
|
||||
content: "\f0140";
|
||||
content: '\f0140';
|
||||
font-family: 'Material Design Icons';
|
||||
}
|
||||
|
||||
th.desc > a::after {
|
||||
content: "\f0143";
|
||||
content: '\f0143';
|
||||
font-family: 'Material Design Icons';
|
||||
}
|
||||
|
||||
@ -419,7 +419,7 @@ nav.search {
|
||||
// Styles for the quicksearch and its clear button;
|
||||
// Overrides input-group styles and adds transition effects
|
||||
.quicksearch {
|
||||
input[type="search"] {
|
||||
input[type='search'] {
|
||||
border-radius: $border-radius !important;
|
||||
}
|
||||
|
||||
@ -998,9 +998,24 @@ div.card-overlay {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
/* Markdown widget */
|
||||
.markdown-widget {
|
||||
.nav-link {
|
||||
border-bottom: 0;
|
||||
|
||||
&.active {
|
||||
background-color: var(--nbx-body-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
background-color: var(--nbx-pre-bg);
|
||||
}
|
||||
}
|
||||
|
||||
// Preformatted text blocks
|
||||
td pre {
|
||||
margin-bottom: 0
|
||||
margin-bottom: 0;
|
||||
}
|
||||
pre.block {
|
||||
padding: $spacer;
|
||||
|
@ -27,12 +27,9 @@
|
||||
</div>
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane{% if not providernetwork_tab_active %} active{% endif %}" id="site">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
</div>
|
||||
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
|
||||
{% render_field form.provider_network_provider %}
|
||||
{% render_field form.provider_network %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -74,10 +74,10 @@
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ termination.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
@ -3,82 +3,49 @@
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block content-wrapper %}
|
||||
<div class="tab-content">
|
||||
{% render_errors form %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
<div class="row my-3">
|
||||
<div class="col col-md-5">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header offset-sm-3">A Side</h5>
|
||||
<div class="card-body">
|
||||
{% render_field form.termination_a_region %}
|
||||
{% render_field form.termination_a_sitegroup %}
|
||||
{% render_field form.termination_a_site %}
|
||||
{% render_field form.termination_a_location %}
|
||||
{% if 'termination_a_rack' in form.fields %}
|
||||
{% render_field form.termination_a_rack %}
|
||||
{% endif %}
|
||||
{% block form %}
|
||||
|
||||
{# A side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">A Side</h5>
|
||||
</div>
|
||||
{% if 'termination_a_device' in form.fields %}
|
||||
{% render_field form.termination_a_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_a_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_provider' in form.fields %}
|
||||
{% render_field form.termination_a_provider %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_circuit' in form.fields %}
|
||||
{% render_field form.termination_a_circuit %}
|
||||
{% endif %}
|
||||
{% render_field form.a_terminations %}
|
||||
</div>
|
||||
|
||||
{# B side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">B Side</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col col-md-2 flex-column justify-content-center align-items-center d-none d-md-flex">
|
||||
<i class="mdi mdi-swap-horizontal-bold mdi-48px"></i>
|
||||
</div>
|
||||
<div class="col col-md-5">
|
||||
<div class="card h-100">
|
||||
<h5 class="card-header offset-sm-3">B Side</h5>
|
||||
<div class="card-body">
|
||||
{% render_field form.termination_b_region %}
|
||||
{% render_field form.termination_b_sitegroup %}
|
||||
{% render_field form.termination_b_site %}
|
||||
{% render_field form.termination_b_location %}
|
||||
{% if 'termination_b_rack' in form.fields %}
|
||||
{% render_field form.termination_b_rack %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_device' in form.fields %}
|
||||
{% render_field form.termination_b_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_b_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_provider' in form.fields %}
|
||||
{% render_field form.termination_b_provider %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_circuit' in form.fields %}
|
||||
{% render_field form.termination_b_circuit %}
|
||||
{% endif %}
|
||||
{% render_field form.b_terminations %}
|
||||
</div>
|
||||
|
||||
{# Cable attributes #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Cable</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-3 justify-content-center">
|
||||
<div class="col col-md-8">
|
||||
<div class="card">
|
||||
<h5 class="card-header offset-sm-3">Cable</h5>
|
||||
<div class="card-body">
|
||||
{% render_field form.status %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
{% render_field form.label %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.color %}
|
||||
@ -94,33 +61,29 @@
|
||||
</div>
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Tenancy</h5>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header text-center">Comments</h5>
|
||||
<div class="card-body">
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="card">
|
||||
<h5 class="card-header offset-sm-3">Custom Fields</h5>
|
||||
<div class="card-body">
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">Comments</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row my-3">
|
||||
<div class="col col-md-12 text-center">
|
||||
{% if object.pk %}
|
||||
<button type="submit" name="_update" class="btn btn-primary">Save</button>
|
||||
{% else %}
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
{% endif %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
@ -71,13 +71,13 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -71,13 +71,13 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -7,5 +7,6 @@
|
||||
<ul class="dropdown-menu">
|
||||
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
|
||||
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
|
||||
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
|
||||
</ul>
|
||||
{% endblock extra_table_controls %}
|
||||
|
@ -18,7 +18,6 @@
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Hardware</h5>
|
||||
</div>
|
||||
{% render_field form.manufacturer %}
|
||||
{% render_field form.device_type %}
|
||||
{% render_field form.airflow %}
|
||||
{% render_field form.serial %}
|
||||
@ -29,8 +28,6 @@
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Location</h5>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.location %}
|
||||
{% render_field form.rack %}
|
||||
@ -76,7 +73,6 @@
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Virtualization</h5>
|
||||
</div>
|
||||
{% render_field form.cluster_group %}
|
||||
{% render_field form.cluster %}
|
||||
</div>
|
||||
|
||||
|
@ -109,22 +109,22 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Console Server Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}">Console Server Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Console Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}">Console Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -182,16 +182,16 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Interface</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Front Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
||||
<a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.interface&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -122,7 +122,7 @@
|
||||
{% if not object.mark_connected and not object.cable %}
|
||||
<div class="card-footer">
|
||||
{% if perms.dcim.add_cable %}
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" class="btn btn-primary btn-sm float-end">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -71,7 +71,7 @@
|
||||
<div class="text-muted">
|
||||
Not Connected
|
||||
{% if perms.dcim.add_cable %}
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" title="Connect" class="btn btn-primary btn-sm float-end">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
|
||||
</a>
|
||||
{% endif %}
|
||||
|
@ -77,10 +77,10 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.poweroutlet&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerfeed&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
|
@ -6,8 +6,6 @@
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Rack</h5>
|
||||
</div>
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site_group %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.location %}
|
||||
{% render_field form.name %}
|
||||
|
@ -105,16 +105,16 @@
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}" class="dropdown-item">Interface</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&termination_b_site={{ object.device.site.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.rearport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}" class="dropdown-item">Circuit Termination</a>
|
||||
</li>
|
||||
</ul>
|
||||
</span>
|
||||
|
@ -60,6 +60,10 @@
|
||||
<th scope="row">UI Visibility</th>
|
||||
<td>{{ object.get_ui_visibility_display }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Cloneable</th>
|
||||
<td>{% checkmark object.is_cloneable %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -30,7 +30,7 @@
|
||||
<strong>{{ widget.title }}</strong>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="card-body p-2 overflow-auto">
|
||||
{% render_widget widget %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,6 +4,7 @@
|
||||
{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Add a Widget</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% block form %}
|
||||
|
@ -1,4 +0,0 @@
|
||||
<div class="htmx-container"
|
||||
hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
|
||||
hx-trigger="load"
|
||||
></div>
|
@ -0,0 +1,4 @@
|
||||
<div class="htmx-container"
|
||||
hx-get="{% url viewname %}"
|
||||
hx-trigger="load"
|
||||
></div>
|
13
netbox/templates/extras/dashboard/widgets/rssfeed.html
Normal file
13
netbox/templates/extras/dashboard/widgets/rssfeed.html
Normal file
@ -0,0 +1,13 @@
|
||||
<div class="list-group list-group-flush">
|
||||
{% for entry in feed.entries %}
|
||||
<div class="list-group-item px-1">
|
||||
<h6><a href="{{ entry.link }}">{{ entry.title }}</a></h6>
|
||||
<div>
|
||||
{{ entry.summary|safe }}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">No content found</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
@ -15,15 +15,20 @@ Context:
|
||||
{% block tabs %}
|
||||
<ul class="nav nav-tabs px-3">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="data-import-tab" data-bs-toggle="tab" data-bs-target="#data-import-form" type="button" role="tab" aria-controls="data-import-form" aria-selected="true">
|
||||
Data Import
|
||||
<button class="nav-link active" id="import-form-tab" data-bs-toggle="tab" data-bs-target="#import-form" type="button" role="tab" aria-controls="import-form" aria-selected="true">
|
||||
Direct Import
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="file-upload-tab" data-bs-toggle="tab" data-bs-target="#file-upload-form" type="button" role="tab" aria-controls="file-upload-form" aria-selected="false">
|
||||
<button class="nav-link" id="upload-form-tab" data-bs-toggle="tab" data-bs-target="#upload-form" type="button" role="tab" aria-controls="upload-form" aria-selected="false">
|
||||
Upload File
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="datafile-form-tab" data-bs-toggle="tab" data-bs-target="#datafile-form" type="button" role="tab" aria-controls="datafile-form" aria-selected="false">
|
||||
Data File
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
{% endblock tabs %}
|
||||
|
||||
@ -31,12 +36,12 @@ Context:
|
||||
<div class="tab-content">
|
||||
|
||||
{# Data Import Form #}
|
||||
<div class="tab-pane show active" id="data-import-form" role="tabpanel" aria-labelledby="data-import-tab">
|
||||
{% block content %}
|
||||
<div class="tab-pane show active" id="import-form" role="tabpanel" aria-labelledby="import-form-tab">
|
||||
<div class="row">
|
||||
<div class="col col-md-12 col-lg-10">
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_method" value="direct" />
|
||||
{% render_field form.data %}
|
||||
{% render_field form.format %}
|
||||
<div class="form-group">
|
||||
@ -50,14 +55,35 @@ Context:
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
</div>
|
||||
|
||||
{# File Upload Form #}
|
||||
<div class="tab-pane show" id="file-upload-form" role="tabpanel" aria-labelledby="file-upload-tab">
|
||||
<div class="col col-md-12 col-lg-10">
|
||||
<div class="tab-pane show" id="upload-form" role="tabpanel" aria-labelledby="upload-form-tab">
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_method" value="upload" />
|
||||
{% render_field form.upload_file %}
|
||||
{% render_field form.format %}
|
||||
<div class="form-group">
|
||||
<div class="col col-md-12 text-end">
|
||||
<button type="submit" name="file_submit" class="btn btn-primary">Submit</button>
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# DataFile Form #}
|
||||
<div class="tab-pane show" id="datafile-form" role="tabpanel" aria-labelledby="datafile-form-tab">
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="import_method" value="datafile" />
|
||||
{% render_field form.data_source %}
|
||||
{% render_field form.data_file %}
|
||||
{% render_field form.format %}
|
||||
<div class="form-group">
|
||||
|
@ -74,3 +74,7 @@ Context:
|
||||
</div>
|
||||
|
||||
{% endblock content-wrapper %}
|
||||
|
||||
{% block modals %}
|
||||
{% include 'inc/htmx_modal.html' with size='lg' %}
|
||||
{% endblock %}
|
||||
|
32
netbox/templates/htmx/object_selector.html
Normal file
32
netbox/templates/htmx/object_selector.html
Normal file
@ -0,0 +1,32 @@
|
||||
{% load form_helpers %}
|
||||
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Select {{ model|meta:"verbose_name"|bettertitle }}</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body row">
|
||||
<div class="col-3">
|
||||
<div class="list-group list-group-flush">
|
||||
{% for field in form.visible_fields %}
|
||||
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
|
||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||
{{ field.label }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-9">
|
||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
|
||||
<input type="hidden" name="_search" value="true" />
|
||||
<div class="tab-content p-1">
|
||||
{% for field in form.visible_fields %}
|
||||
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<button type="submit" class="btn btn-sm btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="selector_results" class="mt-3" data-selector-target="{{ target_id }}"></div>
|
||||
</div>
|
||||
</div>
|
13
netbox/templates/htmx/object_selector_results.html
Normal file
13
netbox/templates/htmx/object_selector_results.html
Normal file
@ -0,0 +1,13 @@
|
||||
<div class="list-group">
|
||||
{% for object in results %}
|
||||
<a href="#" class="list-group-item list-group-item-action" data-label="{{ object }}" data-value="{{ object.pk }}" data-bs-dismiss="modal">
|
||||
<h6 class="mb-1">
|
||||
{{ object }}
|
||||
{% if object.status %}{% badge object.get_status_display bg_color=object.get_status_color %}{% endif %}
|
||||
</h6>
|
||||
{% if object.description %}
|
||||
<small>{{ object.description }}</small>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
@ -1,5 +1,5 @@
|
||||
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-dialog{% if size %} modal-{{ size }}{% endif %}">
|
||||
<div class="modal-content" id="htmx-modal-content">
|
||||
{# Dynamic content goes here #}
|
||||
</div>
|
||||
|
@ -1,48 +0,0 @@
|
||||
{% extends 'generic/object_edit.html' %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block form %}
|
||||
<div class="field-group my-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">FHRP Group</h5>
|
||||
</div>
|
||||
{% render_field form.protocol %}
|
||||
{% render_field form.group_id %}
|
||||
{% render_field form.name %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Authentication</h5>
|
||||
</div>
|
||||
{% render_field form.auth_type %}
|
||||
{% render_field form.auth_key %}
|
||||
</div>
|
||||
|
||||
{% if not form.instance.pk %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Virtual IP Address</h5>
|
||||
</div>
|
||||
{% render_field form.ip_vrf %}
|
||||
{% render_field form.ip_address %}
|
||||
{% render_field form.ip_status %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Comments</h5>
|
||||
</div>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="row mb-2">
|
||||
<h5 class="offset-sm-3">Custom Fields</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -121,14 +121,9 @@
|
||||
</div>
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel">
|
||||
{% render_field form.nat_region %}
|
||||
{% render_field form.nat_site_group %}
|
||||
{% render_field form.nat_site %}
|
||||
{% render_field form.nat_rack %}
|
||||
{% render_field form.nat_device %}
|
||||
</div>
|
||||
<div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
|
||||
{% render_field form.nat_cluster %}
|
||||
{% render_field form.nat_virtual_machine %}
|
||||
</div>
|
||||
<div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user