9608 fix merge

This commit is contained in:
Arthur 2023-03-15 11:42:18 -07:00
commit 7c5aeab347
124 changed files with 1360 additions and 1260 deletions

View File

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

View File

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

View File

@ -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 Available as open source software under the Apache 2.0 license, NetBox serves
as the cornerstone for network automation in thousands of organizations. 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. * **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. * **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
* **Organization:** Manage tenant and contact assignments natively. * **Organization:** Manage tenant and contact assignments natively.
* **Powerful search:** Easily find anything you need using a single global search function. * **Powerful search:** Easily find anything you need using a single global search function.

View File

@ -2,6 +2,10 @@
# https://github.com/mozilla/bleach # https://github.com/mozilla/bleach
bleach<6.0 bleach<6.0
# Python client for Amazon AWS API
# https://github.com/boto/boto3
boto3
# The Python web framework on which NetBox is built # The Python web framework on which NetBox is built
# https://github.com/django/django # https://github.com/django/django
Django<4.2 Django<4.2
@ -66,6 +70,10 @@ djangorestframework
# https://github.com/tfranzel/drf-spectacular # https://github.com/tfranzel/drf-spectacular
drf-spectacular drf-spectacular
# RSS feed parser
# https://github.com/kurtmckee/feedparser
feedparser
# Django wrapper for Graphene (GraphQL support) # Django wrapper for Graphene (GraphQL support)
# https://github.com/graphql-python/graphene-django # https://github.com/graphql-python/graphene-django
graphene_django graphene_django

View File

@ -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> <VirtualHost *:443>
ProxyPreserveHost On ProxyPreserveHost On

View File

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

View File

@ -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: Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
```no-highlight ```no-highlight
sudo a2enmod ssl proxy proxy_http headers sudo a2enmod ssl proxy proxy_http headers rewrite
sudo a2ensite netbox sudo a2ensite netbox
sudo systemctl restart apache2 sudo systemctl restart apache2
``` ```

View File

@ -14,15 +14,17 @@ The type of data source. Supported options include:
* Local directory * Local directory
* git repository * git repository
* Amazon S3 bucket
### URL ### URL
The URL identifying the remote source. Some examples are included below. The URL identifying the remote source. Some examples are included below.
| Type | Example URL | | Type | Example URL |
|------|-------------| |-----------|----------------------------------------------------|
| Local | file:///var/my/data/source/ | | Local | file:///path/to/my/data/ |
| git | https://https://github.com/my-organization/my-repo | | git | https://github.com/my-organization/my-repo |
| Amazon S3 | https://s3.us-east-2.amazonaws.com/my-bucket-name/ |
### Status ### Status

View File

@ -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. 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 ### Minimum Value
For numeric custom fields only. The minimum valid value (optional). For numeric custom fields only. The minimum valid value (optional).

View File

@ -1,18 +1,46 @@
# NetBox v3.4 # 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 ### Enhancements
* [#11110](https://github.com/netbox-community/netbox/issues/11110) - Add `start_address` and `end_address` filters for IP ranges * [#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 * [#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 * [#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 ### Bug Fixes
* [#11032](https://github.com/netbox-community/netbox/issues/11032) - Fix false custom validation errors during component creation * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 * [#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) * [#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 * [#11786](https://github.com/netbox-community/netbox/issues/11786) - List only applicable object types in form widget when filtering custom fields
--- ---

View File

@ -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)) #### 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)) #### 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 * [#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 * [#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 * [#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 * [#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 * [#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) * [#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 * [#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 * [#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 * [#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 * [#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 * [#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 ### Other Changes

View File

@ -34,7 +34,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label=_('Comments') label=_('Comments')
) )
@ -62,7 +61,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label=_('Comments') label=_('Comments')
) )
@ -123,7 +121,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label=_('Comments') label=_('Comments')
) )

View File

@ -1,7 +1,7 @@
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from circuits.models import * from circuits.models import *
from dcim.models import Region, Site, SiteGroup from dcim.models import Site
from ipam.models import ASN from ipam.models import ASN
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
@ -114,50 +114,22 @@ class CircuitTerminationForm(NetBoxModelForm):
'provider_id': '$provider', '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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), 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, required=False,
label='Provider', selector=True
initial_params={
'networks': 'provider_network'
}
) )
provider_network = DynamicModelChoiceField( provider_network = DynamicModelChoiceField(
queryset=ProviderNetwork.objects.all(), queryset=ProviderNetwork.objects.all(),
query_params={ required=False,
'provider_id': '$provider_network_provider', selector=True
},
required=False
) )
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'provider', 'circuit', 'term_side', 'region', 'site_group', 'site', 'provider_network_provider', 'provider', 'circuit', 'term_side', 'site', 'provider_network', 'mark_connected', 'port_speed',
'provider_network', 'mark_connected', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info', 'upstream_speed', 'xconnect_id', 'pp_info', 'description', 'tags',
'description', 'tags',
] ]
widgets = { widgets = {
'port_speed': SelectSpeedWidget(), 'port_speed': SelectSpeedWidget(),

View File

@ -10,10 +10,12 @@ from utilities.choices import ChoiceSet
class DataSourceTypeChoices(ChoiceSet): class DataSourceTypeChoices(ChoiceSet):
LOCAL = 'local' LOCAL = 'local'
GIT = 'git' GIT = 'git'
AMAZON_S3 = 'amazon-s3'
CHOICES = ( CHOICES = (
(LOCAL, _('Local'), 'gray'), (LOCAL, _('Local'), 'gray'),
(GIT, _('Git'), 'blue'), (GIT, _('Git'), 'blue'),
(AMAZON_S3, _('Amazon S3'), 'blue'),
) )

View File

@ -1,9 +1,14 @@
import logging import logging
import os
import re
import subprocess import subprocess
import tempfile import tempfile
from contextlib import contextmanager from contextlib import contextmanager
from pathlib import Path
from urllib.parse import quote, urlunparse, urlparse from urllib.parse import quote, urlunparse, urlparse
import boto3
from botocore.config import Config as Boto3Config
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -115,3 +120,70 @@ class GitBackend(DataBackend):
yield local_path.name yield local_path.name
local_path.cleanup() 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 ''

View File

@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE160 = 'cisco-stackwise-160' TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320' TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480' TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
TYPE_JUNIPER_VCP = 'juniper-vcp' TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE160, 'Cisco StackWise-160'), (TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'), (TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'), (TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'), (TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af' TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at' TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_2_8023AZ = 'type2-ieee802.3az'
TYPE_3_8023BT = 'type3-ieee802.3bt' TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt' TYPE_4_8023BT = 'type4-ieee802.3bt'
@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet):
( (
(TYPE_1_8023AF, '802.3af (Type 1)'), (TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'), (TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_2_8023AZ, '802.3az (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'), (TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'), (TYPE_4_8023BT, '802.3bt (Type 4)'),
) )

View File

@ -1004,7 +1004,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
Q(serial__icontains=value.strip()) | Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) | Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__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() ).distinct()
def _has_primary_ip(self, queryset, name, value): def _has_primary_ip(self, queryset, name, value):
@ -1748,6 +1750,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class CableTerminationFilterSet(BaseFilterSet): class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()
class Meta: class Meta:
model = CableTermination model = CableTermination

View File

@ -137,7 +137,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -302,7 +301,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -337,7 +335,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -400,7 +397,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -434,7 +430,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -549,7 +544,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -592,7 +586,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -639,7 +632,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -663,7 +655,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -709,7 +700,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -767,7 +757,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label=_('Comments') label=_('Comments')
) )

View File

@ -469,11 +469,14 @@ class DeviceImportForm(BaseDeviceImportForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params) self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
self.fields['parent'].queryset = self.fields['parent'].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 = { params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'), 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) self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
# Limit device bay queryset by parent device # Limit device bay queryset by parent device

View File

@ -15,68 +15,16 @@ def get_cable_form(a_type, b_type):
for cable_end, term_cls in (('a', a_type), ('b', 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 # Device component
if hasattr(term_cls, 'device'): 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( attrs[f'termination_{cable_end}_device'] = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
label=_('Device'), label=_('Device'),
required=False, required=False,
selector=True,
initial_params={ initial_params={
f'{term_cls._meta.model_name}s__in': f'${cable_end}_terminations' 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( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
@ -96,12 +44,9 @@ def get_cable_form(a_type, b_type):
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
label=_('Power Panel'), label=_('Power Panel'),
required=False, required=False,
selector=True,
initial_params={ initial_params={
'powerfeeds__in': f'${cable_end}_terminations' '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( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(
@ -116,23 +61,12 @@ def get_cable_form(a_type, b_type):
# CircuitTermination # CircuitTermination
elif term_cls == 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( attrs[f'termination_{cable_end}_circuit'] = DynamicModelChoiceField(
queryset=Circuit.objects.all(), queryset=Circuit.objects.all(),
label=_('Circuit'), label=_('Circuit'),
selector=True,
initial_params={ initial_params={
'terminations__in': f'${cable_end}_terminations' '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( attrs[f'{cable_end}_terminations'] = DynamicModelMultipleChoiceField(

View File

@ -14,9 +14,9 @@ from tenancy.forms import TenancyForm
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField, APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, 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 wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm, ModuleCommonForm from .common import InterfaceCommonForm, ModuleCommonForm
@ -157,26 +157,9 @@ class SiteForm(TenancyForm, NetBoxModelForm):
class LocationForm(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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
query_params={ selector=True
'region_id': '$region',
'group_id': '$site_group',
}
) )
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
@ -188,17 +171,14 @@ class LocationForm(TenancyForm, NetBoxModelForm):
slug = SlugField() slug = SlugField()
fieldsets = ( fieldsets = (
('Location', ( ('Location', ('site', 'parent', 'name', 'slug', 'status', 'description', 'tags')),
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tags',
)),
('Tenancy', ('tenant_group', 'tenant')), ('Tenancy', ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
model = Location model = Location
fields = ( fields = (
'region', 'site_group', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'site', 'parent', 'name', 'slug', 'status', 'description', 'tenant_group', 'tenant', 'tags',
'tags',
) )
@ -219,26 +199,9 @@ class RackRoleForm(NetBoxModelForm):
class RackForm(TenancyForm, 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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
query_params={ selector=True
'region_id': '$region',
'group_id': '$site_group',
}
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
@ -256,48 +219,16 @@ class RackForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = Rack model = Rack
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
] ]
class RackReservationForm(TenancyForm, NetBoxModelForm): 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( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
query_params={ selector=True
'site_id': '$site',
'location_id': '$location',
}
) )
units = NumericArrayField( units = NumericArrayField(
base_field=forms.IntegerField(), base_field=forms.IntegerField(),
@ -311,15 +242,14 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Reservation', ('region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'description', 'tags')), ('Reservation', ('rack', 'units', 'user', 'description', 'tags')),
('Tenancy', ('tenant_group', 'tenant')), ('Tenancy', ('tenant_group', 'tenant')),
) )
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'rack', 'units', 'user', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
'description', 'comments', 'tags',
] ]
@ -441,26 +371,9 @@ class PlatformForm(NetBoxModelForm):
class DeviceForm(TenancyForm, 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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
query_params={ selector=True
'region_id': '$region',
'group_id': '$site_group',
}
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
queryset=Location.objects.all(), 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( device_type = DynamicModelChoiceField(
queryset=DeviceType.objects.all(), queryset=DeviceType.objects.all(),
query_params={ selector=True
'manufacturer_id': '$manufacturer'
}
) )
device_role = DynamicModelChoiceField( device_role = DynamicModelChoiceField(
queryset=DeviceRole.objects.all() queryset=DeviceRole.objects.all()
) )
platform = DynamicModelChoiceField( platform = DynamicModelChoiceField(
queryset=Platform.objects.all(), queryset=Platform.objects.all(),
required=False, required=False
query_params={
'manufacturer_id': ['$manufacturer', 'null']
}
)
cluster_group = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
null_option='None',
initial_params={
'clusters': '$cluster'
}
) )
cluster = DynamicModelChoiceField( cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),
required=False, required=False,
query_params={ selector=True
'group_id': '$cluster_group'
}
) )
comments = CommentField() comments = CommentField()
local_context_data = JSONField( local_context_data = JSONField(
@ -536,7 +427,8 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
) )
virtual_chassis = DynamicModelChoiceField( virtual_chassis = DynamicModelChoiceField(
queryset=VirtualChassis.objects.all(), queryset=VirtualChassis.objects.all(),
required=False required=False,
selector=True
) )
vc_position = forms.IntegerField( vc_position = forms.IntegerField(
required=False, required=False,
@ -556,10 +448,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = Device model = Device
fields = [ fields = [
'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'rack', 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face',
'location', 'position', 'face', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant',
'cluster_group', 'cluster', 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags',
'description', 'config_template', 'comments', 'tags', 'local_context_data' 'local_context_data'
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -632,18 +524,9 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
'device_id': '$device' 'device_id': '$device'
} }
) )
manufacturer = DynamicModelChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
initial_params={
'module_types': '$module_type'
}
)
module_type = DynamicModelChoiceField( module_type = DynamicModelChoiceField(
queryset=ModuleType.objects.all(), queryset=ModuleType.objects.all(),
query_params={ selector=True
'manufacturer_id': '$manufacturer'
}
) )
comments = CommentField() comments = CommentField()
replicate_components = forms.BooleanField( replicate_components = forms.BooleanField(
@ -651,7 +534,6 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
initial=True, initial=True,
help_text=_("Automatically populate components associated with this module type") help_text=_("Automatically populate components associated with this module type")
) )
adopt_components = forms.BooleanField( adopt_components = forms.BooleanField(
required=False, required=False,
initial=False, initial=False,
@ -659,9 +541,7 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('Module', ( ('Module', ('device', 'module_bay', 'module_type', 'status', 'description', 'tags')),
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'description', 'tags',
)),
('Hardware', ( ('Hardware', (
'serial', 'asset_tag', 'replicate_components', 'adopt_components', 'serial', 'asset_tag', 'replicate_components', 'adopt_components',
)), )),
@ -670,8 +550,8 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
class Meta: class Meta:
model = Module model = Module
fields = [ fields = [
'device', 'module_bay', 'manufacturer', 'module_type', 'status', 'serial', 'asset_tag', 'tags', 'device', 'module_bay', 'module_type', 'status', 'serial', 'asset_tag', 'tags', 'replicate_components',
'replicate_components', 'adopt_components', 'description', 'comments', 'adopt_components', 'description', 'comments',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -702,26 +582,9 @@ class CableForm(TenancyForm, NetBoxModelForm):
class PowerPanelForm(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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
query_params={ selector=True
'region_id': '$region',
'group_id': '$site_group',
}
) )
location = DynamicModelChoiceField( location = DynamicModelChoiceField(
queryset=Location.objects.all(), queryset=Location.objects.all(),
@ -733,80 +596,38 @@ class PowerPanelForm(NetBoxModelForm):
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Power Panel', ('region', 'site_group', 'site', 'location', 'name', 'description', 'tags')), ('Power Panel', ('site', 'location', 'name', 'description', 'tags')),
) )
class Meta: class Meta:
model = PowerPanel model = PowerPanel
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'name', 'description', 'comments', 'tags', 'site', 'location', 'name', 'description', 'comments', 'tags',
] ]
class PowerFeedForm(NetBoxModelForm): 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( power_panel = DynamicModelChoiceField(
queryset=PowerPanel.objects.all(), queryset=PowerPanel.objects.all(),
query_params={ selector=True
'site_id': '$site'
}
)
location = DynamicModelChoiceField(
queryset=Location.objects.all(),
required=False,
query_params={
'site_id': '$site'
},
initial_params={
'racks': '$rack'
}
) )
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
query_params={ selector=True
'location_id': '$location',
'site_id': '$site'
}
) )
comments = CommentField() comments = CommentField()
fieldsets = ( fieldsets = (
('Power Panel', ('region', 'site', 'power_panel')), ('Power Feed', ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')), ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
) )
class Meta: class Meta:
model = PowerFeed model = PowerFeed
fields = [ fields = [
'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'mark_connected', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'max_utilization', 'description', 'comments', 'tags',
'tags',
] ]
@ -878,43 +699,12 @@ class DeviceVCMembershipForm(forms.ModelForm):
class VCMemberSelectForm(BootstrapMixin, forms.Form): 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( device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
query_params={ query_params={
'site_id': '$site',
'rack_id': '$rack',
'virtual_chassis_id': 'null', 'virtual_chassis_id': 'null',
} },
selector=True
) )
def clean_device(self): def clean_device(self):
@ -1150,7 +940,8 @@ class InventoryItemTemplateForm(ComponentTemplateForm):
class DeviceComponentForm(NetBoxModelForm): class DeviceComponentForm(NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
queryset=Device.objects.all() queryset=Device.objects.all(),
selector=True
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -1592,53 +1383,9 @@ class InventoryItemRoleForm(NetBoxModelForm):
class VirtualDeviceContextForm(TenancyForm, 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( device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
query_params={ selector=True
'site_id': '$site',
'location_id': '$location',
'rack_id': '$rack',
}
) )
primary_ip4 = DynamicModelChoiceField( primary_ip4 = DynamicModelChoiceField(
queryset=IPAddress.objects.all(), queryset=IPAddress.objects.all(),
@ -1660,14 +1407,13 @@ class VirtualDeviceContextForm(TenancyForm, NetBoxModelForm):
) )
fieldsets = ( fieldsets = (
('Assigned Device', ('region', 'site_group', 'site', 'location', 'rack', 'device')), ('Virtual Device Context', ('device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
('Virtual Device Context', ('name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tags')),
('Tenancy', ('tenant_group', 'tenant')) ('Tenancy', ('tenant_group', 'tenant'))
) )
class Meta: class Meta:
model = VirtualDeviceContext model = VirtualDeviceContext
fields = [ fields = [
'region', 'site_group', 'site', 'location', 'rack', 'device', 'name', 'status', 'identifier', 'device', 'name', 'status', 'identifier', 'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant',
'primary_ip4', 'primary_ip6', 'tenant_group', 'tenant', 'comments', 'tags' 'comments', 'tags'
] ]

View File

@ -10,3 +10,11 @@ class CabledObjectMixin:
def resolve_link_peers(self, info): def resolve_link_peers(self, info):
return self.link_peers 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

View File

@ -7,7 +7,7 @@ from extras.graphql.mixins import (
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin from .mixins import CabledObjectMixin, PathEndpointMixin
__all__ = ( __all__ = (
'CableType', 'CableType',
@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
filterset_class = filtersets.CableTerminationFilterSet filterset_class = filtersets.CableTerminationFilterSet
class ConsolePortType(ComponentObjectType, CabledObjectMixin): class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.ConsolePort model = models.ConsolePort
@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None return self.type or None
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin): class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.ConsoleServerPort model = models.ConsoleServerPort
@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin): class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.Interface model = models.Interface
@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType, CabledObjectMixin): class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.PowerFeed model = models.PowerFeed
@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
filterset_class = filtersets.PowerFeedFilterSet filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType, CabledObjectMixin): class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.PowerOutlet model = models.PowerOutlet
@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
filterset_class = filtersets.PowerPanelFilterSet filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType, CabledObjectMixin): class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta: class Meta:
model = models.PowerPort model = models.PowerPort

View File

@ -600,6 +600,7 @@ class DeviceInterfaceTable(InterfaceTable):
'class': get_interface_row_class, 'class': get_interface_row_class,
'data-name': lambda record: record.name, 'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute, 'data-enabled': get_interface_state_attribute,
'data-type': lambda record: record.type,
} }

View File

@ -99,8 +99,9 @@ class CustomFieldSerializer(ValidatedModelSerializer):
model = CustomField model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name', 'id', 'url', 'display', 'content_types', 'type', 'object_type', 'data_type', 'name', 'label', 'group_name',
'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'default', 'weight', 'description', 'required', 'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'default',
'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created', 'last_updated', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
'last_updated',
] ]
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)

View File

@ -48,8 +48,12 @@ DEFAULT_DASHBOARD = [
} }
}, },
{ {
'widget': 'extras.ChangeLogWidget', 'widget': 'extras.ObjectListWidget',
'width': 12, 'width': 12,
'height': 6, 'height': 6,
'title': 'Change Log',
'config': {
'model': 'extras.objectchange',
}
}, },
] ]

View File

@ -1,20 +1,25 @@
import uuid import uuid
from functools import cached_property
from hashlib import sha256
import feedparser
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.cache import cache
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from utilities.forms import BootstrapMixin from utilities.forms import BootstrapMixin
from utilities.templatetags.builtins.filters import render_markdown 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 from .utils import register_widget
__all__ = ( __all__ = (
'ChangeLogWidget',
'DashboardWidget', 'DashboardWidget',
'NoteWidget', 'NoteWidget',
'ObjectCountsWidget', 'ObjectCountsWidget',
'ObjectListWidget',
'RSSFeedWidget',
) )
@ -27,6 +32,7 @@ def get_content_type_labels():
class DashboardWidget: class DashboardWidget:
default_title = None default_title = None
default_config = {}
description = None description = None
width = 4 width = 4
height = 3 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): 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.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.title = title or self.default_title
self.color = color self.color = color
if width: if width:
@ -72,6 +78,7 @@ class DashboardWidget:
@register_widget @register_widget
class NoteWidget(DashboardWidget): class NoteWidget(DashboardWidget):
default_title = _('Note')
description = _('Display some arbitrary custom content. Markdown is supported.') description = _('Display some arbitrary custom content. Markdown is supported.')
class ConfigForm(BootstrapMixin, forms.Form): class ConfigForm(BootstrapMixin, forms.Form):
@ -85,7 +92,7 @@ class NoteWidget(DashboardWidget):
@register_widget @register_widget
class ObjectCountsWidget(DashboardWidget): 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.') description = _('Display a set of NetBox models and the number of objects created for each type.')
template_name = 'extras/dashboard/widgets/objectcounts.html' template_name = 'extras/dashboard/widgets/objectcounts.html'
@ -108,12 +115,79 @@ class ObjectCountsWidget(DashboardWidget):
@register_widget @register_widget
class ChangeLogWidget(DashboardWidget): class ObjectListWidget(DashboardWidget):
default_title = _('Change Log') default_title = _('Object List')
description = _('Display the most recent records from the global change log.') description = _('Display an arbitrary list of objects.')
template_name = 'extras/dashboard/widgets/changelog.html' template_name = 'extras/dashboard/widgets/objectlist.html'
width = 12 width = 12
height = 4 height = 4
class ConfigForm(BootstrapMixin, forms.Form):
model = forms.ChoiceField(
choices=get_content_type_labels
)
def render(self, request): 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

View File

@ -78,7 +78,7 @@ class CustomFieldFilterSet(BaseFilterSet):
model = CustomField model = CustomField
fields = [ fields = [
'id', 'content_types', 'name', 'group_name', 'required', 'search_weight', 'filter_logic', 'ui_visibility', '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): def search(self, queryset, name, value):
@ -220,6 +220,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
class JournalEntryFilterSet(NetBoxModelFilterSet): class JournalEntryFilterSet(NetBoxModelFilterSet):
created = django_filters.DateTimeFromToRangeFilter() created = django_filters.DateTimeFromToRangeFilter()
assigned_object_type = ContentTypeFilter() assigned_object_type = ContentTypeFilter()
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
created_by_id = django_filters.ModelMultipleChoiceFilter( created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
@ -504,6 +507,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
) )
time = django_filters.DateTimeFromToRangeFilter() time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter() changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),

View File

@ -2,6 +2,7 @@ from .model_forms import *
from .filtersets import * from .filtersets import *
from .bulk_edit import * from .bulk_edit import *
from .bulk_import import * from .bulk_import import *
from .misc import *
from .mixins import * from .mixins import *
from .config import * from .config import *
from .scripts import * from .scripts import *

View File

@ -44,6 +44,10 @@ class CustomFieldBulkEditForm(BulkEditForm):
required=False, required=False,
initial='' initial=''
) )
is_cloneable = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('group_name', 'description',) nullable_fields = ('group_name', 'description',)

View File

@ -51,7 +51,7 @@ class CustomFieldImportForm(CSVModelForm):
fields = ( fields = (
'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description', 'name', 'label', 'group_name', 'type', 'content_types', 'object_type', 'required', 'description',
'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum', 'search_weight', 'filter_logic', 'default', 'choices', 'weight', 'validation_minimum', 'validation_maximum',
'validation_regex', 'ui_visibility', 'validation_regex', 'ui_visibility', 'is_cloneable',
) )

View File

@ -37,7 +37,9 @@ __all__ = (
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm): class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = ( fieldsets = (
(None, ('q', 'filter_id')), (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( content_type_id = ContentTypeMultipleChoiceField(
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
@ -66,6 +68,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
required=False, required=False,
label=_('UI visibility') label=_('UI visibility')
) )
is_cloneable = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
class JobResultFilterForm(SavedFiltersMixin, FilterForm): class JobResultFilterForm(SavedFiltersMixin, FilterForm):

View 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
)

View File

@ -47,7 +47,7 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
('Custom Field', ( ('Custom Field', (
'content_types', 'name', 'label', 'group_name', 'type', 'object_type', 'required', 'description', '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')), ('Values', ('default', 'choices')),
('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')), ('Validation', ('validation_minimum', 'validation_maximum', 'validation_regex')),
) )

View File

@ -15,6 +15,11 @@ class Command(BaseCommand):
nargs='*', nargs='*',
help='One or more apps or models to reindex', 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): def _get_indexers(self, *model_names):
indexers = {} indexers = {}
@ -60,7 +65,8 @@ class Command(BaseCommand):
raise CommandError("No indexers found!") raise CommandError("No indexers found!")
self.stdout.write(f'Reindexing {len(indexers)} models.') 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.write('Clearing cached values... ', ending='')
self.stdout.flush() self.stdout.flush()
content_types = [ content_types = [
@ -76,11 +82,18 @@ class Command(BaseCommand):
model_name = model._meta.model_name model_name = model._meta.model_name
self.stdout.write(f' {app_label}.{model_name}... ', ending='') self.stdout.write(f' {app_label}.{model_name}... ', ending='')
self.stdout.flush() 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) i = search_backend.cache(model.objects.iterator(), remove_existing=False)
if i: if i:
self.stdout.write(f'{i} entries cached.') self.stdout.write(f'{i} entries cached.')
else: else:
self.stdout.write(f'None found.') self.stdout.write(f'No objects found.')
msg = f'Completed.' msg = f'Completed.'
if total_count := search_backend.size: if total_count := search_backend.size:

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

View File

@ -163,13 +163,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
verbose_name='UI visibility', verbose_name='UI visibility',
help_text=_('Specifies the visibility of custom field in the UI') 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() objects = CustomFieldManager()
clone_fields = ( clone_fields = (
'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight', 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'search_weight',
'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'filter_logic', 'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'ui_visibility', 'ui_visibility', 'is_cloneable',
) )
class Meta: class Meta:

View File

@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
def _load_resource(self, name): def _load_resource(self, name):
# Import from the configured path, if defined. # Import from the configured path, if defined.
if getattr(self, name): if path := getattr(self, name, None):
return import_string(f"{self.__module__}.{self.name}") return import_string(f"{self.__module__}.{path}")
# Fall back to the resource's default path. Return None if the module has not been provided. # 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]}' default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'

View File

@ -1,5 +1,6 @@
from netbox.navigation import MenuGroup from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
__all__ = ( __all__ = (
'PluginMenu', 'PluginMenu',
@ -21,7 +22,7 @@ class PluginMenu:
@property @property
def name(self): def name(self):
return self.label.replace(' ', '_') return slugify(self.label)
class PluginMenuItem: class PluginMenuItem:

View File

@ -29,12 +29,14 @@ class CustomFieldTable(NetBoxTable):
content_types = columns.ContentTypesColumn() content_types = columns.ContentTypesColumn()
required = columns.BooleanColumn() required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
is_cloneable = columns.BooleanColumn()
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = CustomField model = CustomField
fields = ( fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description', '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') default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')

View File

@ -548,7 +548,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_assigned_object_type(self): def test_assigned_object_type(self):
params = {'assigned_object_type': 'dcim.site'} params = {'assigned_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) 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) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_assigned_object(self): def test_assigned_object(self):
@ -922,7 +922,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
def test_changed_object_type(self): def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'} params = {'changed_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
def test_changed_object_type_id(self):
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@ -107,4 +107,6 @@ urlpatterns = [
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'), 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'), 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")
] ]

View File

@ -2,7 +2,7 @@ from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q 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.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View from django.views.generic import View
@ -14,6 +14,7 @@ from extras.dashboard.utils import get_widget_class
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import is_htmx 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.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -685,7 +686,7 @@ class DashboardWidgetAddView(LoginRequiredMixin, View):
widget_form = DashboardWidgetAddForm(initial=initial) widget_form = DashboardWidgetAddForm(initial=initial)
widget_name = get_field_value(widget_form, 'widget_class') widget_name = get_field_value(widget_form, 'widget_class')
widget_class = get_widget_class(widget_name) 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, { return render(request, self.template_name, {
'widget_class': widget_class, 'widget_class': widget_class,
@ -1076,3 +1077,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
queryset = JobResult.objects.all() queryset = JobResult.objects.all()
filterset = filtersets.JobResultFilterSet filterset = filtersets.JobResultFilterSet
table = tables.JobResultTable 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)

View File

@ -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. # This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import secrets import secrets

View File

@ -18,6 +18,7 @@ from virtualization.models import VirtualMachine, VMInterface
from .choices import * from .choices import *
from .models import * from .models import *
from rest_framework import serializers
__all__ = ( __all__ = (
'AggregateFilterSet', 'AggregateFilterSet',
@ -625,7 +626,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.none() return queryset.none()
return queryset.filter(q) 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): 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: try:
return queryset.filter(address__net_in=value) return queryset.filter(address__net_in=value)
except ValidationError: except ValidationError:

View File

@ -49,7 +49,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -70,7 +69,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -139,7 +137,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -168,7 +165,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -249,7 +245,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -292,7 +287,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -338,7 +332,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -381,7 +374,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -463,7 +455,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -494,7 +485,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )
@ -523,7 +513,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
required=False required=False
) )
comments = CommentField( comments = CommentField(
widget=forms.Textarea,
label='Comments' label='Comments'
) )

View File

@ -200,40 +200,11 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
required=False, required=False,
label=_('VRF') 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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
null_option='None', selector=True,
query_params={ null_option='None'
'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'
}
) )
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
@ -241,7 +212,6 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
label=_('VLAN'), label=_('VLAN'),
query_params={ query_params={
'site_id': '$site', 'site_id': '$site',
'group_id': '$vlan_group',
} }
) )
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
@ -252,7 +222,7 @@ class PrefixForm(TenancyForm, NetBoxModelForm):
fieldsets = ( fieldsets = (
('Prefix', ('prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags')), ('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')), ('Tenancy', ('tenant_group', 'tenant')),
) )
@ -329,65 +299,22 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
required=False, required=False,
label=_('VRF') 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( nat_device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False, required=False,
label=_('Device'), selector=True,
query_params={ label=_('Device')
'site_id': '$site',
'rack_id': '$nat_rack',
}
)
nat_cluster = DynamicModelChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
) )
nat_virtual_machine = DynamicModelChoiceField( nat_virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False, required=False,
label=_('Virtual Machine'), selector=True,
query_params={ label=_('Virtual Machine')
'cluster_id': '$nat_cluster',
}
) )
nat_vrf = DynamicModelChoiceField( nat_vrf = DynamicModelChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
required=False, required=False,
selector=True,
label=_('VRF') label=_('VRF')
) )
nat_inside = DynamicModelChoiceField( nat_inside = DynamicModelChoiceField(
@ -409,9 +336,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
class Meta: class Meta:
model = IPAddress model = IPAddress
fields = [ fields = [
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_site', 'nat_rack', 'nat_device', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine',
'nat_cluster', 'nat_virtual_machine', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
'comments', 'tags',
] ]
def __init__(self, *args, **kwargs): 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), role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance assigned_object=instance
) )
ipaddress.populate_custom_field_defaults()
ipaddress.save() ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions # Check that the new IPAddress conforms with any assigned object-level permissions
@ -713,58 +640,18 @@ class VLANGroupForm(NetBoxModelForm):
class VLANForm(TenancyForm, 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( group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
required=False, required=False,
query_params={ selector=True,
'scope_type': '$scope_type',
},
label=_('VLAN Group') 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( site = DynamicModelChoiceField(
queryset=Site.objects.all(), queryset=Site.objects.all(),
required=False, required=False,
null_option='None', null_option='None',
query_params={ selector=True
'region_id': '$region',
'group_id': '$sitegroup',
}
) )
# Other fields
role = DynamicModelChoiceField( role = DynamicModelChoiceField(
queryset=Role.objects.all(), queryset=Role.objects.all(),
required=False required=False
@ -803,11 +690,13 @@ class ServiceTemplateForm(NetBoxModelForm):
class ServiceForm(NetBoxModelForm): class ServiceForm(NetBoxModelForm):
device = DynamicModelChoiceField( device = DynamicModelChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
required=False required=False,
selector=True
) )
virtual_machine = DynamicModelChoiceField( virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(), queryset=VirtualMachine.objects.all(),
required=False required=False,
selector=True
) )
ports = NumericArrayField( ports = NumericArrayField(
base_field=forms.IntegerField( base_field=forms.IntegerField(
@ -907,43 +796,21 @@ class L2VPNTerminationForm(NetBoxModelForm):
label=_('L2VPN'), label=_('L2VPN'),
fetch_trigger='open' fetch_trigger='open'
) )
device_vlan = DynamicModelChoiceField(
queryset=Device.objects.all(),
label=_("Available on Device"),
required=False,
query_params={}
)
vlan = DynamicModelChoiceField( vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(), queryset=VLAN.objects.all(),
required=False, required=False,
query_params={ selector=True,
'available_on_device': '$device_vlan'
},
label=_('VLAN') label=_('VLAN')
) )
device = DynamicModelChoiceField(
queryset=Device.objects.all(),
required=False,
query_params={}
)
interface = DynamicModelChoiceField( interface = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
query_params={ selector=True
'device_id': '$device'
}
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
query_params={}
) )
vminterface = DynamicModelChoiceField( vminterface = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
query_params={ selector=True,
'virtual_machine_id': '$virtual_machine'
},
label=_('Interface') label=_('Interface')
) )
@ -957,7 +824,6 @@ class L2VPNTerminationForm(NetBoxModelForm):
if instance: if instance:
if type(instance.assigned_object) is Interface: if type(instance.assigned_object) is Interface:
initial['device'] = instance.assigned_object.parent
initial['interface'] = instance.assigned_object initial['interface'] = instance.assigned_object
elif type(instance.assigned_object) is VLAN: elif type(instance.assigned_object) is VLAN:
initial['vlan'] = instance.assigned_object initial['vlan'] = instance.assigned_object

View File

@ -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): class ASNType(NetBoxObjectType):
asn = graphene.Field(BigInt) asn = graphene.Field(BigInt)
@ -44,7 +66,7 @@ class ASNRangeType(NetBoxObjectType):
filterset_class = filtersets.ASNRangeFilterSet filterset_class = filtersets.ASNRangeFilterSet
class AggregateType(NetBoxObjectType): class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
class Meta: class Meta:
model = models.Aggregate model = models.Aggregate
@ -72,7 +94,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet filterset_class = filtersets.FHRPGroupAssignmentFilterSet
class IPAddressType(NetBoxObjectType): class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType') assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
class Meta: class Meta:
@ -95,7 +117,7 @@ class IPRangeType(NetBoxObjectType):
return self.role or None return self.role or None
class PrefixType(NetBoxObjectType): class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
class Meta: class Meta:
model = models.Prefix model = models.Prefix

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

View File

@ -12,7 +12,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('tenancy', '0009_standardize_description_comments'), ('tenancy', '0009_standardize_description_comments'),
('extras', '0087_dashboard'), ('extras', '0087_dashboard'),
('ipam', '0063_standardize_description_comments'), ('ipam', '0064_clear_search_cache'),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('ipam', '0064_asnrange'), ('ipam', '0065_asnrange'),
] ]
operations = [ operations = [

View File

@ -10,6 +10,7 @@ from ipam.models import *
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from rest_framework import serializers
class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests): class ASNRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -927,6 +928,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']} params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): def test_mask_length(self):
params = {'mask_length': '24'} params = {'mask_length': '24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)

View File

@ -990,7 +990,6 @@ class FHRPGroupView(generic.ObjectView):
class FHRPGroupEditView(generic.ObjectEditView): class FHRPGroupEditView(generic.ObjectEditView):
queryset = FHRPGroup.objects.all() queryset = FHRPGroup.objects.all()
form = forms.FHRPGroupForm form = forms.FHRPGroupForm
template_name = 'ipam/fhrpgroup_edit.html'
def get_return_url(self, request, obj=None): def get_return_url(self, request, obj=None):
return_url = super().get_return_url(request, obj) return_url = super().get_return_url(request, obj)

View File

@ -107,6 +107,9 @@ CORS_ORIGIN_REGEX_WHITELIST = [
# r'^(https?://)?(\w+\.)?example\.com$', # 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 # 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 # sensitive information about your installation. Only enable debugging while performing testing. Never enable debugging
# on a production system. # on a production system.
@ -127,6 +130,9 @@ EMAIL = {
'FROM_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 # 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. # by anonymous users. List models in the form `<app>.<model>`. Add '*' to this list to exempt all models.
EXEMPT_VIEW_PERMISSIONS = [ EXEMPT_VIEW_PERMISSIONS = [
@ -168,16 +174,6 @@ LOGOUT_REDIRECT_URL = 'home'
# the default value of this setting is derived from the installed location. # the default value of this setting is derived from the installed location.
# MEDIA_ROOT = '/opt/netbox/netbox/media' # 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' # Expose Prometheus monitoring metrics at the HTTP endpoint '/metrics'
METRICS_ENABLED = False METRICS_ENABLED = False
@ -217,13 +213,6 @@ RQ_DEFAULT_TIMEOUT = 300
# this setting is derived from the installed location. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # 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. # The name to use for the session cookie.
SESSION_COOKIE_NAME = 'sessionid' 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. # database access.) Note that the user as which NetBox runs must have read and write permissions to this path.
SESSION_FILE_PATH = None SESSION_FILE_PATH = None
# Localization # By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
ENABLE_LOCALIZATION = False # 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 (default: UTC)
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'

View File

@ -121,6 +121,12 @@ class CloningMixin(models.Model):
if is_taggable(self): if is_taggable(self):
attrs['tags'] = [tag.pk for tag in self.tags.all()] 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 return attrs
@ -219,6 +225,13 @@ class CustomFieldsMixin(models.Model):
return dict(groups) 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): def clean(self):
super().clean() super().clean()
from extras.models import CustomField from extras.models import CustomField

View File

@ -78,16 +78,16 @@ DEVICES_MENU = Menu(
MenuGroup( MenuGroup(
label=_('Device Components'), label=_('Device Components'),
items=( items=(
get_model_item('dcim', 'interface', _('Interfaces'), actions=['import']), get_model_item('dcim', 'interface', _('Interfaces')),
get_model_item('dcim', 'frontport', _('Front Ports'), actions=['import']), get_model_item('dcim', 'frontport', _('Front Ports')),
get_model_item('dcim', 'rearport', _('Rear Ports'), actions=['import']), get_model_item('dcim', 'rearport', _('Rear Ports')),
get_model_item('dcim', 'consoleport', _('Console Ports'), actions=['import']), get_model_item('dcim', 'consoleport', _('Console Ports')),
get_model_item('dcim', 'consoleserverport', _('Console Server Ports'), actions=['import']), get_model_item('dcim', 'consoleserverport', _('Console Server Ports')),
get_model_item('dcim', 'powerport', _('Power Ports'), actions=['import']), get_model_item('dcim', 'powerport', _('Power Ports')),
get_model_item('dcim', 'poweroutlet', _('Power Outlets'), actions=['import']), get_model_item('dcim', 'poweroutlet', _('Power Outlets')),
get_model_item('dcim', 'modulebay', _('Module Bays'), actions=['import']), get_model_item('dcim', 'modulebay', _('Module Bays')),
get_model_item('dcim', 'devicebay', _('Device Bays'), actions=['import']), get_model_item('dcim', 'devicebay', _('Device Bays')),
get_model_item('dcim', 'inventoryitem', _('Inventory Items'), actions=['import']), get_model_item('dcim', 'inventoryitem', _('Inventory Items')),
get_model_item('dcim', 'inventoryitemrole', _('Inventory Item Roles')), get_model_item('dcim', 'inventoryitemrole', _('Inventory Item Roles')),
), ),
), ),
@ -216,7 +216,7 @@ VIRTUALIZATION_MENU = Menu(
label=_('Virtual Machines'), label=_('Virtual Machines'),
items=( items=(
get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')), get_model_item('virtualization', 'virtualmachine', _('Virtual Machines')),
get_model_item('virtualization', 'vminterface', _('Interfaces'), actions=['import']), get_model_item('virtualization', 'vminterface', _('Interfaces')),
), ),
), ),
MenuGroup( MenuGroup(

View File

@ -24,7 +24,7 @@ PREFERENCES = {
'pagination.per_page': UserPreference( 'pagination.per_page': UserPreference(
label=_('Page length'), label=_('Page length'),
choices=get_page_lengths(), 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) coerce=lambda x: int(x)
), ),
'pagination.placement': UserPreference( 'pagination.placement': UserPreference(

View File

@ -54,11 +54,11 @@ class SearchBackend:
""" """
raise NotImplementedError 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. 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): def removal_handler(self, sender, instance, **kwargs):
""" """
@ -80,7 +80,13 @@ class SearchBackend:
def clear(self, object_types=None): 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 raise NotImplementedError
@ -218,6 +224,12 @@ class CachedValueSearchBackend(SearchBackend):
# Call _raw_delete() on the queryset to avoid first loading instances into memory # Call _raw_delete() on the queryset to avoid first loading instances into memory
return qs._raw_delete(using=qs.db) 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 @property
def size(self): def size(self):
return CachedValue.objects.count() return CachedValue.objects.count()

View File

@ -1,5 +1,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
from urllib.parse import quote
import django_tables2 as tables import django_tables2 as tables
from django.conf import settings from django.conf import settings
@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template from django.template import Context, Template
from django.urls import reverse from django.urls import reverse
from django.utils.dateparse import parse_date from django.utils.dateparse import parse_date
from django.utils.encoding import escape_uri_path
from django.utils.html import escape from django.utils.html import escape
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
model = table.Meta.model model = table.Meta.model
request = getattr(table, 'context', {}).get('request') 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 = '' html = ''
# Compile actions menu # Compile actions menu

View File

@ -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.api.views import APIRootView, StatusView
from netbox.graphql.schema import schema from netbox.graphql.schema import schema
from netbox.graphql.views import GraphQLView 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 users.views import LoginView, LogoutView
from .admin import admin_site from .admin import admin_site
@ -36,6 +36,9 @@ _patterns = [
path('virtualization/', include('virtualization.urls')), path('virtualization/', include('virtualization.urls')),
path('wireless/', include('wireless.urls')), path('wireless/', include('wireless.urls')),
# HTMX views
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),
# API # API
path('api/', APIRootView.as_view(), name='api-root'), path('api/', APIRootView.as_view(), name='api-root'),
path('api/circuits/', include('circuits.api.urls')), path('api/circuits/', include('circuits.api.urls')),

View File

@ -16,10 +16,10 @@ from django_tables2.export import TableExport
from extras.models import ExportTemplate from extras.models import ExportTemplate
from extras.signals import clear_webhooks from extras.signals import clear_webhooks
from utilities.choices import ImportFormatChoices
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation 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.htmx import is_embedded, is_htmx
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin from utilities.views import GetReturnURLMixin

View 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}')

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
import { initReslug } from './reslug'; import { initReslug } from './reslug';
import { initSelectAll } from './selectAll'; import { initSelectAll } from './selectAll';
import { initSelectMultiple } from './selectMultiple'; import { initSelectMultiple } from './selectMultiple';
import { initMarkdownPreviews } from './markdownPreview';
export function initButtons(): void { export function initButtons(): void {
for (const func of [ for (const func of [
@ -13,6 +14,7 @@ export function initButtons(): void {
initSelectAll, initSelectAll,
initSelectMultiple, initSelectMultiple,
initMoveButtons, initMoveButtons,
initMarkdownPreviews,
]) { ]) {
func(); func();
} }

View 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);
}
}

View File

@ -1,9 +1,10 @@
import { getElements, isTruthy } from './util'; import { getElements, isTruthy } from './util';
import { initButtons } from './buttons'; import { initButtons } from './buttons';
import { initSelect } from './select'; import { initSelect } from './select';
import { initObjectSelector } from './objectSelector';
function initDepedencies(): void { function initDepedencies(): void {
for (const init of [initButtons, initSelect]) { for (const init of [initButtons, initSelect, initObjectSelector]) {
init(); init();
} }
} }

View 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));
}
}

View File

@ -1,6 +1,5 @@
import { getElements, replaceAll, findFirstAdjacent } from '../util'; import { getElements, replaceAll, findFirstAdjacent } from '../util';
type InterfaceState = 'enabled' | 'disabled';
type ShowHide = 'show' | 'hide'; type ShowHide = 'show' | 'hide';
function isShowHide(value: unknown): value is ShowHide { function isShowHide(value: unknown): value is ShowHide {
@ -27,54 +26,23 @@ class ButtonState {
* Underlying Button DOM Element * Underlying Button DOM Element
*/ */
public button: HTMLButtonElement; 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.button = button;
this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'); this.rows = rows;
this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
} }
/** /**
* This button's controlled type. For example, a button with the class `toggle-disabled` has * Remove visibility of button state rows.
* directive 'disabled' because it controls the visibility of rows with
* `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
* `data-enabled="enabled"`.
*/ */
private get directive(): InterfaceState { private hideRows(): void {
if (this.button.classList.contains('toggle-disabled')) { for (const row of this.rows) {
return 'disabled'; row.classList.add('d-none');
} 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');
} }
} }
@ -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. * Toggle the DOM element's `data-state` attribute.
*/ */
@ -139,17 +96,20 @@ class ButtonState {
private toggle(): void { private toggle(): void {
this.toggleState(); this.toggleState();
this.toggleButton(); 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 { public handleClick(event: Event): void {
const button = event.currentTarget as HTMLButtonElement; const button = event.currentTarget as HTMLButtonElement;
if (button.isEqualNode(this.button)) { if (button.isEqualNode(this.button)) {
this.toggle(); this.toggle();
} }
if (this.buttonState === 'hide') {
this.hideRows();
}
} }
} }
@ -174,14 +134,25 @@ class TableState {
// @ts-expect-error null handling is performed in the constructor // @ts-expect-error null handling is performed in the constructor
private disabledButton: ButtonState; 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. * Underlying DOM Table Caption Element.
*/ */
private caption: Nullable<HTMLTableCaptionElement> = null; private caption: Nullable<HTMLTableCaptionElement> = null;
/**
* All table rows in table
*/
private rows: NodeListOf<HTMLTableRowElement>;
constructor(table: HTMLTableElement) { constructor(table: HTMLTableElement) {
this.table = table; this.table = table;
this.rows = this.table.querySelectorAll('tr');
try { try {
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>( const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
this.table, this.table,
@ -191,6 +162,10 @@ class TableState {
this.table, this.table,
'button.toggle-disabled', 'button.toggle-disabled',
); );
const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
this.table,
'button.toggle-virtual',
);
const caption = this.table.querySelector('caption'); const caption = this.table.querySelector('caption');
this.caption = caption; this.caption = caption;
@ -203,13 +178,28 @@ class TableState {
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table); 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. // Attach event listeners to the buttons elements.
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this)); toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisabledButton.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. // Instantiate ButtonState for each button for state management.
this.enabledButton = new ButtonState(toggleEnabledButton, this.table); this.enabledButton = new ButtonState(
this.disabledButton = new ButtonState(toggleDisabledButton, this.table); 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) { } catch (err) {
if (err instanceof TableStateError) { if (err instanceof TableStateError) {
// This class is useless for tables that don't have toggle buttons. // This class is useless for tables that don't have toggle buttons.
@ -246,37 +236,42 @@ class TableState {
private toggleCaption(): void { private toggleCaption(): void {
const showEnabled = this.enabledButton.buttonState === 'show'; const showEnabled = this.enabledButton.buttonState === 'show';
const showDisabled = this.disabledButton.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'; this.captionText = 'Showing Enabled Interfaces';
} else if (showEnabled && showDisabled) { } else if (showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled & Disabled Interfaces'; this.captionText = 'Showing Enabled & Disabled Interfaces';
} else if (!showEnabled && showDisabled) { } else if (!showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Disabled Interfaces'; this.captionText = 'Showing Disabled Interfaces';
} else if (!showEnabled && !showDisabled) { } else if (!showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Hiding Enabled & Disabled Interfaces'; 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 { } else {
this.captionText = ''; this.captionText = '';
} }
} }
/** /**
* When toggle buttons are clicked, pass the event to the relevant button's handler and update * When toggle buttons are clicked, reapply visability all rows and
* this instance's state. * pass the event to all button handlers
* *
* @param event onClick event for toggle buttons. * @param event onClick event for toggle buttons.
* @param instance Instance of TableState (`this` cannot be used since that's context-specific). * @param instance Instance of TableState (`this` cannot be used since that's context-specific).
*/ */
public handleClick(event: Event, instance: TableState): void { public handleClick(event: Event, instance: TableState): void {
const button = event.currentTarget as HTMLButtonElement; for (const row of this.rows) {
const enabled = button.isEqualNode(instance.enabledButton.button); row.classList.remove('d-none');
const disabled = button.isEqualNode(instance.disabledButton.button);
if (enabled) {
instance.enabledButton.handleClick(event);
} else if (disabled) {
instance.disabledButton.handleClick(event);
} }
instance.enabledButton.handleClick(event);
instance.disabledButton.handleClick(event);
instance.virtualButton.handleClick(event);
instance.toggleCaption(); instance.toggleCaption();
} }
} }

View File

@ -236,12 +236,12 @@ table {
} }
th.asc > a::after { th.asc > a::after {
content: "\f0140"; content: '\f0140';
font-family: 'Material Design Icons'; font-family: 'Material Design Icons';
} }
th.desc > a::after { th.desc > a::after {
content: "\f0143"; content: '\f0143';
font-family: 'Material Design Icons'; font-family: 'Material Design Icons';
} }
@ -419,7 +419,7 @@ nav.search {
// Styles for the quicksearch and its clear button; // Styles for the quicksearch and its clear button;
// Overrides input-group styles and adds transition effects // Overrides input-group styles and adds transition effects
.quicksearch { .quicksearch {
input[type="search"] { input[type='search'] {
border-radius: $border-radius !important; border-radius: $border-radius !important;
} }
@ -998,9 +998,24 @@ div.card-overlay {
padding: 8px; 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 // Preformatted text blocks
td pre { td pre {
margin-bottom: 0 margin-bottom: 0;
} }
pre.block { pre.block {
padding: $spacer; padding: $spacer;

View File

@ -27,12 +27,9 @@
</div> </div>
<div class="tab-content p-0 border-0"> <div class="tab-content p-0 border-0">
<div class="tab-pane{% if not providernetwork_tab_active %} active{% endif %}" id="site"> <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 %} {% render_field form.site %}
</div> </div>
<div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork"> <div class="tab-pane{% if providernetwork_tab_active %} active{% endif %}" id="providernetwork">
{% render_field form.provider_network_provider %}
{% render_field form.provider_network %} {% render_field form.provider_network %}
</div> </div>
</div> </div>

View File

@ -74,10 +74,10 @@
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
</button> </button>
<ul class="dropdown-menu"> <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.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&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.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&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=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&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=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
</ul> </ul>
</div> </div>
{% endif %} {% endif %}

View File

@ -3,82 +3,49 @@
{% load helpers %} {% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% block content-wrapper %} {% block form %}
<div class="tab-content">
{% render_errors form %} {# A side termination #}
<form method="post"> <div class="field-group mb-5">
{% csrf_token %} <div class="row mb-2">
{% for field in form.hidden_fields %} <h5 class="offset-sm-3">A Side</h5>
{{ field }} </div>
{% 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 %}
{% if 'termination_a_device' in form.fields %} {% if 'termination_a_device' in form.fields %}
{% render_field form.termination_a_device %} {% render_field form.termination_a_device %}
{% endif %} {% endif %}
{% if 'termination_a_powerpanel' in form.fields %} {% if 'termination_a_powerpanel' in form.fields %}
{% render_field form.termination_a_powerpanel %} {% render_field form.termination_a_powerpanel %}
{% endif %} {% endif %}
{% if 'termination_a_provider' in form.fields %}
{% render_field form.termination_a_provider %}
{% endif %}
{% if 'termination_a_circuit' in form.fields %} {% if 'termination_a_circuit' in form.fields %}
{% render_field form.termination_a_circuit %} {% render_field form.termination_a_circuit %}
{% endif %} {% endif %}
{% render_field form.a_terminations %} {% render_field form.a_terminations %}
</div> </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>
<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 %} {% if 'termination_b_device' in form.fields %}
{% render_field form.termination_b_device %} {% render_field form.termination_b_device %}
{% endif %} {% endif %}
{% if 'termination_b_powerpanel' in form.fields %} {% if 'termination_b_powerpanel' in form.fields %}
{% render_field form.termination_b_powerpanel %} {% render_field form.termination_b_powerpanel %}
{% endif %} {% endif %}
{% if 'termination_b_provider' in form.fields %}
{% render_field form.termination_b_provider %}
{% endif %}
{% if 'termination_b_circuit' in form.fields %} {% if 'termination_b_circuit' in form.fields %}
{% render_field form.termination_b_circuit %} {% render_field form.termination_b_circuit %}
{% endif %} {% endif %}
{% render_field form.b_terminations %} {% render_field form.b_terminations %}
</div> </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>
<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.status %}
{% render_field form.type %} {% render_field form.type %}
{% render_field form.tenant_group %}
{% render_field form.tenant %}
{% render_field form.label %} {% render_field form.label %}
{% render_field form.description %} {% render_field form.description %}
{% render_field form.color %} {% render_field form.color %}
@ -94,33 +61,29 @@
</div> </div>
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
</div> </div>
<div class="card"> {% render_field form.tenant_group %}
<h5 class="card-header text-center">Comments</h5> {% render_field form.tenant %}
<div class="card-body">
{% render_field form.comments %}
</div>
</div> </div>
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="card"> <div class="field-group mb-5">
<h5 class="card-header offset-sm-3">Custom Fields</h5> <div class="row mb-2">
<div class="card-body"> <h5 class="offset-sm-3">Custom Fields</h5>
</div>
{% render_custom_fields form %} {% render_custom_fields form %}
</div> </div>
{% endif %}
{% if form.comments %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
{% render_field form.comments %}
</div> </div>
{% endif %} {% 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 %} {% endblock %}

View File

@ -71,13 +71,13 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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> </li>
</ul> </ul>
</div> </div>

View File

@ -71,13 +71,13 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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> </li>
</ul> </ul>
</div> </div>

View File

@ -7,5 +7,6 @@
<ul class="dropdown-menu"> <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-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-disabled" data-state="show">Hide Disabled</button>
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
</ul> </ul>
{% endblock extra_table_controls %} {% endblock extra_table_controls %}

View File

@ -18,7 +18,6 @@
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5> <h5 class="offset-sm-3">Hardware</h5>
</div> </div>
{% render_field form.manufacturer %}
{% render_field form.device_type %} {% render_field form.device_type %}
{% render_field form.airflow %} {% render_field form.airflow %}
{% render_field form.serial %} {% render_field form.serial %}
@ -29,8 +28,6 @@
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Location</h5> <h5 class="offset-sm-3">Location</h5>
</div> </div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.location %} {% render_field form.location %}
{% render_field form.rack %} {% render_field form.rack %}
@ -76,7 +73,6 @@
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5> <h5 class="offset-sm-3">Virtualization</h5>
</div> </div>
{% render_field form.cluster_group %}
{% render_field form.cluster %} {% render_field form.cluster %}
</div> </div>

View File

@ -109,22 +109,22 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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>
<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>
<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>
<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> </li>
</ul> </ul>
</div> </div>

View File

@ -182,16 +182,16 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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>
<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> </li>
</ul> </ul>
</div> </div>

View File

@ -122,7 +122,7 @@
{% if not object.mark_connected and not object.cable %} {% if not object.mark_connected and not object.cable %}
<div class="card-footer"> <div class="card-footer">
{% if perms.dcim.add_cable %} {% 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 <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a> </a>
{% endif %} {% endif %}

View File

@ -71,7 +71,7 @@
<div class="text-muted"> <div class="text-muted">
Not Connected Not Connected
{% if perms.dcim.add_cable %} {% 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 <i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Connect
</a> </a>
{% endif %} {% endif %}

View File

@ -77,10 +77,10 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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> </li>
</ul> </ul>
</span> </span>

View File

@ -6,8 +6,6 @@
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Rack</h5> <h5 class="offset-sm-3">Rack</h5>
</div> </div>
{% render_field form.region %}
{% render_field form.site_group %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.location %} {% render_field form.location %}
{% render_field form.name %} {% render_field form.name %}

View File

@ -105,16 +105,16 @@
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li> <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>
<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>
<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>
<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> </li>
</ul> </ul>
</span> </span>

View File

@ -60,6 +60,10 @@
<th scope="row">UI Visibility</th> <th scope="row">UI Visibility</th>
<td>{{ object.get_ui_visibility_display }}</td> <td>{{ object.get_ui_visibility_display }}</td>
</tr> </tr>
<tr>
<th scope="row">Cloneable</th>
<td>{% checkmark object.is_cloneable %}</td>
</tr>
</table> </table>
</div> </div>
</div> </div>

View File

@ -30,7 +30,7 @@
<strong>{{ widget.title }}</strong> <strong>{{ widget.title }}</strong>
{% endif %} {% endif %}
</div> </div>
<div class="card-body p-2"> <div class="card-body p-2 overflow-auto">
{% render_widget widget %} {% render_widget widget %}
</div> </div>
</div> </div>

View File

@ -4,6 +4,7 @@
{% csrf_token %} {% csrf_token %}
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">Add a Widget</h5> <h5 class="modal-title">Add a Widget</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
{% block form %} {% block form %}

View File

@ -1,4 +0,0 @@
<div class="htmx-container"
hx-get="{% url 'extras:objectchange_list' %}?sort=-time"
hx-trigger="load"
></div>

View File

@ -0,0 +1,4 @@
<div class="htmx-container"
hx-get="{% url viewname %}"
hx-trigger="load"
></div>

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

View File

@ -15,15 +15,20 @@ Context:
{% block tabs %} {% block tabs %}
<ul class="nav nav-tabs px-3"> <ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation"> <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"> <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">
Data Import Direct Import
</button> </button>
</li> </li>
<li class="nav-item" role="presentation"> <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 Upload File
</button> </button>
</li> </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> </ul>
{% endblock tabs %} {% endblock tabs %}
@ -31,12 +36,12 @@ Context:
<div class="tab-content"> <div class="tab-content">
{# Data Import Form #} {# Data Import Form #}
<div class="tab-pane show active" id="data-import-form" role="tabpanel" aria-labelledby="data-import-tab"> <div class="tab-pane show active" id="import-form" role="tabpanel" aria-labelledby="import-form-tab">
{% block content %}
<div class="row"> <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"> <form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% csrf_token %}
<input type="hidden" name="import_method" value="direct" />
{% render_field form.data %} {% render_field form.data %}
{% render_field form.format %} {% render_field form.format %}
<div class="form-group"> <div class="form-group">
@ -50,14 +55,35 @@ Context:
</form> </form>
</div> </div>
</div> </div>
{% endblock content %}
</div> </div>
{# File Upload Form #} {# File Upload Form #}
<div class="tab-pane show" id="file-upload-form" role="tabpanel" aria-labelledby="file-upload-tab"> <div class="tab-pane show" id="upload-form" role="tabpanel" aria-labelledby="upload-form-tab">
<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"> <form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %} {% 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.data_file %}
{% render_field form.format %} {% render_field form.format %}
<div class="form-group"> <div class="form-group">

View File

@ -74,3 +74,7 @@ Context:
</div> </div>
{% endblock content-wrapper %} {% endblock content-wrapper %}
{% block modals %}
{% include 'inc/htmx_modal.html' with size='lg' %}
{% endblock %}

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

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

View File

@ -1,5 +1,5 @@
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-hidden="true"> <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"> <div class="modal-content" id="htmx-modal-content">
{# Dynamic content goes here #} {# Dynamic content goes here #}
</div> </div>

View File

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

View File

@ -121,14 +121,9 @@
</div> </div>
<div class="tab-content p-0 border-0"> <div class="tab-content p-0 border-0">
<div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel"> <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 %} {% render_field form.nat_device %}
</div> </div>
<div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel"> <div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
{% render_field form.nat_cluster %}
{% render_field form.nat_virtual_machine %} {% render_field form.nat_virtual_machine %}
</div> </div>
<div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel"> <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