Merge branch 'develop' into 17219-custom-validator

This commit is contained in:
Arthur Hanson 2024-08-27 09:23:44 -07:00
commit 6d84241db9
20 changed files with 379 additions and 268 deletions

View File

@ -2,6 +2,20 @@
## v4.0.10 (FUTURE) ## v4.0.10 (FUTURE)
### Enhancements
* [#16857](https://github.com/netbox-community/netbox/issues/16857) - Scroll long rendered Markdown content within tables
* [#16949](https://github.com/netbox-community/netbox/issues/16949) - Add device count column to sites table
* [#17072](https://github.com/netbox-community/netbox/issues/17072) - Linkify email addresses & phone numbers in contact assignments list
* [#17177](https://github.com/netbox-community/netbox/issues/17177) - Add facility field to locations filter form
### Bug Fixes
* [#16640](https://github.com/netbox-community/netbox/issues/16640) - Fix potential corruption of JSON values in custom fields that are not UI-editable
* [#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
* [#17117](https://github.com/netbox-community/netbox/issues/17117) - Work around Safari rendering bug
--- ---
## v4.0.9 (2024-08-14) ## v4.0.9 (2024-08-14)

View File

@ -195,7 +195,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location model = Location
fieldsets = ( fieldsets = (
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', name=_('Attributes')), FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
) )
@ -232,6 +232,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
choices=LocationStatusChoices, choices=LocationStatusChoices,
required=False required=False
) )
facility = forms.CharField(
label=_('Facility'),
required=False
)
tag = TagFilterField(model) tag = TagFilterField(model)

View File

@ -99,6 +99,11 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
url_params={'site_id': 'pk'}, url_params={'site_id': 'pk'},
verbose_name=_('ASN Count') verbose_name=_('ASN Count')
) )
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'site_id': 'pk'},
verbose_name=_('Devices')
)
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )

View File

@ -380,7 +380,9 @@ class SiteGroupContactsView(ObjectContactsView):
# #
class SiteListView(generic.ObjectListView): class SiteListView(generic.ObjectListView):
queryset = Site.objects.all() queryset = Site.objects.annotate(
device_count=count_related(Device, 'site')
)
filterset = filtersets.SiteFilterSet filterset = filtersets.SiteFilterSet
filterset_form = forms.SiteFilterForm filterset_form = forms.SiteFilterForm
table = tables.SiteTable table = tables.SiteTable

View File

@ -19,6 +19,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
queryset=ObjectType.objects.all() queryset=ObjectType.objects.all()
) )
parent = serializers.SerializerMethodField(read_only=True) parent = serializers.SerializerMethodField(read_only=True)
image_width = serializers.IntegerField(read_only=True)
image_height = serializers.IntegerField(read_only=True)
class Meta: class Meta:
model = ImageAttachment model = ImageAttachment

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

@ -31,7 +31,7 @@ class ReportForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Annotate the current system time for reference # Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S') now = local_now().strftime('%Y-%m-%d %H:%M:%S %Z')
self.fields['schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now) self.fields['schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled # Remove scheduling fields if scheduling is disabled

View File

@ -37,7 +37,7 @@ class ScriptForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Annotate the current system time for reference # Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S') now = local_now().strftime('%Y-%m-%d %H:%M:%S %Z')
self.fields['_schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now) self.fields['_schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled # Remove scheduling fields if scheduling is disabled

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.

View File

@ -30,6 +30,9 @@
// Remove the bottom margin of <p> elements inside a table cell // Remove the bottom margin of <p> elements inside a table cell
td > .rendered-markdown { td > .rendered-markdown {
max-height: 200px;
overflow-y: scroll;
p:last-of-type { p:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }

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

@ -45,6 +45,19 @@ table a {
background-color: rgba(var(--tblr-primary-rgb),.48) background-color: rgba(var(--tblr-primary-rgb),.48)
} }
// Do not apply padding to <code> elements inside a <pre>
pre code { pre code {
padding: unset; padding: unset;
} }
// Use an icon instead of Tabler's native "caret" for dropdowns (avoids a Safari bug)
.dropdown-toggle:after{
font-family: "Material Design Icons";
content: '\F0140';
padding-right: 9px;
border-bottom: none;
border-left: none;
transform: none;
vertical-align: .05em;
height: auto;
}

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

@ -113,11 +113,12 @@ class ContactAssignmentTable(NetBoxTable):
) )
contact_phone = tables.Column( contact_phone = tables.Column(
accessor=Accessor('contact__phone'), accessor=Accessor('contact__phone'),
verbose_name=_('Contact Phone') verbose_name=_('Contact Phone'),
linkify=linkify_phone,
) )
contact_email = tables.Column( contact_email = tables.EmailColumn(
accessor=Accessor('contact__email'), accessor=Accessor('contact__email'),
verbose_name=_('Contact Email') verbose_name=_('Contact Email'),
) )
contact_address = tables.Column( contact_address = tables.Column(
accessor=Accessor('contact__address'), accessor=Accessor('contact__address'),

File diff suppressed because it is too large Load Diff

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

View File

@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturalday, naturaltime from django.contrib.humanize.templatetags.humanize import naturalday, naturaltime
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from markdown import markdown from markdown import markdown
from markdown.extensions.tables import TableExtension from markdown.extensions.tables import TableExtension
@ -218,7 +219,8 @@ def isodate(value):
text = value.isoformat() text = value.isoformat()
return mark_safe(f'<span title="{naturalday(value)}">{text}</span>') return mark_safe(f'<span title="{naturalday(value)}">{text}</span>')
elif type(value) is datetime.datetime: elif type(value) is datetime.datetime:
text = value.date().isoformat() local_value = localtime(value) if value.tzinfo else value
text = local_value.date().isoformat()
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>') return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
else: else:
return '' return ''
@ -229,7 +231,8 @@ def isotime(value, spec='seconds'):
if type(value) is datetime.time: if type(value) is datetime.time:
return value.isoformat(timespec=spec) return value.isoformat(timespec=spec)
if type(value) is datetime.datetime: if type(value) is datetime.datetime:
return value.time().isoformat(timespec=spec) local_value = localtime(value) if value.tzinfo else value
return local_value.time().isoformat(timespec=spec)
return '' return ''