Merge branch 'develop' into 17256-vlangroup-scope-selector

This commit is contained in:
Jeremy Stretch 2024-08-27 13:41:01 -04:00
commit e747ad5b51
14 changed files with 134 additions and 52 deletions

View File

@ -11,10 +11,16 @@
### Bug Fixes ### Bug Fixes
* [#16385](https://github.com/netbox-community/netbox/issues/16385) - Restore support for white, gray, and black background colors
* [#16640](https://github.com/netbox-community/netbox/issues/16640) - Fix potential corruption of JSON values in custom fields that are not UI-editable * [#16640](https://github.com/netbox-community/netbox/issues/16640) - Fix potential corruption of JSON values in custom fields that are not UI-editable
* [#16825](https://github.com/netbox-community/netbox/issues/16825) - Avoid `NoReverseMatch` exception when displaying count of related object type with no list view
* [#16946](https://github.com/netbox-community/netbox/issues/16946) - GraphQL API requests with an invalid filter should return an empty set
* [#16959](https://github.com/netbox-community/netbox/issues/16959) - Fix function of "reset" button on objects filter form
* [#16973](https://github.com/netbox-community/netbox/issues/16973) - Fix support for evaluating user token (`$user`) against custom field values in permission constraints
* [#17070](https://github.com/netbox-community/netbox/issues/17070) - Image height & width values should not be required when creating an image attachment via the REST API * [#17070](https://github.com/netbox-community/netbox/issues/17070) - Image height & width values should not be required when creating an image attachment via the REST API
* [#17108](https://github.com/netbox-community/netbox/issues/17108) - Ensure template date & time filters always return localtime-aware values * [#17108](https://github.com/netbox-community/netbox/issues/17108) - Ensure template date & time filters always return localtime-aware values
* [#17117](https://github.com/netbox-community/netbox/issues/17117) - Work around Safari rendering bug * [#17117](https://github.com/netbox-community/netbox/issues/17117) - Work around Safari rendering bug
* [#17230](https://github.com/netbox-community/netbox/issues/17230) - Ensure consistent rendering for all dashboard widget colors
--- ---

View File

@ -126,9 +126,18 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers return response_serializers
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
# If this serializer is nested, prepend its name with "Brief"
if getattr(serializer, 'nested', False):
name = f'Brief{name}'
return name
def get_serializer_ref_name(self, serializer): def get_serializer_ref_name(self, serializer):
# from drf-yasg.utils # from drf-yasg.utils
"""Get serializer's ref_name (or None for ModelSerializer if it is named 'NestedSerializer') """Get serializer's ref_name
:param serializer: Serializer instance :param serializer: Serializer instance
:return: Serializer's ``ref_name`` or ``None`` for inline serializer :return: Serializer's ``ref_name`` or ``None`` for inline serializer
:rtype: str or None :rtype: str or None
@ -137,8 +146,6 @@ class NetBoxAutoSchema(AutoSchema):
serializer_name = type(serializer).__name__ serializer_name = type(serializer).__name__
if hasattr(serializer_meta, 'ref_name'): if hasattr(serializer_meta, 'ref_name'):
ref_name = serializer_meta.ref_name ref_name = serializer_meta.ref_name
elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
ref_name = None
else: else:
ref_name = serializer_name ref_name = serializer_name
if ref_name.endswith('Serializer'): if ref_name.endswith('Serializer'):

View File

@ -131,22 +131,6 @@ class DashboardWidget:
def name(self): def name(self):
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}' return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
@property
def fg_color(self):
"""
Return the appropriate foreground (text) color for the widget's color.
"""
if self.color in (
ButtonColorChoices.CYAN,
ButtonColorChoices.GRAY,
ButtonColorChoices.GREY,
ButtonColorChoices.TEAL,
ButtonColorChoices.WHITE,
ButtonColorChoices.YELLOW,
):
return ButtonColorChoices.BLACK
return ButtonColorChoices.WHITE
@property @property
def form_data(self): def form_data(self):
return { return {

View File

@ -4,7 +4,7 @@ from typing import List
import django_filters import django_filters
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist, ValidationError
from strawberry import auto from strawberry import auto
from ipam.fields import ASNField from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
@ -201,4 +201,9 @@ def autotype_decorator(filterset):
class BaseFilterMixin: class BaseFilterMixin:
def filter_by_filterset(self, queryset, key): def filter_by_filterset(self, queryset, key):
return self.filterset(data={key: getattr(self, key)}, queryset=queryset).qs filterset = self.filterset(data={key: getattr(self, key)}, queryset=queryset)
if not filterset.is_valid():
# We could raise validation error but strawberry logs it all to the
# console i.e. raise ValidationError(f"{k}: {v[0]}")
return filterset.qs.none()
return filterset.qs

View File

@ -1,7 +1,13 @@
import json
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from rest_framework import status
from utilities.testing import disable_warnings, TestCase from core.models import ObjectType
from dcim.models import Site, Location
from users.models import ObjectPermission
from utilities.testing import disable_warnings, APITestCase, TestCase
class GraphQLTestCase(TestCase): class GraphQLTestCase(TestCase):
@ -34,3 +40,45 @@ class GraphQLTestCase(TestCase):
response = self.client.get(url, **header) response = self.client.get(url, **header)
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(response, 302) # Redirect to login page self.assertHttpStatus(response, 302) # Redirect to login page
class GraphQLAPITestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_filter_objects(self):
"""
Test the operation of filters for GraphQL API requests.
"""
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1'),
Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
# 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(Location))
# A valid request should return the filtered list
url = reverse('graphql')
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
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.assertEqual(len(data['data']['location_list']), 1)
# An invalid request should return an empty list
query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID
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.assertEqual(len(data['data']['location_list']), 0)

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,10 +39,17 @@ export function initFormElements(): void {
// Find each of the form's submitters. Most object edit forms have a "Create" and // Find each of the form's submitters. Most object edit forms have a "Create" and
// a "Create & Add", so we need to add a listener to both. // a "Create & Add", so we need to add a listener to both.
const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]'); const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
for (const submitter of submitters) { for (const submitter of submitters) {
// Add the event listener to each submitter. // Add the event listener to each submitter.
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
} }
// Initialize any reset buttons so that when clicked, the page is reloaded without query parameters.
const resetButton = document.querySelector<HTMLButtonElement>('button[data-reset-select]');
if (resetButton !== null) {
resetButton.addEventListener('click', () => {
window.location.assign(window.location.origin + window.location.pathname);
});
}
} }
} }

View File

@ -20,3 +20,14 @@ hr.dropdown-divider {
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
// Restore support for old Bootstrap v3 colors
.text-bg-black {
@extend .text-bg-dark;
}
.text-bg-gray {
@extend .text-bg-secondary;
}
.text-bg-white {
@extend .text-bg-light;
}

View File

@ -9,31 +9,35 @@
gs-id="{{ widget.id }}" gs-id="{{ widget.id }}"
> >
<div class="card grid-stack-item-content"> <div class="card grid-stack-item-content">
<div class="card-header text-{{ widget.fg_color }} bg-{{ widget.color|default:"secondary" }} px-2 py-1 d-flex flex-row"> {% with bg_color=widget.color|default:"secondary" %}
<a href="#" <div class="card-header text-bg-{{ bg_color }} px-2 py-1 d-flex flex-row">
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}" <a href="#"
hx-target="#htmx-modal-content" hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
data-bs-toggle="modal" hx-target="#htmx-modal-content"
data-bs-target="#htmx-modal" data-bs-toggle="modal"
> data-bs-target="#htmx-modal"
<i class="mdi mdi-cog text-{{ widget.fg_color }}"></i> class="text-bg-{{ bg_color }}"
</a> >
<div class="card-title flex-fill text-center"> <i class="mdi mdi-cog"></i>
{% if widget.title %} </a>
<span class="fs-4 fw-bold">{{ widget.title }}</span> <div class="card-title flex-fill text-center">
{% endif %} {% if widget.title %}
<span class="fs-4 fw-bold">{{ widget.title }}</span>
{% endif %}
</div>
<a href="#"
hx-get="{% url 'extras:dashboardwidget_delete' id=widget.id %}"
hx-target="#htmx-modal-content"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
class="text-bg-{{ bg_color }}"
>
<i class="mdi mdi-close"></i>
</a>
</div> </div>
<a href="#" <div class="card-body p-2 pt-1 overflow-auto">
hx-get="{% url 'extras:dashboardwidget_delete' id=widget.id %}" {% render_widget widget %}
hx-target="#htmx-modal-content" </div>
data-bs-toggle="modal" {% endwith %}
data-bs-target="#htmx-modal"
>
<i class="mdi mdi-close text-{{ widget.fg_color }}"></i>
</a>
</div>
<div class="card-body p-2 pt-1 overflow-auto">
{% render_widget widget %}
</div>
</div> </div>
</div> </div>

View File

@ -5,7 +5,8 @@
<h5 class="card-header">{% trans "Related Objects" %}</h5> <h5 class="card-header">{% trans "Related Objects" %}</h5>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for qs, filter_param in related_models %} {% for qs, filter_param in related_models %}
{% with viewname=qs.model|viewname:"list" %} {% with viewname=qs.model|validated_viewname:"list" %}
{% if viewname is not None %}
<a href="{% url viewname %}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between"> <a href="{% url viewname %}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }} {{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %} {% with count=qs.count %}
@ -16,6 +17,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</a> </a>
{% endif %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-08-23 05:01+0000\n" "POT-Creation-Date: 2024-08-27 05:01+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -14154,14 +14154,14 @@ msgstr ""
msgid "Missing required value for static query param: '{static_params}'" msgid "Missing required value for static query param: '{static_params}'"
msgstr "" msgstr ""
#: netbox/utilities/permissions.py:39 #: netbox/utilities/permissions.py:42
#, python-brace-format #, python-brace-format
msgid "" msgid ""
"Invalid permission name: {name}. Must be in the format <app_label>." "Invalid permission name: {name}. Must be in the format <app_label>."
"<action>_<model>" "<action>_<model>"
msgstr "" msgstr ""
#: netbox/utilities/permissions.py:57 #: netbox/utilities/permissions.py:60
#, python-brace-format #, python-brace-format
msgid "Unknown app_label/model_name for {name}" msgid "Unknown app_label/model_name for {name}"
msgstr "" msgstr ""

View File

@ -1,7 +1,10 @@
from django.conf import settings from django.conf import settings
from django.apps import apps
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
__all__ = ( __all__ = (
'get_permission_for_model', 'get_permission_for_model',
'permission_is_exempt', 'permission_is_exempt',
@ -90,6 +93,11 @@ def qs_filter_from_constraints(constraints, tokens=None):
if tokens is None: if tokens is None:
tokens = {} tokens = {}
User = apps.get_model('users.User')
for token, value in tokens.items():
if token == CONSTRAINT_TOKEN_USER and isinstance(value, User):
tokens[token] = value.id
def _replace_tokens(value, tokens): def _replace_tokens(value, tokens):
if type(value) is list: if type(value) is list:
return list(map(lambda v: tokens.get(v, v), value)) return list(map(lambda v: tokens.get(v, v), value))