mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 00:28:16 -06:00
Merge branch 'develop' into 17186-custom-link
This commit is contained in:
commit
1d9dbae104
@ -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)
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
@ -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'),
|
||||||
)
|
)
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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 {
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -9,14 +9,16 @@
|
|||||||
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" %}
|
||||||
|
<div class="card-header text-bg-{{ bg_color }} px-2 py-1 d-flex flex-row">
|
||||||
<a href="#"
|
<a href="#"
|
||||||
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
|
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
|
||||||
hx-target="#htmx-modal-content"
|
hx-target="#htmx-modal-content"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#htmx-modal"
|
data-bs-target="#htmx-modal"
|
||||||
|
class="text-bg-{{ bg_color }}"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-cog text-{{ widget.fg_color }}"></i>
|
<i class="mdi mdi-cog"></i>
|
||||||
</a>
|
</a>
|
||||||
<div class="card-title flex-fill text-center">
|
<div class="card-title flex-fill text-center">
|
||||||
{% if widget.title %}
|
{% if widget.title %}
|
||||||
@ -28,12 +30,14 @@
|
|||||||
hx-target="#htmx-modal-content"
|
hx-target="#htmx-modal-content"
|
||||||
data-bs-toggle="modal"
|
data-bs-toggle="modal"
|
||||||
data-bs-target="#htmx-modal"
|
data-bs-target="#htmx-modal"
|
||||||
|
class="text-bg-{{ bg_color }}"
|
||||||
>
|
>
|
||||||
<i class="mdi mdi-close text-{{ widget.fg_color }}"></i>
|
<i class="mdi mdi-close"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body p-2 pt-1 overflow-auto">
|
<div class="card-body p-2 pt-1 overflow-auto">
|
||||||
{% render_widget widget %}
|
{% render_widget widget %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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>
|
||||||
|
@ -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
@ -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))
|
||||||
|
@ -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 ''
|
||||||
|
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user