Merge branch 'develop' into feature
This commit is contained in:
commit
312291b010
|
@ -26,7 +26,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.6
|
||||
placeholder: v3.7.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -14,7 +14,7 @@ body:
|
|||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.7.6
|
||||
placeholder: v3.7.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
|
|
@ -14,3 +14,7 @@ timeout = 120
|
|||
# The maximum number of requests a worker can handle before being respawned
|
||||
max_requests = 5000
|
||||
max_requests_jitter = 500
|
||||
|
||||
# Uncomment this line to accept HTTP headers containing underscores, e.g. for remote
|
||||
# authentication support. See https://docs.gunicorn.org/en/stable/settings.html#header-map
|
||||
# header-map = 'dangerous'
|
||||
|
|
|
@ -26,7 +26,10 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
|
|||
|
||||
Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
|
||||
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the user's profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
|
||||
|
||||
!!! warning Verify Header Compatibility
|
||||
Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
|
||||
|
||||
### Single Sign-On (SSO)
|
||||
|
||||
|
|
|
@ -85,6 +85,9 @@ Default: `'HTTP_REMOTE_USER'`
|
|||
|
||||
When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
|
||||
|
||||
!!! warning Verify Header Compatibility
|
||||
Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
|
||||
|
||||
---
|
||||
|
||||
## REMOTE_AUTH_USER_EMAIL
|
||||
|
|
|
@ -371,6 +371,14 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
|
|||
* `min_prefix_length` - Minimum length of the mask
|
||||
* `max_prefix_length` - Maximum length of the mask
|
||||
|
||||
### DateVar
|
||||
|
||||
A calendar date. Returns a `datetime.date` object.
|
||||
|
||||
### DateTimeVar
|
||||
|
||||
A complete date & time. Returns a `datetime.datetime` object.
|
||||
|
||||
## Running Custom Scripts
|
||||
|
||||
!!! note
|
||||
|
|
|
@ -1,11 +1,36 @@
|
|||
# NetBox v3.7
|
||||
|
||||
## v3.7.7 (FUTURE)
|
||||
## v3.7.7 (2024-05-01)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list
|
||||
* [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts
|
||||
* [#15894](https://github.com/netbox-community/netbox/issues/15894) - Cache the generated API schema definition for shorter loading times
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#11460](https://github.com/netbox-community/netbox/issues/11460) - Fix AttributeError exception when editing a cable with only one end terminated
|
||||
* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display
|
||||
* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display
|
||||
* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices
|
||||
* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination
|
||||
* [#14852](https://github.com/netbox-community/netbox/issues/14852) - Fix NoReverseMatch exception when viewing an event rule which references a deleted custom script
|
||||
* [#15524](https://github.com/netbox-community/netbox/issues/15524) - Fix rounding error when reporting IP range utilization
|
||||
* [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted
|
||||
* [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API
|
||||
* [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML
|
||||
* [#15891](https://github.com/netbox-community/netbox/issues/15891) - Ensure deterministic ordering for scripts & reports
|
||||
* [#15896](https://github.com/netbox-community/netbox/issues/15896) - Fix retention of default value when editing a custom JSON field
|
||||
* [#15899](https://github.com/netbox-community/netbox/issues/15899) - Fix exception when enabling the tags column on the L2VPN terminations table
|
||||
|
||||
---
|
||||
|
||||
## v3.7.6 (2024-04-22)
|
||||
|
||||
!!! warning
|
||||
If remote authentication is in use with Gunicorn v22.0 or later, it may be necessary to configure Gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to preserve authentication headers.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
|
@ -88,14 +89,22 @@ def get_cable_form(a_type, b_type):
|
|||
|
||||
class _CableForm(CableForm, metaclass=FormMetaclass):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
|
||||
initial = initial or {}
|
||||
if a_type:
|
||||
ct = ContentType.objects.get_for_model(a_type)
|
||||
initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}'
|
||||
if b_type:
|
||||
ct = ContentType.objects.get_for_model(b_type)
|
||||
initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}'
|
||||
|
||||
# TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
|
||||
for field_name in ('a_terminations', 'b_terminations'):
|
||||
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
|
||||
kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
|
||||
if field_name in initial and type(initial[field_name]) is not list:
|
||||
initial[field_name] = [initial[field_name]]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
if self.instance and self.instance.pk:
|
||||
# Initialize A/B terminations when modifying an existing Cable instance
|
||||
|
@ -106,7 +115,7 @@ def get_cable_form(a_type, b_type):
|
|||
super().clean()
|
||||
|
||||
# Set the A/B terminations on the Cable instance
|
||||
self.instance.a_terminations = self.cleaned_data['a_terminations']
|
||||
self.instance.b_terminations = self.cleaned_data['b_terminations']
|
||||
self.instance.a_terminations = self.cleaned_data.get('a_terminations', [])
|
||||
self.instance.b_terminations = self.cleaned_data.get('b_terminations', [])
|
||||
|
||||
return _CableForm
|
||||
|
|
|
@ -628,14 +628,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
|
|||
self.fields['adopt_components'].disabled = True
|
||||
|
||||
|
||||
def get_termination_type_choices():
|
||||
return add_blank_choice([
|
||||
(f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title())
|
||||
for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS)
|
||||
])
|
||||
|
||||
|
||||
class CableForm(TenancyForm, NetBoxModelForm):
|
||||
a_terminations_type = forms.ChoiceField(
|
||||
choices=get_termination_type_choices,
|
||||
required=False,
|
||||
widget=HTMXSelect(),
|
||||
label=_('Type')
|
||||
)
|
||||
b_terminations_type = forms.ChoiceField(
|
||||
choices=get_termination_type_choices,
|
||||
required=False,
|
||||
widget=HTMXSelect(),
|
||||
label=_('Type')
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
class Meta:
|
||||
model = Cable
|
||||
fields = [
|
||||
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
|
||||
'comments', 'tags',
|
||||
'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
|
||||
'length', 'length_unit', 'description', 'comments', 'tags',
|
||||
]
|
||||
error_messages = {
|
||||
'length': {
|
||||
|
|
|
@ -8,17 +8,16 @@ from django.conf import settings
|
|||
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
|
||||
from utilities.html import foreground_color
|
||||
|
||||
|
||||
__all__ = (
|
||||
'CableTraceSVG',
|
||||
)
|
||||
|
||||
|
||||
OFFSET = 0.5
|
||||
PADDING = 10
|
||||
LINE_HEIGHT = 20
|
||||
FANOUT_HEIGHT = 35
|
||||
FANOUT_LEG_HEIGHT = 15
|
||||
CABLE_HEIGHT = 4 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
|
||||
|
||||
|
||||
class Node(Hyperlink):
|
||||
|
@ -84,31 +83,38 @@ class Connector(Group):
|
|||
labels: Iterable of text labels
|
||||
"""
|
||||
|
||||
def __init__(self, start, url, color, labels=[], description=[], **extra):
|
||||
super().__init__(class_='connector', **extra)
|
||||
def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
|
||||
super().__init__(class_="connector", **extra)
|
||||
|
||||
self.start = start
|
||||
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
|
||||
self.end = (start[0], start[1] + self.height)
|
||||
# Allow to specify end-position or auto-calculate
|
||||
self.end = end if end else (start[0], start[1] + self.height)
|
||||
self.color = color or '000000'
|
||||
|
||||
# Draw a "shadow" line to give the cable a border
|
||||
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
|
||||
self.add(cable_shadow)
|
||||
if wireless:
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, class_="wireless-link")
|
||||
self.add(cable)
|
||||
else:
|
||||
# Draw a "shadow" line to give the cable a border
|
||||
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
|
||||
self.add(cable_shadow)
|
||||
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
|
||||
self.add(cable)
|
||||
# Draw the cable
|
||||
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
|
||||
self.add(cable)
|
||||
|
||||
# Add link
|
||||
link = Hyperlink(href=url, target='_parent')
|
||||
|
||||
# Add text label(s)
|
||||
cursor = start[1]
|
||||
cursor += PADDING * 2
|
||||
cursor = start[1] + text_offset
|
||||
cursor += PADDING * 2 + LINE_HEIGHT * 2
|
||||
x_coord = (start[0] + end[0]) / 2 + PADDING
|
||||
for i, label in enumerate(labels):
|
||||
cursor += LINE_HEIGHT
|
||||
text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
|
||||
text_coords = (x_coord, cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
if len(description) > 0:
|
||||
|
@ -190,8 +196,9 @@ class CableTraceSVG:
|
|||
|
||||
def draw_parent_objects(self, obj_list):
|
||||
"""
|
||||
Draw a set of parent objects.
|
||||
Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
|
||||
"""
|
||||
objects = []
|
||||
width = self.width / len(obj_list)
|
||||
for i, obj in enumerate(obj_list):
|
||||
node = Node(
|
||||
|
@ -199,23 +206,26 @@ class CableTraceSVG:
|
|||
width=width,
|
||||
url=f'{self.base_url}{obj.get_absolute_url()}',
|
||||
color=self._get_color(obj),
|
||||
labels=self._get_labels(obj)
|
||||
labels=self._get_labels(obj),
|
||||
object=obj
|
||||
)
|
||||
objects.append(node)
|
||||
self.parent_objects.append(node)
|
||||
if i + 1 == len(obj_list):
|
||||
self.cursor += node.box['height']
|
||||
return objects
|
||||
|
||||
def draw_terminations(self, terminations):
|
||||
def draw_object_terminations(self, terminations, offset_x, width):
|
||||
"""
|
||||
Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
|
||||
Draw all terminations belonging to an object with specified offset and width
|
||||
Return all created nodes and their maximum height
|
||||
"""
|
||||
nodes = []
|
||||
nodes_height = 0
|
||||
width = self.width / len(terminations)
|
||||
|
||||
for i, term in enumerate(terminations):
|
||||
nodes = []
|
||||
# Sort them by name to make renders more readable
|
||||
for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
|
||||
node = Node(
|
||||
position=(i * width, self.cursor),
|
||||
position=(offset_x + i * width, self.cursor),
|
||||
width=width,
|
||||
url=f'{self.base_url}{term.get_absolute_url()}',
|
||||
color=self._get_color(term),
|
||||
|
@ -225,133 +235,89 @@ class CableTraceSVG:
|
|||
)
|
||||
nodes_height = max(nodes_height, node.box['height'])
|
||||
nodes.append(node)
|
||||
return nodes, nodes_height
|
||||
|
||||
def draw_terminations(self, terminations, parent_object_nodes):
|
||||
"""
|
||||
Draw a row of terminating objects (e.g. interfaces) and return all created nodes
|
||||
Attach them to previously created parent objects
|
||||
"""
|
||||
nodes = []
|
||||
nodes_height = 0
|
||||
|
||||
# Draw terminations for each parent object
|
||||
for parent in parent_object_nodes:
|
||||
parent_terms = [term for term in terminations if term.parent_object == parent.object]
|
||||
|
||||
# Width and offset(position) for each termination box
|
||||
width = parent.box['width'] / len(parent_terms)
|
||||
offset_x = parent.box['x']
|
||||
|
||||
result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
|
||||
nodes.extend(result)
|
||||
|
||||
self.cursor += nodes_height
|
||||
self.terminations.extend(nodes)
|
||||
|
||||
return nodes
|
||||
|
||||
def draw_fanin(self, node, connector):
|
||||
points = (
|
||||
node.bottom_center,
|
||||
(node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
|
||||
connector.start,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_fanout(self, node, connector):
|
||||
points = (
|
||||
connector.end,
|
||||
(node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
|
||||
node.top_center,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{connector.color}'),
|
||||
))
|
||||
|
||||
def draw_cable(self, cable, terminations, cable_count=0):
|
||||
def draw_far_objects(self, obj_list, terminations):
|
||||
"""
|
||||
Draw a single cable. Terminations and cable count are passed for determining position and padding
|
||||
|
||||
:param cable: The cable to draw
|
||||
:param terminations: List of terminations to build positioning data off of
|
||||
:param cable_count: Count of all cables on this layer for determining whether to collapse description into a
|
||||
tooltip.
|
||||
Draw the far-end objects and its terminations and return all created nodes
|
||||
"""
|
||||
# Make sure elements are sorted by name for readability
|
||||
objects = sorted(obj_list, key=lambda x: x.name)
|
||||
width = self.width / len(objects)
|
||||
|
||||
# If the cable count is higher than 2, collapse the description into a tooltip
|
||||
if cable_count > 2:
|
||||
# Use the cable __str__ function to denote the cable
|
||||
labels = [f'{cable}']
|
||||
# Max-height of created terminations
|
||||
terms_height = 0
|
||||
term_nodes = []
|
||||
|
||||
# Include the label and the status description in the tooltip
|
||||
description = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
# Draw the terminations by per object first
|
||||
for i, obj in enumerate(objects):
|
||||
obj_terms = [term for term in terminations if term.parent_object == obj]
|
||||
obj_pos = i * width
|
||||
result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
|
||||
|
||||
if cable.type:
|
||||
# Include the cable type in the tooltip
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
description.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
else:
|
||||
labels = [
|
||||
f'Cable {cable}',
|
||||
cable.get_status_display()
|
||||
]
|
||||
description = []
|
||||
if cable.type:
|
||||
labels.append(cable.get_type_display())
|
||||
if cable.length is not None and cable.length_unit:
|
||||
# Include the cable length in the tooltip
|
||||
labels.append(f'{cable.length} {cable.get_length_unit_display()}')
|
||||
terms_height = max(terms_height, result_nodes_height)
|
||||
term_nodes.extend(result)
|
||||
|
||||
# If there is only one termination, center on that termination
|
||||
# Otherwise average the center across the terminations
|
||||
if len(terminations) == 1:
|
||||
center = terminations[0].bottom_center[0]
|
||||
else:
|
||||
# Get a list of termination centers
|
||||
termination_centers = [term.bottom_center[0] for term in terminations]
|
||||
# Average the centers
|
||||
center = sum(termination_centers) / len(termination_centers)
|
||||
# Update cursor and draw the objects
|
||||
self.cursor += terms_height
|
||||
self.terminations.extend(term_nodes)
|
||||
object_nodes = self.draw_parent_objects(objects)
|
||||
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=(center, self.cursor),
|
||||
color=cable.color or '000000',
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
return object_nodes, term_nodes
|
||||
|
||||
# Set the cursor position
|
||||
self.cursor += connector.height
|
||||
|
||||
return connector
|
||||
|
||||
def draw_wirelesslink(self, wirelesslink):
|
||||
def draw_fanin(self, target, terminations, color):
|
||||
"""
|
||||
Draw a line with labels representing a WirelessLink.
|
||||
Draw the fan-in-lines from each of the terminations to the targetpoint
|
||||
"""
|
||||
group = Group(class_='connector')
|
||||
for term in terminations:
|
||||
points = (
|
||||
term.bottom_center,
|
||||
(term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
|
||||
target,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{color}'),
|
||||
))
|
||||
|
||||
labels = [
|
||||
f'Wireless link {wirelesslink}',
|
||||
wirelesslink.get_status_display()
|
||||
]
|
||||
if wirelesslink.ssid:
|
||||
labels.append(wirelesslink.ssid)
|
||||
|
||||
# Draw the wireless link
|
||||
start = (OFFSET + self.center, self.cursor)
|
||||
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
|
||||
end = (start[0], start[1] + height)
|
||||
line = Line(start=start, end=end, class_='wireless-link')
|
||||
group.add(line)
|
||||
|
||||
self.cursor += PADDING * 2
|
||||
|
||||
# Add link
|
||||
link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
|
||||
|
||||
# Add text label(s)
|
||||
for i, label in enumerate(labels):
|
||||
self.cursor += LINE_HEIGHT
|
||||
text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
|
||||
text = Text(label, insert=text_coords, class_='bold' if not i else [])
|
||||
link.add(text)
|
||||
|
||||
group.add(link)
|
||||
self.cursor += PADDING * 2
|
||||
|
||||
return group
|
||||
def draw_fanout(self, start, terminations, color):
|
||||
"""
|
||||
Draw the fan-out-lines from the startpoint to each of the terminations
|
||||
"""
|
||||
for term in terminations:
|
||||
points = (
|
||||
term.top_center,
|
||||
(term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
|
||||
start,
|
||||
)
|
||||
self.connectors.extend((
|
||||
Polyline(points=points, class_='cable-shadow'),
|
||||
Polyline(points=points, style=f'stroke: #{color}'),
|
||||
))
|
||||
|
||||
def draw_attachment(self):
|
||||
"""
|
||||
|
@ -378,86 +344,99 @@ class CableTraceSVG:
|
|||
|
||||
traced_path = self.origin.trace()
|
||||
|
||||
parent_object_nodes = []
|
||||
# Iterate through each (terms, cable, terms) segment in the path
|
||||
for i, segment in enumerate(traced_path):
|
||||
near_ends, links, far_ends = segment
|
||||
|
||||
# Near end parent
|
||||
# This is segment number one.
|
||||
if i == 0:
|
||||
# If this is the first segment, draw the originating termination's parent object
|
||||
self.draw_parent_objects(set(end.parent_object for end in near_ends))
|
||||
parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
|
||||
# Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
|
||||
|
||||
# Near end termination(s)
|
||||
terminations = self.draw_terminations(near_ends)
|
||||
near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
link_cables = {}
|
||||
fanin = False
|
||||
fanout = False
|
||||
|
||||
# Determine if we have fanins or fanouts
|
||||
if len(near_ends) > len(set(links)):
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
fanin = True
|
||||
if len(far_ends) > len(set(links)):
|
||||
fanout = True
|
||||
cursor = self.cursor
|
||||
for link in links:
|
||||
# Cable
|
||||
if type(link) is Cable and not link_cables.get(link.pk):
|
||||
# Reset cursor
|
||||
self.cursor = cursor
|
||||
# Generate a list of terminations connected to this cable
|
||||
near_end_link_terminations = [term for term in terminations if term.object.cable == link]
|
||||
# Draw the cable
|
||||
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
|
||||
# Add cable to the list of cables
|
||||
link_cables.update({link.pk: cable})
|
||||
# Add cable to drawing
|
||||
self.connectors.append(cable)
|
||||
parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
|
||||
for cable in links:
|
||||
# Fill in labels and description with all available data
|
||||
description = [
|
||||
f"Link {cable}",
|
||||
cable.get_status_display()
|
||||
]
|
||||
near = []
|
||||
far = []
|
||||
color = '000000'
|
||||
if cable.description:
|
||||
description.append(f"{cable.description}")
|
||||
if isinstance(cable, Cable):
|
||||
labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
|
||||
if cable.type:
|
||||
description.append(cable.get_type_display())
|
||||
if cable.length and cable.length_unit:
|
||||
description.append(f"{cable.length} {cable.get_length_unit_display()}")
|
||||
color = cable.color or '000000'
|
||||
|
||||
# Draw fan-ins
|
||||
if len(near_ends) > 1 and fanin:
|
||||
for term in terminations:
|
||||
if term.object.cable == link:
|
||||
self.draw_fanin(term, cable)
|
||||
# Collect all connected nodes to this cable
|
||||
near = [term for term in near_terminations if term.object in cable.a_terminations]
|
||||
far = [term for term in far_terminations if term.object in cable.b_terminations]
|
||||
if not (near and far):
|
||||
# a and b terminations may be swapped
|
||||
near = [term for term in near_terminations if term.object in cable.b_terminations]
|
||||
far = [term for term in far_terminations if term.object in cable.a_terminations]
|
||||
elif isinstance(cable, WirelessLink):
|
||||
labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
|
||||
if cable.ssid:
|
||||
description.append(f"{cable.ssid}")
|
||||
near = [term for term in near_terminations if term.object == cable.interface_a]
|
||||
far = [term for term in far_terminations if term.object == cable.interface_b]
|
||||
if not (near and far):
|
||||
# a and b terminations may be swapped
|
||||
near = [term for term in near_terminations if term.object == cable.interface_b]
|
||||
far = [term for term in far_terminations if term.object == cable.interface_a]
|
||||
|
||||
# WirelessLink
|
||||
elif type(link) is WirelessLink:
|
||||
wirelesslink = self.draw_wirelesslink(link)
|
||||
self.connectors.append(wirelesslink)
|
||||
# Select most-probable start and end position
|
||||
start = near[0].bottom_center
|
||||
end = far[0].top_center
|
||||
text_offset = 0
|
||||
|
||||
# Far end termination(s)
|
||||
if len(far_ends) > 1:
|
||||
if fanout:
|
||||
self.cursor += FANOUT_HEIGHT
|
||||
terminations = self.draw_terminations(far_ends)
|
||||
for term in terminations:
|
||||
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
|
||||
self.draw_fanout(term, link_cables.get(term.object.cable.pk))
|
||||
else:
|
||||
self.draw_terminations(far_ends)
|
||||
elif far_ends:
|
||||
self.draw_terminations(far_ends)
|
||||
else:
|
||||
# Link is not connected to anything
|
||||
break
|
||||
if len(near) > 1:
|
||||
# Handle Fan-In - change start position to be directly below start
|
||||
start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
|
||||
self.draw_fanin(start, near, color)
|
||||
text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
|
||||
elif len(far) > 1:
|
||||
# Handle Fan-Out - change end position to be directly above end
|
||||
end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
|
||||
self.draw_fanout(end, far, color)
|
||||
text_offset -= FANOUT_HEIGHT
|
||||
|
||||
# Far end parent
|
||||
parent_objects = set(end.parent_object for end in far_ends)
|
||||
self.draw_parent_objects(parent_objects)
|
||||
# Create the connector
|
||||
connector = Connector(
|
||||
start=start,
|
||||
end=end,
|
||||
color=color,
|
||||
wireless=isinstance(cable, WirelessLink),
|
||||
url=f'{self.base_url}{cable.get_absolute_url()}',
|
||||
text_offset=text_offset,
|
||||
labels=labels,
|
||||
description=description
|
||||
)
|
||||
self.connectors.append(connector)
|
||||
|
||||
# Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
|
||||
# a CircuitTermination)
|
||||
elif far_ends:
|
||||
|
||||
# Attachment
|
||||
attachment = self.draw_attachment()
|
||||
self.connectors.append(attachment)
|
||||
|
||||
# Object
|
||||
self.draw_parent_objects(far_ends)
|
||||
parent_object_nodes = self.draw_parent_objects(far_ends)
|
||||
|
||||
# Determine drawing size
|
||||
self.drawing = svgwrite.Drawing(
|
||||
|
|
|
@ -51,34 +51,6 @@ def get_cabletermination_row_class(record):
|
|||
return ''
|
||||
|
||||
|
||||
def get_interface_row_class(record):
|
||||
if not record.enabled:
|
||||
return 'danger'
|
||||
elif record.is_virtual:
|
||||
return 'primary'
|
||||
return get_cabletermination_row_class(record)
|
||||
|
||||
|
||||
def get_interface_state_attribute(record):
|
||||
"""
|
||||
Get interface enabled state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.enabled:
|
||||
return 'enabled'
|
||||
else:
|
||||
return 'disabled'
|
||||
|
||||
|
||||
def get_interface_connected_attribute(record):
|
||||
"""
|
||||
Get interface disconnected state as string to attach to <tr/> DOM element.
|
||||
"""
|
||||
if record.mark_connected or record.cable:
|
||||
return 'connected'
|
||||
else:
|
||||
return 'disconnected'
|
||||
|
||||
|
||||
#
|
||||
# Device roles
|
||||
#
|
||||
|
@ -706,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable):
|
|||
'cable', 'connection',
|
||||
)
|
||||
row_attrs = {
|
||||
'class': get_interface_row_class,
|
||||
'data-name': lambda record: record.name,
|
||||
'data-enabled': get_interface_state_attribute,
|
||||
'data-type': lambda record: record.type,
|
||||
'data-connected': get_interface_connected_attribute
|
||||
'data-enabled': lambda record: "enabled" if record.enabled else "disabled",
|
||||
'data-virtual': lambda record: "true" if record.is_virtual else "false",
|
||||
'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
|
||||
'data-cable-status': lambda record: record.cable.status if record.cable else "",
|
||||
'data-type': lambda record: record.type
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -3177,34 +3177,29 @@ class CableView(generic.ObjectView):
|
|||
class CableEditView(generic.ObjectEditView):
|
||||
queryset = Cable.objects.all()
|
||||
template_name = 'dcim/cable_edit.html'
|
||||
htmx_template_name = 'dcim/htmx/cable_edit.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
||||
# If creating a new Cable, initialize the form class using URL query params
|
||||
if 'pk' not in kwargs:
|
||||
self.form = forms.get_cable_form(
|
||||
a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
|
||||
b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
|
||||
)
|
||||
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
def alter_object(self, obj, request, url_args, url_kwargs):
|
||||
"""
|
||||
Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView
|
||||
Hack into alter_object() to set the form class when editing an existing Cable, since ObjectEditView
|
||||
doesn't currently provide a hook for dynamic class resolution.
|
||||
"""
|
||||
obj = super().get_object(**kwargs)
|
||||
a_terminations_type = CABLE_TERMINATION_TYPES.get(
|
||||
request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type')
|
||||
)
|
||||
b_terminations_type = CABLE_TERMINATION_TYPES.get(
|
||||
request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type')
|
||||
)
|
||||
|
||||
if obj.pk:
|
||||
# TODO: Optimize this logic
|
||||
termination_a = obj.terminations.filter(cable_end='A').first()
|
||||
a_type = termination_a.termination._meta.model if termination_a else None
|
||||
termination_b = obj.terminations.filter(cable_end='B').first()
|
||||
b_type = termination_b.termination._meta.model if termination_b else None
|
||||
self.form = forms.get_cable_form(a_type, b_type)
|
||||
if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()):
|
||||
a_terminations_type = termination_a.termination._meta.model
|
||||
if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()):
|
||||
b_terminations_type = termination_b.termination._meta.model
|
||||
|
||||
return obj
|
||||
self.form = forms.get_cable_form(a_terminations_type, b_terminations_type)
|
||||
|
||||
return super().alter_object(obj, request, url_args, url_kwargs)
|
||||
|
||||
def get_extra_addanother_params(self, request):
|
||||
|
||||
|
|
|
@ -68,6 +68,7 @@ class Migration(migrations.Migration):
|
|||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
|
@ -79,6 +80,7 @@ class Migration(migrations.Migration):
|
|||
],
|
||||
options={
|
||||
'proxy': True,
|
||||
'ordering': ('file_root', 'file_path'),
|
||||
'indexes': [],
|
||||
'constraints': [],
|
||||
},
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import decimal
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, date
|
||||
|
||||
|
@ -488,7 +489,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||
|
||||
# JSON
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
field = JSONField(required=required, initial=initial)
|
||||
field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
|
||||
|
||||
# Object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
|
|
|
@ -97,8 +97,16 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||
"""
|
||||
objects = ScriptModuleManager()
|
||||
|
||||
event_rules = GenericRelation(
|
||||
to='extras.EventRule',
|
||||
content_type_field='action_object_type',
|
||||
object_id_field='action_object_id',
|
||||
for_concrete_model=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
proxy = True
|
||||
ordering = ('file_root', 'file_path')
|
||||
verbose_name = _('script module')
|
||||
verbose_name_plural = _('script modules')
|
||||
|
||||
|
|
|
@ -24,6 +24,7 @@ from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator,
|
|||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import DatePicker, DateTimePicker
|
||||
from .context_managers import event_tracking
|
||||
from .forms import ScriptForm
|
||||
from .utils import is_report
|
||||
|
@ -33,6 +34,8 @@ __all__ = (
|
|||
'BaseScript',
|
||||
'BooleanVar',
|
||||
'ChoiceVar',
|
||||
'DateVar',
|
||||
'DateTimeVar',
|
||||
'FileVar',
|
||||
'IntegerVar',
|
||||
'IPAddressVar',
|
||||
|
@ -174,6 +177,28 @@ class ChoiceVar(ScriptVariable):
|
|||
self.field_attrs['choices'] = add_blank_choice(choices)
|
||||
|
||||
|
||||
class DateVar(ScriptVariable):
|
||||
"""
|
||||
A date.
|
||||
"""
|
||||
form_field = forms.DateField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_field.widget = DatePicker()
|
||||
|
||||
|
||||
class DateTimeVar(ScriptVariable):
|
||||
"""
|
||||
A date and a time.
|
||||
"""
|
||||
form_field = forms.DateTimeField
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.form_field.widget = DateTimePicker()
|
||||
|
||||
|
||||
class MultiChoiceVar(ScriptVariable):
|
||||
"""
|
||||
Like ChoiceVar, but allows for the selection of multiple choices.
|
||||
|
|
|
@ -419,15 +419,35 @@ class ConfigTemplateTable(NetBoxTable):
|
|||
tags = columns.TagColumn(
|
||||
url_name='extras:configtemplate_list'
|
||||
)
|
||||
role_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicerole_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Device Roles')
|
||||
)
|
||||
platform_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:platform_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Platforms')
|
||||
)
|
||||
device_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Devices')
|
||||
)
|
||||
vm_count = columns.LinkedCountColumn(
|
||||
viewname='virtualization:virtualmachine_list',
|
||||
url_params={'config_template_id': 'pk'},
|
||||
verbose_name=_('Virtual Machines')
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ConfigTemplate
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
|
||||
'tags',
|
||||
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
|
||||
'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'description', 'is_synced',
|
||||
'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import tempfile
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
|
@ -322,3 +323,47 @@ class ScriptVariablesTest(TestCase):
|
|||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
|
||||
|
||||
def test_datevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = DateVar()
|
||||
var2 = DateVar(required=False)
|
||||
|
||||
# Test date validation
|
||||
data = {'var1': 'not a date'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
input_date = date(2024, 4, 1)
|
||||
data = {'var1': input_date}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], input_date)
|
||||
# Validate required=False works for this Var type
|
||||
self.assertEqual(form.cleaned_data['var2'], None)
|
||||
|
||||
def test_datetimevar(self):
|
||||
|
||||
class TestScript(Script):
|
||||
|
||||
var1 = DateTimeVar()
|
||||
var2 = DateTimeVar(required=False)
|
||||
|
||||
# Test datetime validation
|
||||
data = {'var1': 'not a datetime'}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('var1', form.errors)
|
||||
|
||||
# Validate valid data
|
||||
input_datetime = datetime(2024, 4, 1, 8, 0, 0, 0, timezone.utc)
|
||||
data = {'var1': input_datetime}
|
||||
form = TestScript().as_form(data, None)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertEqual(form.cleaned_data['var1'], input_datetime)
|
||||
# Validate required=False works for this Var type
|
||||
self.assertEqual(form.cleaned_data['var2'], None)
|
||||
|
|
|
@ -13,6 +13,7 @@ from core.choices import ManagedFileRootPathChoices
|
|||
from core.forms import ManagedFileForm
|
||||
from core.models import Job
|
||||
from core.tables import JobTable
|
||||
from dcim.models import Device, DeviceRole, Platform
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
|
@ -28,6 +29,7 @@ from utilities.request import copy_safe_request
|
|||
from utilities.rqworker import get_workers_for_queue
|
||||
from utilities.templatetags.builtins.filters import render_markdown
|
||||
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
|
||||
from virtualization.models import VirtualMachine
|
||||
from . import filtersets, forms, tables
|
||||
from .models import *
|
||||
from .scripts import run_script
|
||||
|
@ -627,7 +629,12 @@ class ObjectConfigContextView(generic.ObjectView):
|
|||
#
|
||||
|
||||
class ConfigTemplateListView(generic.ObjectListView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
queryset = ConfigTemplate.objects.annotate(
|
||||
device_count=count_related(Device, 'config_template'),
|
||||
vm_count=count_related(VirtualMachine, 'config_template'),
|
||||
role_count=count_related(DeviceRole, 'config_template'),
|
||||
platform_count=count_related(Platform, 'config_template'),
|
||||
)
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
filterset_form = forms.ConfigTemplateFilterForm
|
||||
table = tables.ConfigTemplateTable
|
||||
|
@ -1035,7 +1042,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||
return 'extras.view_script'
|
||||
|
||||
def get(self, request):
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
|
||||
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
|
||||
'data_source', 'data_file', 'jobs'
|
||||
)
|
||||
|
||||
return render(request, 'extras/script_list.html', {
|
||||
'model': ScriptModule,
|
||||
|
|
|
@ -692,7 +692,7 @@ class IPRange(PrimaryModel):
|
|||
ip.address.ip for ip in self.get_child_ips()
|
||||
]).size
|
||||
|
||||
return int(float(child_count) / self.size * 100)
|
||||
return min(float(child_count) / self.size * 100, 100)
|
||||
|
||||
|
||||
class IPAddress(PrimaryModel):
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import json
|
||||
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
@ -35,7 +37,11 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
|
|||
def _get_form_field(self, customfield):
|
||||
if self.instance.pk:
|
||||
form_field = customfield.to_form_field(set_initial=False)
|
||||
form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
|
||||
initial = self.instance.custom_field_data.get(customfield.name)
|
||||
if customfield.type == CustomFieldTypeChoices.TYPE_JSON:
|
||||
form_field.initial = json.dumps(initial)
|
||||
else:
|
||||
form_field.initial = initial
|
||||
return form_field
|
||||
|
||||
return customfield.to_form_field()
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.static import serve
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
|
||||
|
@ -10,7 +11,6 @@ from netbox.graphql.schema import schema
|
|||
from netbox.graphql.views import NetBoxGraphQLView
|
||||
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
|
||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||
from strawberry.django.views import GraphQLView
|
||||
|
||||
_patterns = [
|
||||
|
||||
|
@ -55,7 +55,13 @@ _patterns = [
|
|||
path('api/wireless/', include('wireless.api.urls')),
|
||||
path('api/status/', StatusView.as_view(), name='api-status'),
|
||||
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path(
|
||||
"api/schema/",
|
||||
cache_page(timeout=86400, key_prefix=f"api_schema_{settings.VERSION}")(
|
||||
SpectacularAPIView.as_view()
|
||||
),
|
||||
name="schema",
|
||||
),
|
||||
path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
|
||||
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),
|
||||
|
||||
|
|
|
@ -167,6 +167,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||
"""
|
||||
template_name = 'generic/object_edit.html'
|
||||
form = None
|
||||
htmx_template_name = 'htmx/form.html'
|
||||
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
# Determine required permission based on whether we are editing an existing object
|
||||
|
@ -228,7 +229,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
|||
|
||||
# If this is an HTMX request, return only the rendered form HTML
|
||||
if htmx_partial(request):
|
||||
return render(request, 'htmx/form.html', {
|
||||
return render(request, self.htmx_template_name, {
|
||||
'form': form,
|
||||
})
|
||||
|
||||
|
@ -339,10 +340,14 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
|
|||
|
||||
# Compile a mapping of models to instances
|
||||
dependent_objects = defaultdict(list)
|
||||
for model, instance in collector.instances_with_model():
|
||||
for model, instances in collector.instances_with_model():
|
||||
# Ignore relations to auto-created models (e.g. many-to-many mappings)
|
||||
if model._meta.auto_created:
|
||||
continue
|
||||
# Omit the root object
|
||||
if instance != obj:
|
||||
dependent_objects[model].append(instance)
|
||||
if instances == obj:
|
||||
continue
|
||||
dependent_objects[model].append(instances)
|
||||
|
||||
return dict(dependent_objects)
|
||||
|
||||
|
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
@ -7,10 +7,10 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
|
|||
*
|
||||
* @param element Connection Toggle Button Element
|
||||
*/
|
||||
function toggleConnection(element: HTMLButtonElement): void {
|
||||
function setConnectionStatus(element: HTMLButtonElement, status: string): void {
|
||||
// Get the button's row to change its data-cable-status attribute
|
||||
const row = element.parentElement?.parentElement as HTMLTableRowElement;
|
||||
const url = element.getAttribute('data-url');
|
||||
const connected = element.classList.contains('connected');
|
||||
const status = connected ? 'planned' : 'connected';
|
||||
|
||||
if (isTruthy(url)) {
|
||||
apiPatch(url, { status }).then(res => {
|
||||
|
@ -19,34 +19,18 @@ function toggleConnection(element: HTMLButtonElement): void {
|
|||
createToast('danger', 'Error', res.error).show();
|
||||
return;
|
||||
} else {
|
||||
// Get the button's row to change its styles.
|
||||
const row = element.parentElement?.parentElement as HTMLTableRowElement;
|
||||
// Get the button's icon to change its CSS class.
|
||||
const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement;
|
||||
if (connected) {
|
||||
row.classList.remove('success');
|
||||
row.classList.add('info');
|
||||
element.classList.remove('connected', 'btn-warning');
|
||||
element.classList.add('btn-info');
|
||||
element.title = 'Mark Installed';
|
||||
icon.classList.remove('mdi-lan-disconnect');
|
||||
icon.classList.add('mdi-lan-connect');
|
||||
} else {
|
||||
row.classList.remove('info');
|
||||
row.classList.add('success');
|
||||
element.classList.remove('btn-success');
|
||||
element.classList.add('connected', 'btn-warning');
|
||||
element.title = 'Mark Installed';
|
||||
icon.classList.remove('mdi-lan-connect');
|
||||
icon.classList.add('mdi-lan-disconnect');
|
||||
}
|
||||
// Update cable status in DOM
|
||||
row.setAttribute('data-cable-status', status);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initConnectionToggle(): void {
|
||||
for (const element of getElements<HTMLButtonElement>('button.cable-toggle')) {
|
||||
element.addEventListener('click', () => toggleConnection(element));
|
||||
for (const element of getElements<HTMLButtonElement>('button.mark-planned')) {
|
||||
element.addEventListener('click', () => setConnectionStatus(element, 'planned'));
|
||||
}
|
||||
for (const element of getElements<HTMLButtonElement>('button.mark-installed')) {
|
||||
element.addEventListener('click', () => setConnectionStatus(element, 'connected'));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
@use 'sass:map';
|
||||
|
||||
// Interface row coloring
|
||||
tr[data-cable-status=connected] {
|
||||
background-color: rgba(map.get($theme-colors, "green"), 0.15);
|
||||
}
|
||||
tr[data-cable-status=planned] {
|
||||
background-color: rgba(map.get($theme-colors, "blue"), 0.15);
|
||||
}
|
||||
tr[data-cable-status=decommissioning] {
|
||||
background-color: rgba(map.get($theme-colors, "yellow"), 0.15);
|
||||
}
|
||||
tr[data-mark-connected=true] {
|
||||
background-color: rgba(map.get($theme-colors, "success"), 0.15);
|
||||
}
|
||||
tr[data-virtual=true] {
|
||||
background-color: rgba(map.get($theme-colors, "primary"), 0.15);
|
||||
}
|
||||
tr[data-enabled=disabled] {
|
||||
background-color: rgba(map.get($theme-colors, "danger"), 0.15);
|
||||
}
|
||||
|
||||
// Only show the correct button depending on the cable status
|
||||
tr[data-cable-status=connected] button.mark-installed {
|
||||
display: none;
|
||||
}
|
||||
tr:not([data-cable-status=connected]) button.mark-planned {
|
||||
display: none;
|
||||
}
|
|
@ -20,5 +20,6 @@
|
|||
|
||||
// Custom styling
|
||||
@import 'custom/code';
|
||||
@import 'custom/interfaces';
|
||||
@import 'custom/markdown';
|
||||
@import 'custom/misc';
|
||||
|
|
|
@ -99,7 +99,7 @@ Blocks:
|
|||
{% endif %}
|
||||
|
||||
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
|
||||
{% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|escape %}
|
||||
{% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|safe %}
|
||||
{% endif %}
|
||||
{# /Alerts #}
|
||||
|
||||
|
|
|
@ -1,90 +1,5 @@
|
|||
{% extends 'generic/object_edit.html' %}
|
||||
{% load static %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block form %}
|
||||
|
||||
{# A side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "A Side" %}</h5>
|
||||
</div>
|
||||
{% if 'termination_a_device' in form.fields %}
|
||||
{% render_field form.termination_a_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_a_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_circuit' in form.fields %}
|
||||
{% render_field form.termination_a_circuit %}
|
||||
{% endif %}
|
||||
{% render_field form.a_terminations %}
|
||||
</div>
|
||||
|
||||
{# B side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "B Side" %}</h5>
|
||||
</div>
|
||||
{% if 'termination_b_device' in form.fields %}
|
||||
{% render_field form.termination_b_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_b_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_circuit' in form.fields %}
|
||||
{% render_field form.termination_b_circuit %}
|
||||
{% endif %}
|
||||
{% render_field form.b_terminations %}
|
||||
</div>
|
||||
|
||||
{# Cable attributes #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Cable" %}</h5>
|
||||
</div>
|
||||
{% render_field form.status %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.label %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.color %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form.length }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.length_unit }}
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Tenancy" %}</h5>
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">{% trans "Comments" %}</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% include 'dcim/htmx/cable_edit.html' %}
|
||||
{% endblock %}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
{% load static %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{# A side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "A Side" %}</h5>
|
||||
</div>
|
||||
{% render_field form.a_terminations_type %}
|
||||
{% if 'termination_a_device' in form.fields %}
|
||||
{% render_field form.termination_a_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_a_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_a_circuit' in form.fields %}
|
||||
{% render_field form.termination_a_circuit %}
|
||||
{% endif %}
|
||||
{% if 'a_terminations' in form.fields %}
|
||||
{% render_field form.a_terminations %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# B side termination #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "B Side" %}</h5>
|
||||
</div>
|
||||
{% render_field form.b_terminations_type %}
|
||||
{% if 'termination_b_device' in form.fields %}
|
||||
{% render_field form.termination_b_device %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_powerpanel' in form.fields %}
|
||||
{% render_field form.termination_b_powerpanel %}
|
||||
{% endif %}
|
||||
{% if 'termination_b_circuit' in form.fields %}
|
||||
{% render_field form.termination_b_circuit %}
|
||||
{% endif %}
|
||||
{% if 'b_terminations' in form.fields %}
|
||||
{% render_field form.b_terminations %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Cable attributes #}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Cable" %}</h5>
|
||||
</div>
|
||||
{% render_field form.status %}
|
||||
{% render_field form.type %}
|
||||
{% render_field form.label %}
|
||||
{% render_field form.description %}
|
||||
{% render_field form.color %}
|
||||
<div class="row mb-3">
|
||||
<label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
|
||||
<div class="col-md-5">
|
||||
{{ form.length }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
{{ form.length_unit }}
|
||||
</div>
|
||||
<div class="invalid-feedback"></div>
|
||||
</div>
|
||||
{% render_field form.tags %}
|
||||
</div>
|
||||
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Tenancy" %}</h5>
|
||||
</div>
|
||||
{% render_field form.tenant_group %}
|
||||
{% render_field form.tenant %}
|
||||
</div>
|
||||
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<h5 class="text-center">{% trans "Comments" %}</h5>
|
||||
{% render_field form.comments %}
|
||||
</div>
|
||||
{% endif %}
|
|
@ -1,12 +1,9 @@
|
|||
{% load i18n %}
|
||||
{% if perms.dcim.change_cable %}
|
||||
{% if cable.status == 'connected' %}
|
||||
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
|
||||
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-info btn-sm cable-toggle" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
|
||||
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button type="button" class="btn btn-warning btn-sm mark-planned" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
|
||||
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button type="button" class="btn btn-info btn-sm mark-installed" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
|
||||
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -74,7 +74,7 @@ class L2VPNTerminationTable(NetBoxTable):
|
|||
verbose_name=_('Object Site')
|
||||
)
|
||||
tags = columns.TagColumn(
|
||||
url_name='ipam:l2vpntermination_list'
|
||||
url_name='vpn:l2vpntermination_list'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
|
|
|
@ -15,20 +15,20 @@ django-tables2==2.7.0
|
|||
django-timezone-field==6.1.0
|
||||
djangorestframework==3.15.1
|
||||
drf-spectacular==0.27.2
|
||||
drf-spectacular-sidecar==2024.4.1
|
||||
drf-spectacular-sidecar==2024.5.1
|
||||
feedparser==6.0.11
|
||||
gunicorn==22.0.0
|
||||
Jinja2==3.1.3
|
||||
Markdown==3.6
|
||||
mkdocs-material==9.5.18
|
||||
mkdocstrings[python-legacy]==0.24.3
|
||||
mkdocs-material==9.5.20
|
||||
mkdocstrings[python-legacy]==0.25.0
|
||||
netaddr==1.2.1
|
||||
nh3==0.2.17
|
||||
Pillow==10.3.0
|
||||
psycopg[c,pool]==3.1.18
|
||||
PyYAML==6.0.1
|
||||
requests==2.31.0
|
||||
social-auth-app-django==5.4.0
|
||||
social-auth-app-django==5.4.1
|
||||
social-auth-core==4.5.4
|
||||
strawberry-graphql==0.227.2
|
||||
strawberry-graphql-django==0.34.0
|
||||
|
|
Loading…
Reference in New Issue