Merge pull request #16132 from netbox-community/develop

Release v4.0.2
This commit is contained in:
Jeremy Stretch 2024-05-14 11:20:56 -04:00 committed by GitHub
commit cca1b0a897
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
53 changed files with 2256 additions and 2114 deletions

View File

@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.1
placeholder: v4.0.2
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: v4.0.1
placeholder: v4.0.2
validations:
required: true
- type: dropdown

View File

@ -7,6 +7,7 @@ on:
workflow_dispatch:
permissions:
actions: write
issues: write
pull-requests: write

View File

@ -131,7 +131,7 @@ social-auth-app-django
strawberry-graphql
# Strawberry GraphQL Django extension
# https://github.com/strawberry-graphql/strawberry-django/blob/main/CHANGELOG.md
# https://github.com/strawberry-graphql/strawberry-django/releases
strawberry-graphql-django
# SVG image rendering (used for rack elevations)

View File

@ -159,9 +159,12 @@ Note that enabling this setting causes NetBox to update a user's session in the
## LOGIN_REQUIRED
Default: False
Default: True
Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users are permitted to access most data in NetBox but not make any changes.
When enabled, only authenticated users are permitted to access any part of NetBox. Disabling this will allow unauthenticated users to access most areas of NetBox (but not make any changes).
!!! info "Changed in NetBox v4.0.2"
Prior to NetBox v4.0.2, this setting was disabled by default.
---

View File

@ -198,3 +198,11 @@ If `STORAGE_BACKEND` is not defined, this setting will be ignored.
Default: UTC
The time zone NetBox will use when dealing with dates and times. It is recommended to use UTC time unless you have a specific need to use a local time zone. Please see the [list of available time zones](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones).
---
## TRANSLATION_ENABLED
Default: True
Enables language translation for the user interface. (This parameter maps to Django's [USE_I18N](https://docs.djangoproject.com/en/stable/ref/settings/#std-setting-USE_I18N) setting.)

View File

@ -1,5 +1,28 @@
# NetBox v4.0
## v4.0.2 (2024-05-14)
!!! warning "Important"
This release includes an important security fix, and is a strongly recommended update for all users. More details will follow.
### Enhancements
* [#15119](https://github.com/netbox-community/netbox/issues/15119) - Add cluster & cluster group UI filter fields for VLAN groups
* [#16090](https://github.com/netbox-community/netbox/issues/16090) - Include current NetBox version when an unsupported plugin is detected
* [#16096](https://github.com/netbox-community/netbox/issues/16096) - Introduce the `ENABLE_TRANSLATION` configuration parameter
* [#16107](https://github.com/netbox-community/netbox/issues/16107) - Change the default value for `LOGIN_REQUIRED` to True
* [#16127](https://github.com/netbox-community/netbox/issues/16127) - Add integration point for unsupported settings
### Bug Fixes
* [#16077](https://github.com/netbox-community/netbox/issues/16077) - Fix display of parameter values when viewing configuration revisions
* [#16078](https://github.com/netbox-community/netbox/issues/16078) - Fix integer filters mistakenly marked as required for GraphQL API
* [#16101](https://github.com/netbox-community/netbox/issues/16101) - Fix initial loading of pagination widget for dynamic object tables
* [#16123](https://github.com/netbox-community/netbox/issues/16123) - Fix custom script execution via REST API
* [#16124](https://github.com/netbox-community/netbox/issues/16124) - Fix GraphQL API support for querying virtual machine interfaces
---
## v4.0.1 (2024-05-09)
### Enhancements

View File

@ -2093,7 +2093,6 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@ -2986,7 +2985,6 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),

View File

@ -240,9 +240,9 @@ class ScriptViewSet(ModelViewSet):
raise RQWorkerNotRunningException()
if input_serializer.is_valid():
script.result = Job.enqueue(
Job.enqueue(
run_script,
instance=script.module,
instance=script,
name=script.python_class.class_name,
user=request.user,
data=input_serializer.data['data'],

View File

@ -1,10 +1,10 @@
import json
import django_tables2 as tables
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns
from .template_code import *
@ -550,7 +550,7 @@ class ScriptResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'time', 'status', 'message',
)
@ -581,7 +581,7 @@ class ReportResultsTable(BaseTable):
)
class Meta(BaseTable.Meta):
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
fields = (
'index', 'method', 'time', 'status', 'object', 'url', 'message',
)

View File

@ -10,7 +10,7 @@ from tenancy.forms import TenancyFilterForm
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, add_blank_choice
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, ClusterGroup, Cluster
from vpn.models import L2VPN
__all__ = (
@ -405,6 +405,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
)
model = VLANGroup
@ -445,6 +446,17 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model)

View File

@ -648,6 +648,9 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
bulk_update_data = {
'description': 'New description',
}
graphql_filter = {
'address': '192.168.0.1/24',
}
@classmethod
def setUpTestData(cls):

View File

@ -214,7 +214,6 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@ -883,7 +882,6 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@ -955,7 +953,6 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@ -1111,7 +1108,6 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@ -1129,7 +1125,6 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),

View File

@ -157,9 +157,8 @@ LOGGING = {}
# authenticated to NetBox indefinitely.
LOGIN_PERSISTENCE = False
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
# are permitted to access most data in NetBox but not make any changes.
LOGIN_REQUIRED = False
# Setting this to False will permit unauthenticated users to access most areas of NetBox (but not make any changes).
LOGIN_REQUIRED = True
# The length of time (in seconds) for which a user will remain logged into the web UI before being prompted to
# re-authenticate. (Default: 1209600 [14 days])

View File

@ -41,3 +41,6 @@ DEFAULT_ACTION_PERMISSIONS = {
# General-purpose tokens
CENSOR_TOKEN = '********'
CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found'

View File

@ -87,7 +87,7 @@ def map_strawberry_type(field):
pass
elif issubclass(type(field), django_filters.NumberFilter):
should_create_function = True
attr_type = int
attr_type = int | None
elif issubclass(type(field), django_filters.ModelMultipleChoiceFilter):
should_create_function = True
attr_type = List[str] | None

View File

@ -138,13 +138,15 @@ class PluginConfig(AppConfig):
min_version = version.parse(cls.min_version)
if current_version < min_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version}."
f"Plugin {cls.__module__} requires NetBox minimum version {cls.min_version} (current: "
f"{netbox_version})."
)
if cls.max_version is not None:
max_version = version.parse(cls.max_version)
if current_version > max_version:
raise ImproperlyConfigured(
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version}."
f"Plugin {cls.__module__} requires NetBox maximum version {cls.max_version} (current: "
f"{netbox_version})."
)
# Verify required configuration settings

View File

@ -23,7 +23,7 @@ PREFERENCES = {
),
description=_('Enable dynamic UI navigation'),
default=False,
experimental=True
warning=_('Experimental feature')
),
'locale.language': UserPreference(
label=_('Language'),
@ -31,7 +31,12 @@ PREFERENCES = {
('', _('Auto')),
*settings.LANGUAGES,
),
description=_('Forces UI translation to the specified language.')
description=_('Forces UI translation to the specified language'),
warning=(
_("Support for translation has been disabled locally")
if not settings.TRANSLATION_ENABLED
else ''
)
),
'pagination.per_page': UserPreference(
label=_('Page length'),

View File

@ -25,7 +25,7 @@ from utilities.string import trailing_slash
# Environment setup
#
VERSION = '4.0.1'
VERSION = '4.0.2'
HOSTNAME = platform.node()
# Set the base directory two levels up
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -105,7 +105,7 @@ LANGUAGE_CODE = getattr(configuration, 'DEFAULT_LANGUAGE', 'en-us')
LANGUAGE_COOKIE_PATH = CSRF_COOKIE_PATH
LOGGING = getattr(configuration, 'LOGGING', {})
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', False)
LOGIN_REQUIRED = getattr(configuration, 'LOGIN_REQUIRED', True)
LOGIN_TIMEOUT = getattr(configuration, 'LOGIN_TIMEOUT', None)
LOGOUT_REDIRECT_URL = getattr(configuration, 'LOGOUT_REDIRECT_URL', 'home')
MEDIA_ROOT = getattr(configuration, 'MEDIA_ROOT', os.path.join(BASE_DIR, 'media')).rstrip('/')
@ -156,6 +156,7 @@ SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
STORAGE_BACKEND = getattr(configuration, 'STORAGE_BACKEND', None)
STORAGE_CONFIG = getattr(configuration, 'STORAGE_CONFIG', {})
TIME_ZONE = getattr(configuration, 'TIME_ZONE', 'UTC')
TRANSLATION_ENABLED = getattr(configuration, 'TRANSLATION_ENABLED', True)
# Load any dynamic configuration parameters which have been hard-coded in the configuration file
for param in CONFIG_PARAMS:
@ -445,6 +446,9 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
# Use timezone-aware datetime objects
USE_TZ = True
# Toggle language translation support
USE_I18N = TRANSLATION_ENABLED
# WSGI
WSGI_APPLICATION = 'netbox.wsgi.application'
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
@ -801,3 +805,10 @@ for plugin_name in PLUGINS:
RQ_QUEUES.update({
f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues
})
# UNSUPPORTED FUNCTIONALITY: Import any local overrides.
try:
from .local_settings import *
_UNSUPPORTED_SETTINGS = True
except ImportError:
pass

View File

@ -14,6 +14,7 @@ from django_tables2.data import TableQuerysetData
from core.models import ObjectType
from extras.choices import *
from extras.models import CustomField, CustomLink
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.registry import registry
from netbox.tables import columns
from utilities.paginator import EnhancedPaginator, get_paginate_count
@ -258,7 +259,7 @@ class SearchTable(tables.Table):
attrs = {
'class': 'table table-hover object-list',
}
empty_text = _('No results found')
empty_text = _(EMPTY_TABLE_TEXT)
def __init__(self, data, highlight=None, **kwargs):
self.highlight = highlight

View File

@ -42,6 +42,7 @@ class PluginTest(TestCase):
url = reverse('admin:dummy_plugin_dummymodel_add')
self.assertEqual(url, '/admin/dummy_plugin/dummymodel/add/')
@override_settings(LOGIN_REQUIRED=False)
def test_views(self):
# Test URL resolution
@ -53,7 +54,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_api_views(self):
# Test URL resolution
@ -65,6 +66,7 @@ class PluginTest(TestCase):
response = client.get(url)
self.assertEqual(response.status_code, 200)
@override_settings(LOGIN_REQUIRED=False)
def test_registered_views(self):
# Test URL resolution

View File

@ -1,24 +1,76 @@
import urllib.parse
from utilities.testing import TestCase
from django.urls import reverse
from django.test import override_settings
from dcim.models import Site
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.search.backends import search_backend
from utilities.testing import TestCase
class HomeViewTestCase(TestCase):
def test_home(self):
url = reverse('home')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search(self):
class SearchViewTestCase(TestCase):
@classmethod
def setUpTestData(cls):
sites = (
Site(name='Site Alpha', slug='alpha', description='Red'),
Site(name='Site Bravo', slug='bravo', description='Red'),
Site(name='Site Charlie', slug='charlie', description='Green'),
Site(name='Site Delta', slug='delta', description='Green'),
Site(name='Site Echo', slug='echo', description='Blue'),
Site(name='Site Foxtrot', slug='foxtrot', description='Blue'),
)
Site.objects.bulk_create(sites)
search_backend.cache(sites)
def test_search(self):
url = reverse('search')
response = self.client.get(url)
self.assertHttpStatus(response, 200)
def test_search_query(self):
url = reverse('search')
params = {
'q': 'foo',
'q': 'red',
}
query = urllib.parse.urlencode(params)
response = self.client.get('{}?{}'.format(url, urllib.parse.urlencode(params)))
# Test without view permission
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)
# Add view permissions & query again. Only matching objects should be listed
self.add_permissions('dcim.view_site')
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn('Site Alpha', content)
self.assertIn('Site Bravo', content)
self.assertNotIn('Site Charlie', content)
self.assertNotIn('Site Delta', content)
self.assertNotIn('Site Echo', content)
self.assertNotIn('Site Foxtrot', content)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_search_no_results(self):
url = reverse('search')
params = {
'q': 'xxxxxxxxx', # Matches nothing
}
query = urllib.parse.urlencode(params)
response = self.client.get(f'{url}?{query}')
self.assertHttpStatus(response, 200)
content = str(response.content)
self.assertIn(EMPTY_TABLE_TEXT, content)

View File

@ -93,6 +93,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
child_model = None
table = None
filterset = None
template_name = 'generic/object_children.html'
def get_children(self, request, parent):
"""

View File

@ -30,7 +30,7 @@
"gridstack": "10.1.2",
"htmx.org": "1.9.12",
"query-string": "9.0.0",
"sass": "1.77.0",
"sass": "1.77.1",
"tom-select": "2.3.1",
"typeface-inter": "3.18.1",
"typeface-roboto-mono": "1.1.13"

View File

@ -1816,9 +1816,9 @@ ignore@^5.2.0:
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immutable@^4.0.0:
version "4.3.5"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.5.tgz#f8b436e66d59f99760dc577f5c99a4fd2a5cc5a0"
integrity sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==
version "4.3.6"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447"
integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==
import-fresh@^3.2.1:
version "3.3.0"
@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
es-errors "^1.3.0"
is-regex "^1.1.4"
sass@1.77.0:
version "1.77.0"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.0.tgz#e736c69aff9fae4a4e6dae60a979eee9c942f321"
integrity sha512-eGj4HNfXqBWtSnvItNkn7B6icqH14i3CiCGbzMKs3BAPTq62pp9NBYsBgyN4cA+qssqo9r26lW4JSvlaUUWbgw==
sass@1.77.1:
version "1.77.1"
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.1.tgz#018cdfb206afd14724030c02e9fefd8f30a76cd0"
integrity sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==
dependencies:
chokidar ">=3.0.0 <4.0.0"
immutable "^4.0.0"

View File

@ -33,7 +33,7 @@
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Configuration Data" %}</h5>
{% include 'core/inc/config_data.html' with config=config.data %}
{% include 'core/inc/config_data.html' with config=object.data %}
</div>
<div class="card">

View File

@ -40,7 +40,7 @@
<td>{{ stats.django_version }}</td>
</tr>
<tr>
<th scope="row">{% trans "PotsgreSQL version" %}</th>
<th scope="row">{% trans "PostgreSQL version" %}</th>
<td>{{ stats.postgresql_version }}</td>
</tr>
<tr>

View File

@ -2,6 +2,7 @@
{% load helpers %}
{% load render_table from django_tables2 %}
<div class="htmx-container table-responsive">
{% with preferences|get_key:"pagination.placement" as paginator_placement %}
{% if paginator_placement == 'top' or paginator_placement == 'both' %}
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page placement='top' %}
@ -11,6 +12,7 @@
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
{% endif %}
{% endwith %}
</div>
{# Include the updated object count for display elsewhere on the page #}
<div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>

View File

@ -5,7 +5,8 @@
<div
class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2"
hx-target="closest .htmx-container"
hx-disinherit="hx-select hx-swap"
hx-disinherit="hx-select"
hx-swap="outerHTML"
{% if not table.embedded %}hx-push-url="true"{% endif %}
>

View File

@ -1,5 +1,5 @@
{% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select hx-swap">
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %} hx-disinherit="hx-target hx-select" hx-swap="outerHTML">
{% if table.show_header %}
<thead
hx-target="closest .htmx-container"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -40,11 +40,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
help_text = f'<code>{field_name}</code>'
if preference.description:
help_text = f'{preference.description}<br />{help_text}'
if preference.experimental:
help_text = (
f'<span class="text-danger"><i class="mdi mdi-alert"></i> Experimental feature</span><br />'
f'{help_text}'
)
if warning := preference.warning:
help_text = f'<span class="text-danger"><i class="mdi mdi-alert"></i> {warning}</span><br />{help_text}'
field_kwargs = {
'label': preference.label,
'choices': preference.choices,

View File

@ -2,10 +2,10 @@ class UserPreference:
"""
Represents a configurable user preference.
"""
def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, experimental=False):
def __init__(self, label, choices, default=None, description='', coerce=lambda x: x, warning=''):
self.label = label
self.choices = choices
self.default = default if default is not None else choices[0]
self.description = description
self.coerce = coerce
self.experimental = experimental
self.warning = warning

View File

@ -70,14 +70,24 @@ class RestrictedGenericForeignKey(GenericForeignKey):
# 1. Capture restrict_params from RestrictedPrefetch (hack)
# 2. If restrict_params is set, call restrict() on the queryset for
# the related model
def get_prefetch_queryset(self, instances, queryset=None):
def get_prefetch_querysets(self, instances, querysets=None):
restrict_params = {}
custom_queryset_dict = {}
# Compensate for the hack in RestrictedPrefetch
if type(queryset) is dict:
restrict_params = queryset
elif queryset is not None:
raise ValueError(_("Custom queryset can't be used for this lookup."))
if type(querysets) is dict:
restrict_params = querysets
elif querysets is not None:
for queryset in querysets:
ct_id = self.get_content_type(
model=queryset.query.model, using=queryset.db
).pk
if ct_id in custom_queryset_dict:
raise ValueError(
"Only one queryset is allowed for each content type."
)
custom_queryset_dict[ct_id] = queryset
# For efficiency, group the instances by content type and then do one
# query per model
@ -100,15 +110,16 @@ class RestrictedGenericForeignKey(GenericForeignKey):
ret_val = []
for ct_id, fkeys in fk_dict.items():
if ct_id in custom_queryset_dict:
# Return values from the custom queryset, if provided.
ret_val.extend(custom_queryset_dict[ct_id].filter(pk__in=fkeys))
else:
instance = instance_dict[ct_id]
ct = self.get_content_type(id=ct_id, using=instance._state.db)
qs = ct.model_class().objects.filter(pk__in=fkeys)
if restrict_params:
# Override the default behavior to call restrict() on each model's queryset
qs = ct.model_class().objects.filter(pk__in=fkeys).restrict(**restrict_params)
qs = qs.restrict(**restrict_params)
ret_val.extend(qs)
else:
# Default behavior
ret_val.extend(ct.get_all_objects_for_this_type(pk__in=fkeys))
# For doing the join in Python, we have to match both the FK val and the
# content type, so we use a callable that returns a (fk, class) pair.

View File

@ -20,14 +20,14 @@ class RestrictedPrefetch(Prefetch):
super().__init__(lookup, queryset=queryset, to_attr=to_attr)
def get_current_queryset(self, level):
def get_current_querysets(self, level):
params = {
'user': self.restrict_user,
'action': self.restrict_action,
}
if qs := super().get_current_queryset(level):
return qs.restrict(**params)
if querysets := super().get_current_querysets(level):
return [qs.restrict(**params) for qs in querysets]
# Bit of a hack. If no queryset is defined, pass through the dict of restrict()
# kwargs to be handled by the field. This is necessary e.g. for GenericForeignKey
@ -49,11 +49,11 @@ class RestrictedQuerySet(QuerySet):
permission_required = get_permission_for_model(self.model, action)
# Bypass restriction for superusers and exempt views
if user.is_superuser or permission_is_exempt(permission_required):
if user and user.is_superuser or permission_is_exempt(permission_required):
qs = self
# User is anonymous or has not been granted the requisite permission
elif not user.is_authenticated or permission_required not in user.get_all_permissions():
elif user is None or not user.is_authenticated or permission_required not in user.get_all_permissions():
qs = self.none()
# Filter the queryset to include only objects with allowed attributes

View File

@ -1,5 +1,5 @@
<div class="htmx-container table-responsive"
hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
hx-target="this"
hx-trigger="load" hx-select="table" hx-swap="innerHTML"
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div>

View File

@ -73,7 +73,7 @@ class APIViewTestCases:
class GetObjectViewTestCase(APITestCase):
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_get_object_anonymous(self):
"""
GET a single object as an unauthenticated user.
@ -135,7 +135,7 @@ class APIViewTestCases:
class ListObjectsViewTestCase(APITestCase):
brief_fields = []
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_list_objects_anonymous(self):
"""
GET a list of objects as an unauthenticated user.
@ -440,13 +440,12 @@ class APIViewTestCases:
base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
return getattr(self, 'graphql_base_name', base_name)
def _build_query(self, name, **filters):
def _build_query_with_filter(self, name, filter_string):
"""
Called by either _build_query or _build_filtered_query - construct the actual
query given a name and filter string
"""
type_class = get_graphql_type_for_model(self.model)
if filters:
filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
filter_string = f'({filter_string})'
else:
filter_string = ''
# Compile list of fields to include
fields_string = ''
@ -492,6 +491,30 @@ class APIViewTestCases:
return query
def _build_filtered_query(self, name, **filters):
"""
Create a filtered query: i.e. ip_address_list(filters: {address: "1.1.1.1/24"}){.
"""
if filters:
filter_string = ', '.join(f'{k}: "{v}"' for k, v in filters.items())
filter_string = f'(filters: {{{filter_string}}})'
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
def _build_query(self, name, **filters):
"""
Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
"""
if filters:
filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
filter_string = f'({filter_string})'
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_get_object(self):
@ -550,6 +573,31 @@ class APIViewTestCases:
self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0)
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_filter_objects(self):
if not hasattr(self, 'graphql_filter'):
return
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_filtered_query(field_name, **self.graphql_filter)
# Add object-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0)
class APIViewTestCase(
GetObjectViewTestCase,
ListObjectsViewTestCase,

View File

@ -62,7 +62,7 @@ class ViewTestCases:
"""
Retrieve a single instance.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_get_object_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()
@ -421,7 +421,7 @@ class ViewTestCases:
"""
Retrieve multiple instances.
"""
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], LOGIN_REQUIRED=False)
def test_list_objects_anonymous(self):
# Make the request as an unauthenticated user
self.client.logout()

View File

@ -84,7 +84,7 @@ class VirtualMachineType(ConfigContextMixin, ContactsMixin, NetBoxObjectType):
primary_ip4: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
primary_ip6: Annotated["IPAddressType", strawberry.lazy('ipam.graphql.types')] | None
interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
virtualdisks: List[Annotated["VirtualDiskType", strawberry.lazy('virtualization.graphql.types')]]
@ -102,8 +102,8 @@ class VMInterfaceType(IPAddressesMixin, ComponentType):
vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
bridge_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
child_interfaces: List[Annotated["InterfaceType", strawberry.lazy('dcim.graphql.types')]]
bridge_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
child_interfaces: List[Annotated["VMInterfaceType", strawberry.lazy('virtualization.graphql.types')]]
@strawberry_django.type(

View File

@ -181,7 +181,6 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = tables.VirtualMachineTable
filterset = filtersets.VirtualMachineFilterSet
template_name = 'generic/object_children.html'
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: obj.virtual_machines.count(),

View File

@ -20,18 +20,18 @@ feedparser==6.0.11
gunicorn==22.0.0
Jinja2==3.1.4
Markdown==3.6
mkdocs-material==9.5.21
mkdocs-material==9.5.22
mkdocstrings[python-legacy]==0.25.1
netaddr==1.2.1
nh3==0.2.17
Pillow==10.3.0
psycopg[c,pool]==3.1.18
psycopg[c,pool]==3.1.19
PyYAML==6.0.1
requests==2.31.0
social-auth-app-django==5.4.1
social-auth-core==4.5.4
strawberry-graphql==0.227.4
strawberry-graphql-django==0.39.2
strawberry-graphql==0.229.0
strawberry-graphql-django==0.40.0
svgwrite==1.4.3
tablib==3.6.1
tzdata==2024.1