Compare commits

...

48 Commits

Author SHA1 Message Date
Jeremy Stretch
be903a64a2 Release v3.7.8 2024-05-06 12:54:53 -04:00
transifex-integration[bot]
0d7bac433e Translate django.po in ja
100% translated source file: 'django.po'
on 'ja'.
2024-05-06 12:54:53 -04:00
Jeremy Stretch
b1cfbbc472 Fixes #15960: Use internal ManyToManyColumn to ensure proper export behavior 2024-05-06 12:54:53 -04:00
Jeremy Stretch
6dd311f600 Fixes #15961: Fix secret toggle button by avoiding duplicate event handler 2024-05-06 12:54:53 -04:00
Daniel Sheppard
85d250014f Fixes: #15948 - Fixes cable fanin/fanout when both are required (#15953)
* Preliminary fix for #15948

* Tweaking of line height
2024-05-06 12:54:53 -04:00
Arthur
552c81509a 12127 enable cable add button 2024-05-06 12:54:53 -04:00
Jeremy Stretch
ed7a0a32cc Changelog for #15877, #15917, #15925 2024-05-06 12:54:53 -04:00
Nancy Yang
a544b55e9e Fixes #15917: slim-select-pagination-bug-fix : fixed several bugs related to slim select (#15918)
* slim-select-pagination-bug-fix : fixed several bugs related to slim
select search box gui element
1. If user enters a search text in the filter text box, the user will
   not be able to scroll to the next page. That is the user will only be
   able to see the first page of returned item with a none empty search
   string.
2. User will not be able to select an item returned from search query
   if user clicks reload after a dynami search. When the user is able
   to load a second page, the user will be able to select an item from
   the third+ page if previous bug is fixed.

* Recompile static assets

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-06 12:54:53 -04:00
Jeremy Stretch
53e1ab5fc5 Fixes #15877: Consider VC membership when assigning LAG interfaces via bulk edit 2024-05-06 12:54:53 -04:00
Jeremy Stretch
2c1a9ae455 Fixes #15925: Fix rendering of cable traces to circuit terminations 2024-05-06 12:54:53 -04:00
Jeremy Stretch
1afa476a19 PRVB 2024-05-06 12:54:53 -04:00
Jeremy Stretch
2c06616a1d Merge pull request #15911 from netbox-community/develop
Release v3.7.7
2024-05-01 15:24:12 -04:00
Jeremy Stretch
335a8d6449 Release v3.7.7 2024-05-01 15:08:08 -04:00
Jeremy Stretch
340f9f4fa8 Changelog for #11460, #15891, #15894, #15896, #15899; add warning for #15811 2024-05-01 14:52:15 -04:00
Daniel Sheppard
c08784da46 Fixes #11460 - Fix unterminated cable exception when editing cable (#15813)
* Fix cable edit form with single unterminated cable

* Minor tweaks

* Instead of skipping HTMX, override the template & move form template to an "htmx" template

* Use HTMXSelect widget for A/B type selection

* Infer A/B termination types from POST data

* Fix saving cable which results in resetting of the termination type fields

* Condense view logic

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-05-01 14:24:50 -04:00
Jeremy Stretch
a2efec09be Fixes #15891: Ensure deterministic ordering for scripts & reports 2024-05-01 10:46:25 -04:00
Mattias Loverot
d256c04d9c Added caching on /api/schema/ endpoint (closes #15894) 2024-05-01 08:48:46 -04:00
Jeremy Stretch
365bb4ba17 Fixes #15896: Retain proper formatting for JSON custom field default values 2024-04-30 16:24:26 -04:00
Jeremy Stretch
11816b45e7 Fixes #15899: Correct the view name for the tags column on L2VPNTerminationTable 2024-04-30 15:11:54 -04:00
Jeremy Stretch
693c6e4da5 Changelog for #14852, #15428, #15524, #15548, #15812, #15845, #15872 2024-04-29 17:55:14 -04:00
Jeremy Stretch
c73a974fa9 Closes #15811: Note potential incompatibilities for remote auth headers containing underscores 2024-04-29 16:46:56 -04:00
Arthur
4b21cf604b 14852 delete event-rule when delete script 2024-04-29 15:02:39 -04:00
Julio Oliveira at Encora
79b9dc2013 Feature #15428 - Show all devices with configuration template attached (#15822)
* Added devices instances column for config templates.

* Added devices instances column for config templates.

* Add counts for VMs, roles, and platforms

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-29 14:15:44 -04:00
Jeremy Stretch
0e3c35ae58 Fixes #15548: Ignore many-to-many mappings when checking dependencies of an object being deleted 2024-04-29 13:37:38 -04:00
Arthur Hanson
cbfed83f60 15524 round iprange utilization (#15734) 2024-04-29 13:19:57 -04:00
JCWasmx86
3cbade536e Fixes #15812: Add Date(Time)Var for scripts to allow much easier date… (#15821)
* Fixes #15812: Add Date(Time)Var for scripts to allow much easier date input

* Extend tests for invalid data

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2024-04-29 12:46:39 -04:00
Arthur Hanson
9691bb29b6 15872 don't escape BANNER_MAINTENANCE (#15885)
* 15872 don't escape BANNER_MAINTENANCE

* 15872 don't escape BANNER_MAINTENANCE
2024-04-29 12:34:29 -04:00
Mattias Loverot
851b4cc4d3 Added assigned_object_type in prefetch for api view IPAddressViewSet - fixes #15845 2024-04-29 10:50:08 -04:00
Daniel Sheppard
85db007ff5 Update changelog for #14750 2024-04-22 21:57:40 -05:00
Daniel Sheppard
cad3e34d8f Merge pull request #14750 from Moehritz/13922-svg-uneven
Fixes #14241, Fixes #13922: Update the CableRender
2024-04-22 21:53:34 -05:00
Daniel Sheppard
7b1b91b8ee Correct wording for #13874 2024-04-22 21:51:54 -05:00
Daniel Sheppard
6f36b8513c Update changelog for #13874 2024-04-22 21:51:08 -05:00
Daniel Sheppard
07e2cf0ad2 Merge pull request #13874 from pv2b/choices-css-rewrite
Refactor row coloring logic and simplify mark planned/connected toggle implementation
2024-04-22 21:45:15 -05:00
Jeremy Stretch
d606cf1b3c Update source translations 2024-04-22 15:50:38 -04:00
Jeremy Stretch
0b0dab42eb PRVB 2024-04-22 12:23:31 -04:00
Per von Zweigbergk
8fadd6b744 Merge branch 'develop' into choices-css-rewrite 2024-01-23 21:50:06 +01:00
Per von Zweigbergk
c93413dc9c Move interface colour logic into SCSS where it belongs 2024-01-23 21:33:09 +01:00
Per von Zweigbergk
bf362f4679 Hardcode cable status colours 2024-01-23 20:58:10 +01:00
Per von Zweigbergk
da7f67c359 Refactor noisy getter methods into neat lambdas 2024-01-23 20:49:10 +01:00
Moritz Geist
2c93dd03e1 account for swapped terminations in cable object
also remove out-of-scope changes to tooltips
2024-01-10 14:29:46 +01:00
Moritz Geist
ced44832f7 Remove dangling logging message used during development 2024-01-09 14:22:36 +01:00
Moritz Geist
6af3aad362 Fixes #14722, Fixes #13922: Update the CableRender
This commit updates the cable rendering logic to fix
both issue #14722 and #13922. Before, objects, terminations
and cables where drawn in the svg without context of each
other.
Now the following changes are applied:
- Hosts and Terminations are where possible sorted alphabetically
- Terminations and Cables are visually connected, and if necessary not in a vertical line
- Terminations and Hosts are visually aligning
- Cable Tooltips contain more information
2024-01-09 13:51:09 +01:00
Per von Zweigbergk
c728d3c2e8 Fix formatting 2023-09-24 00:08:39 +02:00
Per von Zweigbergk
83e2c45e74 Simplify mark connected/installed implementation
Fixes: #13712 and #13806.
2023-09-23 23:45:08 +02:00
Per von Zweigbergk
27864ec865 Move DeviceInterfaceTable coloring logic into CSS
Preparatory work for simplifying toggle button code for cable status.
2023-09-23 23:07:16 +02:00
Per von Zweigbergk
d44f67aea5 Add 15% alpha variants of --nbx-color
Preparatory work for factoring row styling out of Python
2023-09-23 23:01:08 +02:00
Per von Zweigbergk
41e1f24cf7 Add --nbx-color-* variables for theme colors
Preparatory work for moving row styling to CSS
2023-09-23 21:43:32 +02:00
Per von Zweigbergk
d76ede17d3 Add data properties for device interface table
Preparatory work for factoring row styling decisions out of Python code.
2023-09-23 21:33:47 +02:00
51 changed files with 1238 additions and 836 deletions

View File

@@ -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.8
validations:
required: true
- type: dropdown

View File

@@ -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.8
validations:
required: true
- type: dropdown

View File

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

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

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`.)
!!! 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

View File

@@ -285,6 +285,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

View File

@@ -1,7 +1,53 @@
# NetBox v3.7
## v3.7.8 (2024-05-06)
### Enhancements
* [#12127](https://github.com/netbox-community/netbox/issues/12127) - Enable adding new cables directly from navigation menu
### Bug Fixes
* [#15877](https://github.com/netbox-community/netbox/issues/15877) - Account for virtual chassis membership when assigning related interfaces via bulk edit
* [#15917](https://github.com/netbox-community/netbox/issues/15917) - Fix pagination through search results within dropdown fields
* [#15925](https://github.com/netbox-community/netbox/issues/15925) - Fix SVG rendering of cable traces to circuit terminations
* [#15948](https://github.com/netbox-community/netbox/issues/15948) - Fix cable trace SVG generation for cables with multiple terminations at both ends
* [#15960](https://github.com/netbox-community/netbox/issues/15960) - Replace CSV export formatting for several many-to-many fields
* [#15961](https://github.com/netbox-community/netbox/issues/15961) - Fix secret toggle button for IKE policies
---
## 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

View File

@@ -1411,9 +1411,9 @@ class InterfaceBulkEditForm(
device = Device.objects.filter(pk=self.initial['device']).first()
# Restrict parent/bridge/LAG interface assignment by device
self.fields['parent'].widget.add_query_param('device_id', device.pk)
self.fields['bridge'].widget.add_query_param('device_id', device.pk)
self.fields['lag'].widget.add_query_param('device_id', device.pk)
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk)
self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk)
# Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)

View File

@@ -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
@@ -82,14 +83,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
@@ -100,7 +109,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

View File

@@ -13,8 +13,7 @@ from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm
from utilities.forms import BootstrapMixin, add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField,
NumericArrayField, SlugField,
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
from utilities.forms.widgets import APISelect, ClearableFileInput, HTMXSelect, NumberWithOptions, SelectWithPK
from virtualization.models import Cluster
@@ -616,14 +615,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': {

View File

@@ -8,17 +8,16 @@ from django.conf import settings
from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
from utilities.utils import foreground_color
__all__ = (
'CableTraceSVG',
)
OFFSET = 0.5
PADDING = 10
LINE_HEIGHT = 20
FANOUT_HEIGHT = 35
FANOUT_LEG_HEIGHT = 15
CABLE_HEIGHT = 5 * 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: str(x))):
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: str(x))
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,110 @@ 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)
obj_list = {end.parent_object for end in far_ends}
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, 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 and len(far) > 1:
start_center = sum([pos.bottom_center[0] for pos in near]) / len(near)
end_center = sum([pos.bottom_center[0] for pos in far]) / len(far)
center_x = (start_center + end_center) / 2
# Far end parent
parent_objects = set(end.parent_object for end in far_ends)
self.draw_parent_objects(parent_objects)
start = (center_x, start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
end = (center_x, end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
text_offset -= (FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
self.draw_fanin(start, near, color)
self.draw_fanout(end, far, color)
elif 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
# 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(

View File

@@ -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
#
@@ -646,7 +618,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
verbose_name=_('VRF'),
linkify=True
)
inventory_items = tables.ManyToManyColumn(
inventory_items = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Inventory Items'),
)
@@ -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
}

View File

@@ -394,6 +394,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
@@ -450,6 +453,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
@@ -558,6 +564,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 3
cable3.delete()
@@ -673,6 +682,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 3
cable3.delete()
@@ -804,6 +816,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 3
cable3.delete()
@@ -931,6 +946,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 5
cable5.delete()
@@ -1034,6 +1052,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 3
cable3.delete()
@@ -1093,6 +1114,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 3)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 1
cable1.delete()
@@ -1135,6 +1159,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 1)
# Test SVG generation
CableTraceSVG(interface1).render()
def test_210_interface_to_circuittermination(self):
"""
[IF1] --C1-- [CT1]
@@ -1156,6 +1183,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 1)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
@@ -1212,6 +1242,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
@@ -1277,6 +1310,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
@@ -1314,6 +1350,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 1)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
@@ -1342,6 +1381,9 @@ class CablePathTestCase(TestCase):
self.assertEqual(CablePath.objects.count(), 1)
self.assertTrue(CablePath.objects.first().is_complete)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
@@ -1439,6 +1481,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cables 3-4
cable3.delete()
cable4.delete()
@@ -1495,6 +1540,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
@@ -1578,6 +1626,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 2
cable2.delete()
@@ -1697,6 +1748,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 4)
# Test SVG generation
CableTraceSVG(interface1).render()
# Delete cable 3
cable3.delete()
@@ -1784,6 +1838,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 2)
# Test SVG generation
CableTraceSVG(interface1).render()
def test_220_interface_to_interface_duplex_via_multiple_front_and_rear_ports(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]
@@ -1877,6 +1934,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 3)
# Test SVG generation
CableTraceSVG(interface1).render()
def test_221_non_symmetric_paths(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- -------------------------------------- [IF2]
@@ -1997,6 +2057,9 @@ class CablePathTestCase(TestCase):
)
self.assertEqual(CablePath.objects.count(), 3)
# Test SVG generation
CableTraceSVG(interface1).render()
def test_301_create_path_via_existing_cable(self):
"""
[IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

@@ -3166,12 +3166,6 @@ class CableListView(generic.ObjectListView):
filterset = filtersets.CableFilterSet
filterset_form = forms.CableFilterForm
table = tables.CableTable
actions = {
'import': {'add'},
'export': {'view'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(Cable)
@@ -3183,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):

View File

@@ -89,8 +89,11 @@ class EventRuleSerializer(NetBoxModelSerializer):
# We need to manually instantiate the serializer for scripts
if instance.action_type == EventRuleActionChoices.SCRIPT:
script_name = instance.action_parameters['script_name']
script = instance.action_object.scripts[script_name]()
return NestedScriptSerializer(script, context=context).data
if script_name in instance.action_object.scripts:
script = instance.action_object.scripts[script_name]()
return NestedScriptSerializer(script, context=context).data
else:
return None
else:
serializer = get_serializer_for_model(
model=instance.action_object_type.model_class(),

View File

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

View File

@@ -1,4 +1,5 @@
import decimal
import json
import re
from datetime import datetime, date
@@ -484,7 +485,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:

View File

@@ -43,6 +43,7 @@ class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
class Meta:
proxy = True
ordering = ('file_root', 'file_path')
verbose_name = _('report module')
verbose_name_plural = _('report modules')

View File

@@ -2,6 +2,7 @@ import inspect
import logging
from functools import cached_property
from django.contrib.contenttypes.fields import GenericRelation
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@@ -41,8 +42,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')

View File

@@ -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
@@ -31,6 +32,8 @@ __all__ = (
'BaseScript',
'BooleanVar',
'ChoiceVar',
'DateVar',
'DateTimeVar',
'FileVar',
'IntegerVar',
'IPAddressVar',
@@ -172,6 +175,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.

View File

@@ -414,15 +414,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',
)

View File

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

View File

@@ -13,6 +13,7 @@ from core.choices import JobStatusChoices, 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
@@ -24,6 +25,7 @@ from utilities.rqworker import get_workers_for_queue
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .forms.reports import ReportForm
from .models import *
@@ -624,7 +626,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,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_report'
def get(self, request):
report_modules = ReportModule.objects.restrict(request.user)
report_modules = ReportModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file')
return render(request, 'extras/report_list.html', {
'model': ReportModule,
@@ -1210,7 +1217,7 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
return 'extras.view_script'
def get(self, request):
script_modules = ScriptModule.objects.restrict(request.user)
script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('data_source', 'data_file')
return render(request, 'extras/script_list.html', {
'model': ScriptModule,

View File

@@ -119,7 +119,7 @@ class IPRangeViewSet(NetBoxModelViewSet):
class IPAddressViewSet(NetBoxModelViewSet):
queryset = IPAddress.objects.prefetch_related(
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object'
'vrf__tenant', 'tenant', 'nat_inside', 'nat_outside', 'tags', 'assigned_object', 'assigned_object_type'
)
serializer_class = serializers.IPAddressSerializer
filterset_class = filtersets.IPAddressFilterSet

View File

@@ -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):

View File

@@ -378,7 +378,7 @@ class IPAddressTable(TenancyColumnsMixin, NetBoxTable):
orderable=False,
verbose_name=_('NAT (Inside)')
)
nat_outside = tables.ManyToManyColumn(
nat_outside = columns.ManyToManyColumn(
linkify_item=True,
orderable=False,
verbose_name=_('NAT (Outside)')

View File

@@ -1,3 +1,5 @@
import json
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.db.models import Q
@@ -34,7 +36,11 @@ class NetBoxModelForm(BootstrapMixin, CheckLastUpdatedMixin, CustomFieldsMixin,
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()

View File

@@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu(
MenuGroup(
label=_('Connections'),
items=(
get_model_item('dcim', 'cable', _('Cables'), actions=['import']),
get_model_item('dcim', 'cable', _('Cables')),
get_model_item('wireless', 'wirelesslink', _('Wireless Links')),
MenuItem(
link='dcim:interface_connections_list',

View File

@@ -28,7 +28,7 @@ from netbox.plugins import PluginConfig
# Environment setup
#
VERSION = '3.7.6'
VERSION = '3.7.8'
# Hostname
HOSTNAME = platform.node()

View File

@@ -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.decorators.csrf import csrf_exempt
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
@@ -56,7 +57,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'),

View File

@@ -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 is_htmx(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

View File

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

View File

@@ -60,18 +60,17 @@ function handleSecretToggle(state: StateManager<SecretState>, button: HTMLButton
toggleSecretButton(hidden, button);
}
function toggleCallback(event: MouseEvent) {
handleSecretToggle(secretState, event.currentTarget as HTMLButtonElement);
}
/**
* Initialize secret toggle button.
*/
export function initSecretToggle(): void {
hideSecret();
for (const button of getElements<HTMLButtonElement>('button.toggle-secret')) {
button.addEventListener(
'click',
event => {
handleSecretToggle(secretState, event.currentTarget as HTMLButtonElement);
},
false,
);
button.removeEventListener('click', toggleCallback);
button.addEventListener('click', toggleCallback);
}
}

View File

@@ -140,6 +140,10 @@ export class APISelect {
*/
private queryUrl: string = '';
/**
* Interal state variable used to remember search key entered by user for "Filter" search box
*/
private searchKey: Nullable<string> = null;
/**
* Scroll position of options is at the bottom of the list, or not. Used to determine if
* additional options should be fetched from the API.
@@ -359,30 +363,41 @@ export class APISelect {
this.slim.enable();
}
private setSearchKey(event: Event) {
const { value: q } = event.target as HTMLInputElement;
this.searchKey = q
}
/**
* Add event listeners to this element and its dependencies so that when dependencies change
* this element's options are updated.
*/
private addEventListeners(): void {
// Create a debounced function to fetch options based on the search input value.
const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
const fetcher = debounce((action:ApplyMethod, url: Nullable<string>) => this.handleSearch(action, url), 300, false);
// Query the API when the input value changes or a value is pasted.
this.slim.slim.search.input.addEventListener('keyup', event => {
// Only search when necessary keys are pressed.
if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
return fetcher(event);
this.setSearchKey(event);
return fetcher('replace', null);
}
});
this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
this.slim.slim.search.input.addEventListener('paste', event => {
this.setSearchKey(event);
return fetcher('replace', null);;
});
// Watch every scroll event to determine if the scroll position is at bottom.
this.slim.slim.list.addEventListener('scroll', () => this.handleScroll());
// When the scroll position is at bottom, fetch additional options.
this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () =>
this.fetchOptions(this.more, 'merge'),
);
this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () => {
if (this.more!=null) {
return fetcher('merge', this.more, )
}
});
// When the base select element is disabled or enabled, properly disable/enable this instance.
this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
@@ -551,6 +566,14 @@ export class APISelect {
}
}
private getUrl() {
var url = this.queryUrl
if (this.searchKey!=null) {
url = queryString.stringifyUrl({ url: this.queryUrl, query: { q : this.searchKey } })
}
return url
}
/**
* Query the NetBox API for this element's options.
*/
@@ -559,21 +582,25 @@ export class APISelect {
this.resetOptions();
return;
}
await this.fetchOptions(this.queryUrl, action);
const url = this.getUrl()
await this.fetchOptions(url, action);
}
/**
* Query the API for a specific search pattern and add the results to the available options.
*/
private async handleSearch(event: Event) {
const { value: q } = event.target as HTMLInputElement;
const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
if (!url.includes(`{{`)) {
await this.fetchOptions(url, 'merge');
this.slim.data.search(q);
this.slim.render();
private async handleSearch(action: ApplyMethod = 'merge', url: Nullable<string> ) {
if (url==null) {
url = this.getUrl()
}
return;
if (url.includes(`{{`)) {
return
}
await this.fetchOptions(url, action);
if (this.searchKey!=null) {
this.slim.data.search(this.searchKey);
}
this.slim.render();
}
/**
@@ -586,13 +613,11 @@ export class APISelect {
Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight ===
this.slim.slim.list.scrollHeight;
if (this.atBottom && !atBottom) {
this.atBottom = false;
this.atBottom = atBottom
if (this.atBottom) {
this.base.dispatchEvent(this.bottomEvent);
} else if (!this.atBottom && atBottom) {
this.atBottom = true;
this.base.dispatchEvent(this.bottomEvent);
}
}
}
/**
@@ -994,7 +1019,9 @@ export class APISelect {
['btn', 'btn-sm', 'btn-ghost-dark'],
[createElement('i', null, ['mdi', 'mdi-reload'])],
);
refreshButton.addEventListener('click', () => this.loadData());
// calling this.loadData() will prevent first page of returned items
// with non-null search key inplace not selectable
refreshButton.addEventListener('click', () => this.handleSearch('replace', null));
refreshButton.type = 'button';
this.slim.slim.search.container.appendChild(refreshButton);
}

View File

@@ -1075,4 +1075,41 @@ html {
display: none;
}
}
}
// Apply row colours to interface lists
&[data-netbox-url-name='device_interfaces'] {
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

@@ -81,7 +81,7 @@ Blocks:
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h5><i class="mdi mdi-alert"></i> {% trans "Maintenance Mode" %}</h5>
{{ config.BANNER_MAINTENANCE|escape }}
{{ config.BANNER_MAINTENANCE|safe }}
</div>
{% endif %}

View File

@@ -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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 %}

View File

@@ -0,0 +1,92 @@
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{# A side termination #}
<div id="a_termination_block" class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-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 id="b_termination_block" class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 mb-2">
<h5 class="offset-sm-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 %}
{% 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

File diff suppressed because it is too large Load Diff

View File

@@ -63,7 +63,7 @@ class IKEPolicyTable(NetBoxTable):
mode = tables.Column(
verbose_name=_('Mode')
)
proposals = tables.ManyToManyColumn(
proposals = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Proposals')
)
@@ -129,7 +129,7 @@ class IPSecPolicyTable(NetBoxTable):
verbose_name=_('Name'),
linkify=True
)
proposals = tables.ManyToManyColumn(
proposals = columns.ManyToManyColumn(
linkify_item=True,
verbose_name=_('Proposals')
)

View File

@@ -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):

View File

@@ -91,7 +91,7 @@ class TunnelTerminationTable(TenancyColumnsMixin, NetBoxTable):
verbose_name=_('Tunnel interface'),
linkify=True
)
ip_addresses = tables.ManyToManyColumn(
ip_addresses = columns.ManyToManyColumn(
accessor=tables.A('termination__ip_addresses'),
orderable=False,
linkify_item=True,

View File

@@ -15,21 +15,21 @@ django-tables2==2.7.0
django-timezone-field==6.1.0
djangorestframework==3.14.0
drf-spectacular==0.27.2
drf-spectacular-sidecar==2024.4.1
drf-spectacular-sidecar==2024.5.1
feedparser==6.0.11
graphene-django==3.0.0
gunicorn==22.0.0
Jinja2==3.1.3
Jinja2==3.1.4
Markdown==3.6
mkdocs-material==9.5.18
mkdocstrings[python-legacy]==0.24.3
mkdocs-material==9.5.21
mkdocstrings[python-legacy]==0.25.1
netaddr==1.2.1
Pillow==10.3.0
psycopg[binary,pool]==3.1.18
PyYAML==6.0.1
requests==2.31.0
social-auth-app-django==5.4.0
social-auth-core[openidconnect]==4.5.3
social-auth-app-django==5.4.1
social-auth-core==4.5.4
svgwrite==1.4.3
tablib==3.6.1
tzdata==2024.1