Compare commits

...

3 Commits

Author SHA1 Message Date
bctiemann
f5d32b1bf1 Closes: #19793 - Nav menu link customization (#19794)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
* Support menu items that are callables

* Fix quote on add button

* Clarify docstring to differentiate link and url

* Back out support for callables but keep alternate prerendered url param

* Make url a property on MenuItem/PluginMenuItem etc, overridable via a setter

* Use reverse_lazy instead of reverse

* Use reverse_lazy instead of reverse
2025-07-14 10:39:24 -04:00
Jeremy Stretch
f05897d61a Closes #18811: Match full-form IPv6 addresses in global search (#19873)
* Closes #18811: Match full-form IPv6 addresses in global search

* Fix typo
2025-07-14 09:28:30 -05:00
Luke Anderson
b5421f1cd6 Fixes #19870: Correct Documentation Formatting for Public Demo Instance URL 2025-07-14 08:45:26 -04:00
7 changed files with 94 additions and 9 deletions

View File

@@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
## Populating Demo Data ## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.) Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data. The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.

View File

@@ -18,9 +18,22 @@ class Empty(Lookup):
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
class NetHost(Lookup):
"""
Similar to ipam.lookups.NetHost, but casts the field to INET.
"""
lookup_name = 'net_host'
def as_sql(self, qn, connection):
lhs, lhs_params = self.process_lhs(qn, connection)
rhs, rhs_params = self.process_rhs(qn, connection)
params = lhs_params + rhs_params
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
class NetContainsOrEquals(Lookup): class NetContainsOrEquals(Lookup):
""" """
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
""" """
lookup_name = 'net_contains_or_equals' lookup_name = 'net_contains_or_equals'
@@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
CharField.register_lookup(Empty) CharField.register_lookup(Empty)
CachedValueField.register_lookup(NetHost)
CachedValueField.register_lookup(NetContainsOrEquals) CachedValueField.register_lookup(NetContainsOrEquals)

View File

@@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
return self.prefix.version return self.prefix.version
return None return None
@property
def ipv6_full(self):
if self.prefix and self.prefix.version == 6:
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
def get_child_prefixes(self): def get_child_prefixes(self):
""" """
Return all Prefixes within this Aggregate Return all Prefixes within this Aggregate
@@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
def mask_length(self): def mask_length(self):
return self.prefix.prefixlen if self.prefix else None return self.prefix.prefixlen if self.prefix else None
@property
def ipv6_full(self):
if self.prefix and self.prefix.version == 6:
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
@property @property
def depth(self): def depth(self):
return self._depth return self._depth
@@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
self._original_assigned_object_id = self.__dict__.get('assigned_object_id') self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
@property
def ipv6_full(self):
if self.address and self.address.version == 6:
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
def get_duplicates(self): def get_duplicates(self):
return IPAddress.objects.filter( return IPAddress.objects.filter(
vrf=self.vrf, vrf=self.vrf,

View File

@@ -1,6 +1,8 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Sequence, Optional from typing import Sequence, Optional
from django.urls import reverse_lazy
__all__ = ( __all__ = (
'get_model_item', 'get_model_item',
@@ -22,20 +24,46 @@ class MenuItemButton:
link: str link: str
title: str title: str
icon_class: str icon_class: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
color: Optional[str] = None color: Optional[str] = None
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass @dataclass
class MenuItem: class MenuItem:
link: str link: str
link_text: str link_text: str
_url: Optional[str] = None
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
auth_required: Optional[bool] = False auth_required: Optional[bool] = False
staff_only: Optional[bool] = False staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = () buttons: Optional[Sequence[MenuItemButton]] = ()
def __post_init__(self):
if self.link:
self._url = reverse_lazy(self.link)
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
@dataclass @dataclass
class MenuGroup: class MenuGroup:

View File

@@ -1,3 +1,4 @@
from django.urls import reverse_lazy
from django.utils.text import slugify from django.utils.text import slugify
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -32,17 +33,23 @@ class PluginMenuItem:
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
specifying additional link buttons that appear to the right of the item in the van menu. specifying additional link buttons that appear to the right of the item in the van menu.
Links are specified as Django reverse URL strings. Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
Buttons are each specified as a list of PluginMenuButton instances. Buttons are each specified as a list of PluginMenuButton instances.
""" """
permissions = [] permissions = []
buttons = [] buttons = []
_url = None
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None): def __init__(
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
):
self.link = link self.link = link
self.link_text = link_text self.link_text = link_text
self.auth_required = auth_required self.auth_required = auth_required
self.staff_only = staff_only self.staff_only = staff_only
if link:
self._url = reverse_lazy(link)
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -52,6 +59,14 @@ class PluginMenuItem:
raise TypeError(_("Buttons must be passed as a tuple or list.")) raise TypeError(_("Buttons must be passed as a tuple or list."))
self.buttons = buttons self.buttons = buttons
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value
class PluginMenuButton: class PluginMenuButton:
""" """
@@ -60,11 +75,14 @@ class PluginMenuButton:
""" """
color = ButtonColorChoices.DEFAULT color = ButtonColorChoices.DEFAULT
permissions = [] permissions = []
_url = None
def __init__(self, link, title, icon_class, color=None, permissions=None): def __init__(self, link, title, icon_class, color=None, permissions=None):
self.link = link self.link = link
self.title = title self.title = title
self.icon_class = icon_class self.icon_class = icon_class
if link:
self._url = reverse_lazy(link)
if permissions is not None: if permissions is not None:
if type(permissions) not in (list, tuple): if type(permissions) not in (list, tuple):
raise TypeError(_("Permissions must be passed as a tuple or list.")) raise TypeError(_("Permissions must be passed as a tuple or list."))
@@ -73,3 +91,11 @@ class PluginMenuButton:
if color not in ButtonColorChoices.values(): if color not in ButtonColorChoices.values():
raise ValueError(_("Button color must be a choice within ButtonColorChoices.")) raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
self.color = color self.color = color
@property
def url(self):
return self._url
@url.setter
def url(self, value):
self._url = value

View File

@@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend):
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH): if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
# "Starts/ends with" matches are valid only on string values # "Starts/ends with" matches are valid only on string values
query_filter &= Q(type=FieldTypes.STRING) query_filter &= Q(type=FieldTypes.STRING)
elif lookup == LookupTypes.PARTIAL: elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
try: try:
# If the value looks like an IP address, add an extra match for CIDR values # If the value looks like an IP address, add extra filters for CIDR/INET values
address = str(netaddr.IPNetwork(value.strip()).cidr) address = str(netaddr.IPNetwork(value.strip()).cidr)
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
if lookup == LookupTypes.PARTIAL:
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
except (AddrFormatError, ValueError): except (AddrFormatError, ValueError):
pass pass

View File

@@ -41,11 +41,11 @@
</div> </div>
{% for item, buttons in items %} {% for item, buttons in items %}
<div class="dropdown-item d-flex justify-content-between ps-3 py-0"> <div class="dropdown-item d-flex justify-content-between ps-3 py-0">
<a href="{% url item.link %}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a> <a href="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
{% if buttons %} {% if buttons %}
<div class="btn-group ms-1"> <div class="btn-group ms-1">
{% for button in buttons %} {% for button in buttons %}
<a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}"> <a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
<i class="{{ button.icon_class }}"></i> <i class="{{ button.icon_class }}"></i>
</a> </a>
{% endfor %} {% endfor %}