Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2024-05-01 16:09:14 -04:00
commit 312291b010
38 changed files with 786 additions and 566 deletions

View File

@ -26,7 +26,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.7.6 placeholder: v3.7.7
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.7.6 placeholder: v3.7.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,3 +14,7 @@ timeout = 120
# The maximum number of requests a worker can handle before being respawned # The maximum number of requests a worker can handle before being respawned
max_requests = 5000 max_requests = 5000
max_requests_jitter = 500 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'

View File

@ -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. 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) ### Single Sign-On (SSO)

View File

@ -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`.) 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 ## REMOTE_AUTH_USER_EMAIL

View File

@ -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 * `min_prefix_length` - Minimum length of the mask
* `max_prefix_length` - Maximum 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 ## Running Custom Scripts
!!! note !!! note

View File

@ -1,11 +1,36 @@
# NetBox v3.7 # 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) ## 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 ### Enhancements
* [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form * [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form

View File

@ -1,4 +1,5 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from circuits.models import Circuit, CircuitTermination from circuits.models import Circuit, CircuitTermination
@ -88,14 +89,22 @@ def get_cable_form(a_type, b_type):
class _CableForm(CableForm, metaclass=FormMetaclass): 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() # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
for field_name in ('a_terminations', 'b_terminations'): for field_name in ('a_terminations', 'b_terminations'):
if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list: if field_name in initial and type(initial[field_name]) is not list:
kwargs['initial'][field_name] = [kwargs['initial'][field_name]] initial[field_name] = [initial[field_name]]
super().__init__(*args, **kwargs) super().__init__(*args, initial=initial, **kwargs)
if self.instance and self.instance.pk: if self.instance and self.instance.pk:
# Initialize A/B terminations when modifying an existing Cable instance # Initialize A/B terminations when modifying an existing Cable instance
@ -106,7 +115,7 @@ def get_cable_form(a_type, b_type):
super().clean() super().clean()
# Set the A/B terminations on the Cable instance # Set the A/B terminations on the Cable instance
self.instance.a_terminations = self.cleaned_data['a_terminations'] self.instance.a_terminations = self.cleaned_data.get('a_terminations', [])
self.instance.b_terminations = self.cleaned_data['b_terminations'] self.instance.b_terminations = self.cleaned_data.get('b_terminations', [])
return _CableForm return _CableForm

View File

@ -628,14 +628,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
self.fields['adopt_components'].disabled = True 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): 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() comments = CommentField()
class Meta: class Meta:
model = Cable model = Cable
fields = [ fields = [
'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
'comments', 'tags', 'length', 'length_unit', 'description', 'comments', 'tags',
] ]
error_messages = { error_messages = {
'length': { 'length': {

View File

@ -8,17 +8,16 @@ from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.html import foreground_color from utilities.html import foreground_color
__all__ = ( __all__ = (
'CableTraceSVG', 'CableTraceSVG',
) )
OFFSET = 0.5 OFFSET = 0.5
PADDING = 10 PADDING = 10
LINE_HEIGHT = 20 LINE_HEIGHT = 20
FANOUT_HEIGHT = 35 FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15 FANOUT_LEG_HEIGHT = 15
CABLE_HEIGHT = 4 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
class Node(Hyperlink): class Node(Hyperlink):
@ -84,31 +83,38 @@ class Connector(Group):
labels: Iterable of text labels labels: Iterable of text labels
""" """
def __init__(self, start, url, color, labels=[], description=[], **extra): def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
super().__init__(class_='connector', **extra) super().__init__(class_="connector", **extra)
self.start = start self.start = start
self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 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' self.color = color or '000000'
# Draw a "shadow" line to give the cable a border if wireless:
cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow') # Draw the cable
self.add(cable_shadow) 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 # Draw the cable
cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}') cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
self.add(cable) self.add(cable)
# Add link # Add link
link = Hyperlink(href=url, target='_parent') link = Hyperlink(href=url, target='_parent')
# Add text label(s) # Add text label(s)
cursor = start[1] cursor = start[1] + text_offset
cursor += PADDING * 2 cursor += PADDING * 2 + LINE_HEIGHT * 2
x_coord = (start[0] + end[0]) / 2 + PADDING
for i, label in enumerate(labels): for i, label in enumerate(labels):
cursor += LINE_HEIGHT 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 []) text = Text(label, insert=text_coords, class_='bold' if not i else [])
link.add(text) link.add(text)
if len(description) > 0: if len(description) > 0:
@ -190,8 +196,9 @@ class CableTraceSVG:
def draw_parent_objects(self, obj_list): 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) width = self.width / len(obj_list)
for i, obj in enumerate(obj_list): for i, obj in enumerate(obj_list):
node = Node( node = Node(
@ -199,23 +206,26 @@ class CableTraceSVG:
width=width, width=width,
url=f'{self.base_url}{obj.get_absolute_url()}', url=f'{self.base_url}{obj.get_absolute_url()}',
color=self._get_color(obj), 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) self.parent_objects.append(node)
if i + 1 == len(obj_list): if i + 1 == len(obj_list):
self.cursor += node.box['height'] 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 nodes_height = 0
width = self.width / len(terminations) nodes = []
# Sort them by name to make renders more readable
for i, term in enumerate(terminations): for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
node = Node( node = Node(
position=(i * width, self.cursor), position=(offset_x + i * width, self.cursor),
width=width, width=width,
url=f'{self.base_url}{term.get_absolute_url()}', url=f'{self.base_url}{term.get_absolute_url()}',
color=self._get_color(term), color=self._get_color(term),
@ -225,133 +235,89 @@ class CableTraceSVG:
) )
nodes_height = max(nodes_height, node.box['height']) nodes_height = max(nodes_height, node.box['height'])
nodes.append(node) 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.cursor += nodes_height
self.terminations.extend(nodes) self.terminations.extend(nodes)
return nodes return nodes
def draw_fanin(self, node, connector): def draw_far_objects(self, obj_list, terminations):
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):
""" """
Draw a single cable. Terminations and cable count are passed for determining position and padding Draw the far-end objects and its terminations and return all created nodes
: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.
""" """
# 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 # Max-height of created terminations
if cable_count > 2: terms_height = 0
# Use the cable __str__ function to denote the cable term_nodes = []
labels = [f'{cable}']
# Include the label and the status description in the tooltip # Draw the terminations by per object first
description = [ for i, obj in enumerate(objects):
f'Cable {cable}', obj_terms = [term for term in terminations if term.parent_object == obj]
cable.get_status_display() obj_pos = i * width
] result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
if cable.type: terms_height = max(terms_height, result_nodes_height)
# Include the cable type in the tooltip term_nodes.extend(result)
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()}')
# If there is only one termination, center on that termination # Update cursor and draw the objects
# Otherwise average the center across the terminations self.cursor += terms_height
if len(terminations) == 1: self.terminations.extend(term_nodes)
center = terminations[0].bottom_center[0] object_nodes = self.draw_parent_objects(objects)
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)
# Create the connector return object_nodes, term_nodes
connector = Connector(
start=(center, self.cursor),
color=cable.color or '000000',
url=f'{self.base_url}{cable.get_absolute_url()}',
labels=labels,
description=description
)
# Set the cursor position def draw_fanin(self, target, terminations, color):
self.cursor += connector.height
return connector
def draw_wirelesslink(self, wirelesslink):
""" """
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 = [ def draw_fanout(self, start, terminations, color):
f'Wireless link {wirelesslink}', """
wirelesslink.get_status_display() Draw the fan-out-lines from the startpoint to each of the terminations
] """
if wirelesslink.ssid: for term in terminations:
labels.append(wirelesslink.ssid) points = (
term.top_center,
# Draw the wireless link (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
start = (OFFSET + self.center, self.cursor) start,
height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2 )
end = (start[0], start[1] + height) self.connectors.extend((
line = Line(start=start, end=end, class_='wireless-link') Polyline(points=points, class_='cable-shadow'),
group.add(line) Polyline(points=points, style=f'stroke: #{color}'),
))
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_attachment(self): def draw_attachment(self):
""" """
@ -378,86 +344,99 @@ class CableTraceSVG:
traced_path = self.origin.trace() traced_path = self.origin.trace()
parent_object_nodes = []
# Iterate through each (terms, cable, terms) segment in the path # Iterate through each (terms, cable, terms) segment in the path
for i, segment in enumerate(traced_path): for i, segment in enumerate(traced_path):
near_ends, links, far_ends = segment near_ends, links, far_ends = segment
# Near end parent # This is segment number one.
if i == 0: if i == 0:
# If this is the first segment, draw the originating termination's parent object # 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) near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
terminations = self.draw_terminations(near_ends) self.cursor += CABLE_HEIGHT
# Connector (a Cable or WirelessLink) # Connector (a Cable or WirelessLink)
if links: if links:
link_cables = {}
fanin = False
fanout = False
# Determine if we have fanins or fanouts parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
if len(near_ends) > len(set(links)): for cable in links:
self.cursor += FANOUT_HEIGHT # Fill in labels and description with all available data
fanin = True description = [
if len(far_ends) > len(set(links)): f"Link {cable}",
fanout = True cable.get_status_display()
cursor = self.cursor ]
for link in links: near = []
# Cable far = []
if type(link) is Cable and not link_cables.get(link.pk): color = '000000'
# Reset cursor if cable.description:
self.cursor = cursor description.append(f"{cable.description}")
# Generate a list of terminations connected to this cable if isinstance(cable, Cable):
near_end_link_terminations = [term for term in terminations if term.object.cable == link] labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
# Draw the cable if cable.type:
cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links)) description.append(cable.get_type_display())
# Add cable to the list of cables if cable.length and cable.length_unit:
link_cables.update({link.pk: cable}) description.append(f"{cable.length} {cable.get_length_unit_display()}")
# Add cable to drawing color = cable.color or '000000'
self.connectors.append(cable)
# Draw fan-ins # Collect all connected nodes to this cable
if len(near_ends) > 1 and fanin: near = [term for term in near_terminations if term.object in cable.a_terminations]
for term in terminations: far = [term for term in far_terminations if term.object in cable.b_terminations]
if term.object.cable == link: if not (near and far):
self.draw_fanin(term, cable) # 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 # Select most-probable start and end position
elif type(link) is WirelessLink: start = near[0].bottom_center
wirelesslink = self.draw_wirelesslink(link) end = far[0].top_center
self.connectors.append(wirelesslink) text_offset = 0
# Far end termination(s) if len(near) > 1:
if len(far_ends) > 1: # Handle Fan-In - change start position to be directly below start
if fanout: start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
self.cursor += FANOUT_HEIGHT self.draw_fanin(start, near, color)
terminations = self.draw_terminations(far_ends) text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
for term in terminations: elif len(far) > 1:
if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk): # Handle Fan-Out - change end position to be directly above end
self.draw_fanout(term, link_cables.get(term.object.cable.pk)) end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
else: self.draw_fanout(end, far, color)
self.draw_terminations(far_ends) text_offset -= FANOUT_HEIGHT
elif far_ends:
self.draw_terminations(far_ends)
else:
# Link is not connected to anything
break
# Far end parent # Create the connector
parent_objects = set(end.parent_object for end in far_ends) connector = Connector(
self.draw_parent_objects(parent_objects) 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 # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
# a CircuitTermination) # a CircuitTermination)
elif far_ends: elif far_ends:
# Attachment # Attachment
attachment = self.draw_attachment() attachment = self.draw_attachment()
self.connectors.append(attachment) self.connectors.append(attachment)
# Object # Object
self.draw_parent_objects(far_ends) parent_object_nodes = self.draw_parent_objects(far_ends)
# Determine drawing size # Determine drawing size
self.drawing = svgwrite.Drawing( self.drawing = svgwrite.Drawing(

View File

@ -51,34 +51,6 @@ def get_cabletermination_row_class(record):
return '' 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 # Device roles
# #
@ -706,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable):
'cable', 'connection', 'cable', 'connection',
) )
row_attrs = { row_attrs = {
'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': lambda record: "enabled" if record.enabled else "disabled",
'data-type': lambda record: record.type, 'data-virtual': lambda record: "true" if record.is_virtual else "false",
'data-connected': get_interface_connected_attribute '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
} }

View File

@ -3177,34 +3177,29 @@ class CableView(generic.ObjectView):
class CableEditView(generic.ObjectEditView): class CableEditView(generic.ObjectEditView):
queryset = Cable.objects.all() queryset = Cable.objects.all()
template_name = 'dcim/cable_edit.html' template_name = 'dcim/cable_edit.html'
htmx_template_name = 'dcim/htmx/cable_edit.html'
def dispatch(self, request, *args, **kwargs): def alter_object(self, obj, request, url_args, url_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):
""" """
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. 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: if obj.pk:
# TODO: Optimize this logic if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()):
termination_a = obj.terminations.filter(cable_end='A').first() a_terminations_type = termination_a.termination._meta.model
a_type = termination_a.termination._meta.model if termination_a else None if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()):
termination_b = obj.terminations.filter(cable_end='B').first() b_terminations_type = termination_b.termination._meta.model
b_type = termination_b.termination._meta.model if termination_b else None
self.form = forms.get_cable_form(a_type, b_type)
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): def get_extra_addanother_params(self, request):

View File

@ -68,6 +68,7 @@ class Migration(migrations.Migration):
], ],
options={ options={
'proxy': True, 'proxy': True,
'ordering': ('file_root', 'file_path'),
'indexes': [], 'indexes': [],
'constraints': [], 'constraints': [],
}, },
@ -79,6 +80,7 @@ class Migration(migrations.Migration):
], ],
options={ options={
'proxy': True, 'proxy': True,
'ordering': ('file_root', 'file_path'),
'indexes': [], 'indexes': [],
'constraints': [], 'constraints': [],
}, },

View File

@ -1,4 +1,5 @@
import decimal import decimal
import json
import re import re
from datetime import datetime, date from datetime import datetime, date
@ -488,7 +489,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON # JSON
elif self.type == CustomFieldTypeChoices.TYPE_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 # Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

View File

@ -97,8 +97,16 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
""" """
objects = ScriptModuleManager() 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: class Meta:
proxy = True proxy = True
ordering = ('file_root', 'file_path')
verbose_name = _('script module') verbose_name = _('script module')
verbose_name_plural = _('script modules') verbose_name_plural = _('script modules')

View File

@ -24,6 +24,7 @@ from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator,
from utilities.exceptions import AbortScript, AbortTransaction from utilities.exceptions import AbortScript, AbortTransaction
from utilities.forms import add_blank_choice from utilities.forms import add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.widgets import DatePicker, DateTimePicker
from .context_managers import event_tracking from .context_managers import event_tracking
from .forms import ScriptForm from .forms import ScriptForm
from .utils import is_report from .utils import is_report
@ -33,6 +34,8 @@ __all__ = (
'BaseScript', 'BaseScript',
'BooleanVar', 'BooleanVar',
'ChoiceVar', 'ChoiceVar',
'DateVar',
'DateTimeVar',
'FileVar', 'FileVar',
'IntegerVar', 'IntegerVar',
'IPAddressVar', 'IPAddressVar',
@ -174,6 +177,28 @@ class ChoiceVar(ScriptVariable):
self.field_attrs['choices'] = add_blank_choice(choices) 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): class MultiChoiceVar(ScriptVariable):
""" """
Like ChoiceVar, but allows for the selection of multiple choices. Like ChoiceVar, but allows for the selection of multiple choices.

View File

@ -419,15 +419,35 @@ class ConfigTemplateTable(NetBoxTable):
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='extras:configtemplate_list' 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): class Meta(NetBoxTable.Meta):
model = ConfigTemplate model = ConfigTemplate
fields = ( fields = (
'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated', 'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
'tags', 'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
) )
default_columns = ( default_columns = (
'pk', 'name', 'description', 'is_synced', 'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
) )

View File

@ -1,4 +1,5 @@
import tempfile import tempfile
from datetime import date, datetime, timezone
from django.core.files.uploadedfile import SimpleUploadedFile from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase from django.test import TestCase
@ -322,3 +323,47 @@ class ScriptVariablesTest(TestCase):
form = TestScript().as_form(data, None) form = TestScript().as_form(data, None)
self.assertTrue(form.is_valid()) self.assertTrue(form.is_valid())
self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1'])) 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)

View File

@ -13,6 +13,7 @@ from core.choices import ManagedFileRootPathChoices
from core.forms import ManagedFileForm from core.forms import ManagedFileForm
from core.models import Job from core.models import Job
from core.tables import JobTable from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS 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.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .models import * from .models import *
from .scripts import run_script from .scripts import run_script
@ -627,7 +629,12 @@ class ObjectConfigContextView(generic.ObjectView):
# #
class ConfigTemplateListView(generic.ObjectListView): 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 = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable table = tables.ConfigTemplateTable
@ -1035,7 +1042,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script' return 'extras.view_script'
def get(self, request): 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', { return render(request, 'extras/script_list.html', {
'model': ScriptModule, 'model': ScriptModule,

View File

@ -692,7 +692,7 @@ class IPRange(PrimaryModel):
ip.address.ip for ip in self.get_child_ips() ip.address.ip for ip in self.get_child_ips()
]).size ]).size
return int(float(child_count) / self.size * 100) return min(float(child_count) / self.size * 100, 100)
class IPAddress(PrimaryModel): class IPAddress(PrimaryModel):

View File

@ -1,3 +1,5 @@
import json
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
@ -35,7 +37,11 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
def _get_form_field(self, customfield): def _get_form_field(self, customfield):
if self.instance.pk: if self.instance.pk:
form_field = customfield.to_form_field(set_initial=False) 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 form_field
return customfield.to_form_field() return customfield.to_form_field()

View File

@ -1,6 +1,7 @@
from django.conf import settings from django.conf import settings
from django.conf.urls import include from django.conf.urls import include
from django.urls import path from django.urls import path
from django.views.decorators.cache import cache_page
from django.views.static import serve from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView 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.graphql.views import NetBoxGraphQLView
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
from strawberry.django.views import GraphQLView
_patterns = [ _patterns = [
@ -55,7 +55,13 @@ _patterns = [
path('api/wireless/', include('wireless.api.urls')), path('api/wireless/', include('wireless.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'), 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/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'), path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),

View File

@ -167,6 +167,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
""" """
template_name = 'generic/object_edit.html' template_name = 'generic/object_edit.html'
form = None form = None
htmx_template_name = 'htmx/form.html'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
# Determine required permission based on whether we are editing an existing object # 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 this is an HTMX request, return only the rendered form HTML
if htmx_partial(request): if htmx_partial(request):
return render(request, 'htmx/form.html', { return render(request, self.htmx_template_name, {
'form': form, 'form': form,
}) })
@ -339,10 +340,14 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
# Compile a mapping of models to instances # Compile a mapping of models to instances
dependent_objects = defaultdict(list) 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 # Omit the root object
if instance != obj: if instances == obj:
dependent_objects[model].append(instance) continue
dependent_objects[model].append(instances)
return dict(dependent_objects) return dict(dependent_objects)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -7,10 +7,10 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
* *
* @param element Connection Toggle Button Element * @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 url = element.getAttribute('data-url');
const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected';
if (isTruthy(url)) { if (isTruthy(url)) {
apiPatch(url, { status }).then(res => { apiPatch(url, { status }).then(res => {
@ -19,34 +19,18 @@ function toggleConnection(element: HTMLButtonElement): void {
createToast('danger', 'Error', res.error).show(); createToast('danger', 'Error', res.error).show();
return; return;
} else { } else {
// Get the button's row to change its styles. // Update cable status in DOM
const row = element.parentElement?.parentElement as HTMLTableRowElement; row.setAttribute('data-cable-status', status);
// 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');
}
} }
}); });
} }
} }
export function initConnectionToggle(): void { export function initConnectionToggle(): void {
for (const element of getElements<HTMLButtonElement>('button.cable-toggle')) { for (const element of getElements<HTMLButtonElement>('button.mark-planned')) {
element.addEventListener('click', () => toggleConnection(element)); element.addEventListener('click', () => setConnectionStatus(element, 'planned'));
}
for (const element of getElements<HTMLButtonElement>('button.mark-installed')) {
element.addEventListener('click', () => setConnectionStatus(element, 'connected'));
} }
} }

View File

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

View File

@ -20,5 +20,6 @@
// Custom styling // Custom styling
@import 'custom/code'; @import 'custom/code';
@import 'custom/interfaces';
@import 'custom/markdown'; @import 'custom/markdown';
@import 'custom/misc'; @import 'custom/misc';

View File

@ -99,7 +99,7 @@ Blocks:
{% endif %} {% endif %}
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} {% 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 %} {% endif %}
{# /Alerts #} {# /Alerts #}

View File

@ -1,90 +1,5 @@
{% extends 'generic/object_edit.html' %} {% extends 'generic/object_edit.html' %}
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% block form %} {% block form %}
{% include 'dcim/htmx/cable_edit.html' %}
{# 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 %}
{% endblock %} {% endblock %}

View File

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

View File

@ -1,12 +1,9 @@
{% load i18n %} {% load i18n %}
{% if perms.dcim.change_cable %} {% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %} <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 %}">
<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>
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i> </button>
</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 %}">
{% else %} <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
<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 %}"> </button>
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button>
{% endif %}
{% endif %} {% endif %}

File diff suppressed because it is too large Load Diff

View File

@ -74,7 +74,7 @@ class L2VPNTerminationTable(NetBoxTable):
verbose_name=_('Object Site') verbose_name=_('Object Site')
) )
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='ipam:l2vpntermination_list' url_name='vpn:l2vpntermination_list'
) )
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):

View File

@ -15,20 +15,20 @@ django-tables2==2.7.0
django-timezone-field==6.1.0 django-timezone-field==6.1.0
djangorestframework==3.15.1 djangorestframework==3.15.1
drf-spectacular==0.27.2 drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.4.1 drf-spectacular-sidecar==2024.5.1
feedparser==6.0.11 feedparser==6.0.11
gunicorn==22.0.0 gunicorn==22.0.0
Jinja2==3.1.3 Jinja2==3.1.3
Markdown==3.6 Markdown==3.6
mkdocs-material==9.5.18 mkdocs-material==9.5.20
mkdocstrings[python-legacy]==0.24.3 mkdocstrings[python-legacy]==0.25.0
netaddr==1.2.1 netaddr==1.2.1
nh3==0.2.17 nh3==0.2.17
Pillow==10.3.0 Pillow==10.3.0
psycopg[c,pool]==3.1.18 psycopg[c,pool]==3.1.18
PyYAML==6.0.1 PyYAML==6.0.1
requests==2.31.0 requests==2.31.0
social-auth-app-django==5.4.0 social-auth-app-django==5.4.1
social-auth-core==4.5.4 social-auth-core==4.5.4
strawberry-graphql==0.227.2 strawberry-graphql==0.227.2
strawberry-graphql-django==0.34.0 strawberry-graphql-django==0.34.0