Merge pull request #11965 from netbox-community/develop

Release v3.4.6
This commit is contained in:
Jeremy Stretch 2023-03-13 11:49:41 -04:00 committed by GitHub
commit 6b6ea36b4c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 397 additions and 165 deletions

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.4.5
placeholder: v3.4.6
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: v3.4.5
placeholder: v3.4.6
validations:
required: true
- type: dropdown

View File

@ -13,9 +13,9 @@ NetBox provides the ideal "source of truth" to power network automation.
Available as open source software under the Apache 2.0 license, NetBox serves
as the cornerstone for network automation in thousands of organizations.
* **Physical infrasucture:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
* **Physical infrastructure:** Accurately model the physical world, from global regions down to individual racks of gear. Then connect everything - network, console, and power!
* **Modern IPAM:** All the standard IPAM functionality you expect, plus VRF import/export tracking, VLAN management, and overlay support.
* **Data circuits:** Confidently manage the delivery of crtical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
* **Data circuits:** Confidently manage the delivery of critical circuits from various service providers, modeled seamlessly alongside your own infrastructure.
* **Power tracking:** Map the distribution of power from upstream sources to individual feeds and outlets.
* **Organization:** Manage tenant and contact assignments natively.
* **Powerful search:** Easily find anything you need using a single global search function.

View File

@ -1,3 +1,12 @@
<VirtualHost *:80>
# CHANGE THIS TO YOUR SERVER'S NAME
ServerName netbox.example.com
RewriteEngine On
RewriteCond %{HTTPS} !=on
RewriteRule ^/?(.*) https://%{SERVER_NAME}/$1 [R,L]
</VirtualHost>
<VirtualHost *:443>
ProxyPreserveHost On

View File

@ -65,7 +65,7 @@ sudo cp /opt/netbox/contrib/apache.conf /etc/apache2/sites-available/netbox.conf
Finally, ensure that the required Apache modules are enabled, enable the `netbox` site, and reload Apache:
```no-highlight
sudo a2enmod ssl proxy proxy_http headers
sudo a2enmod ssl proxy proxy_http headers rewrite
sudo a2ensite netbox
sudo systemctl restart apache2
```

View File

@ -1,5 +1,33 @@
# NetBox v3.4
## v3.4.6 (2023-03-13)
### Enhancements
* [#10058](https://github.com/netbox-community/netbox/issues/10058) - Enable searching for devices/VMs by primary IP address
* [#11011](https://github.com/netbox-community/netbox/issues/11011) - Add ability to toggle visibility of virtual interfaces under device view
* [#11294](https://github.com/netbox-community/netbox/issues/11294) - Enable live preview of Markdown content
* [#11807](https://github.com/netbox-community/netbox/issues/11807) - Restore default page size when navigating between views
* [#11817](https://github.com/netbox-community/netbox/issues/11817) - Add `connected_endpoints` field to GraphQL API for cabled objects
* [#11851](https://github.com/netbox-community/netbox/issues/11851) - Include IP version in GraphQL API representations of aggregates, prefixes, and IP addresses
* [#11862](https://github.com/netbox-community/netbox/issues/11862) - Add Cisco StackWise 1T interface type
* [#11871](https://github.com/netbox-community/netbox/issues/11871) - Add IEEE 802.3az PoE type for interfaces
* [#11929](https://github.com/netbox-community/netbox/issues/11929) - Strip whitespace from CSV headers prior to validation
### Bug Fixes
* [#11470](https://github.com/netbox-community/netbox/issues/11470) - Avoid raising exception when filtering IPs by an invalid address
* [#11565](https://github.com/netbox-community/netbox/issues/11565) - Apply custom field defaults to IP address created during FHRP group creation
* [#11631](https://github.com/netbox-community/netbox/issues/11631) - Fix filtering changelog & journal entries by multiple content type IDs
* [#11758](https://github.com/netbox-community/netbox/issues/11758) - Support non-URL-safe characters in plugin menu titles
* [#11796](https://github.com/netbox-community/netbox/issues/11796) - When importing devices, restrict rack by location only if the location field is specified
* [#11819](https://github.com/netbox-community/netbox/issues/11819) - Fix filtering of cable terminations by object type
* [#11850](https://github.com/netbox-community/netbox/issues/11850) - Fix loading of CSV files containing a byte order mark
* [#11903](https://github.com/netbox-community/netbox/issues/11903) - Fix escaping of return URL values for action buttons in tables
* [#11927](https://github.com/netbox-community/netbox/issues/11927) - Correct loading of plugin resources with custom paths
---
## v3.4.5 (2023-02-21)
### Enhancements

View File

@ -7,7 +7,7 @@ from ipam.models import ASN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SmallTextarea,
add_blank_choice, CommentField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
StaticSelect,
)
@ -35,7 +35,6 @@ class ProviderBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)
@ -63,7 +62,6 @@ class ProviderNetworkBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)
@ -125,7 +123,6 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)

View File

@ -902,6 +902,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_STACKWISE1T = 'cisco-stackwise-1t'
TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -1078,6 +1079,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_STACKWISE1T, 'Cisco StackWise-1T'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),
@ -1135,6 +1137,7 @@ class InterfacePoETypeChoices(ChoiceSet):
TYPE_1_8023AF = 'type1-ieee802.3af'
TYPE_2_8023AT = 'type2-ieee802.3at'
TYPE_2_8023AZ = 'type2-ieee802.3az'
TYPE_3_8023BT = 'type3-ieee802.3bt'
TYPE_4_8023BT = 'type4-ieee802.3bt'
@ -1149,6 +1152,7 @@ class InterfacePoETypeChoices(ChoiceSet):
(
(TYPE_1_8023AF, '802.3af (Type 1)'),
(TYPE_2_8023AT, '802.3at (Type 2)'),
(TYPE_2_8023AZ, '802.3az (Type 2)'),
(TYPE_3_8023BT, '802.3bt (Type 3)'),
(TYPE_4_8023BT, '802.3bt (Type 4)'),
)

View File

@ -981,7 +981,9 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter
Q(serial__icontains=value.strip()) |
Q(inventoryitems__serial__icontains=value.strip()) |
Q(asset_tag__icontains=value.strip()) |
Q(comments__icontains=value)
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
).distinct()
def _has_primary_ip(self, queryset, name, value):
@ -1725,6 +1727,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet):
class CableTerminationFilterSet(BaseFilterSet):
termination_type = ContentTypeFilter()
class Meta:
model = CableTermination

View File

@ -11,7 +11,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, form_from_model, SmallTextarea, StaticSelect, SelectSpeedWidget,
DynamicModelMultipleChoiceField, form_from_model, StaticSelect, SelectSpeedWidget
)
__all__ = (
@ -138,7 +138,6 @@ class SiteBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -309,7 +308,6 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -345,7 +343,6 @@ class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -406,7 +403,6 @@ class DeviceTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -441,7 +437,6 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -551,7 +546,6 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -594,7 +588,6 @@ class ModuleBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -644,7 +637,6 @@ class CableBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -668,7 +660,6 @@ class VirtualChassisBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -714,7 +705,6 @@ class PowerPanelBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -776,7 +766,6 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)

View File

@ -447,11 +447,14 @@ class DeviceImportForm(BaseDeviceImportForm):
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
# Limit rack queryset by assigned site and group
# Limit rack queryset by assigned site and location
params = {
f"site__{self.fields['site'].to_field_name}": data.get('site'),
f"location__{self.fields['location'].to_field_name}": data.get('location'),
}
if 'location' in data:
params.update({
f"location__{self.fields['location'].to_field_name}": data.get('location'),
})
self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
# Limit device bay queryset by parent device

View File

@ -10,3 +10,11 @@ class CabledObjectMixin:
def resolve_link_peers(self, info):
return self.link_peers
class PathEndpointMixin:
connected_endpoints = graphene.List('dcim.graphql.gfk_mixins.LinkPeerType')
def resolve_connected_endpoints(self, info):
# Handle empty values
return self.connected_endpoints or None

View File

@ -7,7 +7,7 @@ from extras.graphql.mixins import (
from ipam.graphql.mixins import IPAddressesMixin, VLANGroupsMixin
from netbox.graphql.scalars import BigInt
from netbox.graphql.types import BaseObjectType, OrganizationalObjectType, NetBoxObjectType
from .mixins import CabledObjectMixin
from .mixins import CabledObjectMixin, PathEndpointMixin
__all__ = (
'CableType',
@ -117,7 +117,7 @@ class CableTerminationType(NetBoxObjectType):
filterset_class = filtersets.CableTerminationFilterSet
class ConsolePortType(ComponentObjectType, CabledObjectMixin):
class ConsolePortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.ConsolePort
@ -139,7 +139,7 @@ class ConsolePortTemplateType(ComponentTemplateObjectType):
return self.type or None
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin):
class ConsoleServerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.ConsoleServerPort
@ -241,7 +241,7 @@ class FrontPortTemplateType(ComponentTemplateObjectType):
filterset_class = filtersets.FrontPortTemplateFilterSet
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin):
class InterfaceType(IPAddressesMixin, ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.Interface
@ -354,7 +354,7 @@ class PlatformType(OrganizationalObjectType):
filterset_class = filtersets.PlatformFilterSet
class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
class PowerFeedType(NetBoxObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.PowerFeed
@ -362,7 +362,7 @@ class PowerFeedType(NetBoxObjectType, CabledObjectMixin):
filterset_class = filtersets.PowerFeedFilterSet
class PowerOutletType(ComponentObjectType, CabledObjectMixin):
class PowerOutletType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.PowerOutlet
@ -398,7 +398,7 @@ class PowerPanelType(NetBoxObjectType, ContactsMixin):
filterset_class = filtersets.PowerPanelFilterSet
class PowerPortType(ComponentObjectType, CabledObjectMixin):
class PowerPortType(ComponentObjectType, CabledObjectMixin, PathEndpointMixin):
class Meta:
model = models.PowerPort

View File

@ -588,6 +588,7 @@ class DeviceInterfaceTable(InterfaceTable):
'class': get_interface_row_class,
'data-name': lambda record: record.name,
'data-enabled': get_interface_state_attribute,
'data-type': lambda record: record.type,
}

View File

@ -210,6 +210,9 @@ class ImageAttachmentFilterSet(BaseFilterSet):
class JournalEntryFilterSet(NetBoxModelFilterSet):
created = django_filters.DateTimeFromToRangeFilter()
assigned_object_type = ContentTypeFilter()
assigned_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),
@ -458,6 +461,9 @@ class ObjectChangeFilterSet(BaseFilterSet):
)
time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter()
changed_object_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=ContentType.objects.all()
)
user_id = django_filters.ModelMultipleChoiceFilter(
queryset=User.objects.all(),
label=_('User (ID)'),

View File

@ -2,6 +2,7 @@ from .model_forms import *
from .filtersets import *
from .bulk_edit import *
from .bulk_import import *
from .misc import *
from .mixins import *
from .config import *
from .scripts import *

View File

@ -0,0 +1,14 @@
from django import forms
__all__ = (
'RenderMarkdownForm',
)
class RenderMarkdownForm(forms.Form):
"""
Provides basic validation for markup to be rendered.
"""
text = forms.CharField(
required=False
)

View File

@ -78,8 +78,8 @@ class PluginConfig(AppConfig):
def _load_resource(self, name):
# Import from the configured path, if defined.
if getattr(self, name):
return import_string(f"{self.__module__}.{self.name}")
if path := getattr(self, name, None):
return import_string(f"{self.__module__}.{path}")
# Fall back to the resource's default path. Return None if the module has not been provided.
default_path = f'{self.__module__}.{DEFAULT_RESOURCE_PATHS[name]}'

View File

@ -1,5 +1,6 @@
from netbox.navigation import MenuGroup
from utilities.choices import ButtonColorChoices
from django.utils.text import slugify
__all__ = (
'PluginMenu',
@ -21,7 +22,7 @@ class PluginMenu:
@property
def name(self):
return self.label.replace(' ', '_')
return slugify(self.label)
class PluginMenuItem:

View File

@ -502,7 +502,7 @@ class JournalEntryTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_assigned_object_type(self):
params = {'assigned_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
params = {'assigned_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
params = {'assigned_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_assigned_object(self):
@ -876,7 +876,5 @@ class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
def test_changed_object_type(self):
params = {'changed_object_type': 'dcim.site'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_changed_object_type_id(self):
params = {'changed_object_type_id': ContentType.objects.get(app_label='dcim', model='site').pk}
params = {'changed_object_type_id': [ContentType.objects.get(app_label='dcim', model='site').pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)

View File

@ -92,4 +92,6 @@ urlpatterns = [
path('scripts/results/<int:job_result_pk>/', views.ScriptResultView.as_view(), name='script_result'),
re_path(r'^scripts/(?P<module>.([^.]+)).(?P<name>.(.+))/', views.ScriptView.as_view(), name='script'),
# Markdown
path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown")
]

View File

@ -1,7 +1,7 @@
from django.contrib import messages
from django.contrib.contenttypes.models import ContentType
from django.db.models import Count, Q
from django.http import Http404, HttpResponseForbidden
from django.http import Http404, HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.views.generic import View
@ -10,6 +10,7 @@ from rq import Worker
from netbox.views import generic
from utilities.htmx import is_htmx
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables
@ -885,3 +886,18 @@ class JobResultBulkDeleteView(generic.BulkDeleteView):
queryset = JobResult.objects.all()
filterset = filtersets.JobResultFilterSet
table = tables.JobResultTable
#
# Markdown
#
class RenderMarkdownView(View):
def post(self, request):
form = forms.RenderMarkdownForm(request.POST)
if not form.is_valid():
HttpResponseBadRequest()
rendered = render_markdown(form.cleaned_data['text'])
return HttpResponse(rendered)

View File

@ -1,4 +1,4 @@
#!/usr/bin/env python
#!/usr/bin/env python3
# This script will generate a random 50-character string suitable for use as a SECRET_KEY.
import secrets

View File

@ -16,6 +16,7 @@ from virtualization.models import VirtualMachine, VMInterface
from .choices import *
from .models import *
from rest_framework import serializers
__all__ = (
'AggregateFilterSet',
@ -599,7 +600,33 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
return queryset.none()
return queryset.filter(q)
def parse_inet_addresses(self, value):
'''
Parse networks or IP addresses and cast to a format
acceptable by the Postgres inet type.
Skips invalid values.
'''
parsed = []
for addr in value:
if netaddr.valid_ipv4(addr) or netaddr.valid_ipv6(addr):
parsed.append(addr)
continue
try:
network = netaddr.IPNetwork(addr)
parsed.append(str(network))
except (AddrFormatError, ValueError):
continue
return parsed
def filter_address(self, queryset, name, value):
# Let's first parse the addresses passed
# as argument. If they are all invalid,
# we return an empty queryset
value = self.parse_inet_addresses(value)
if (len(value) == 0):
return queryset.none()
try:
return queryset.filter(address__net_in=value)
except ValidationError:

View File

@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, CommentField, DynamicModelChoiceField, NumericArrayField,
SmallTextarea, StaticSelect, DynamicModelMultipleChoiceField,
StaticSelect, DynamicModelMultipleChoiceField
)
__all__ = (
@ -48,7 +48,6 @@ class VRFBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -69,7 +68,6 @@ class RouteTargetBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -116,7 +114,6 @@ class ASNBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -145,7 +142,6 @@ class AggregateBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -227,7 +223,6 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -266,7 +261,6 @@ class IPRangeBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -314,7 +308,6 @@ class IPAddressBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -359,7 +352,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -442,7 +434,6 @@ class VLANBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -474,7 +465,6 @@ class ServiceTemplateBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -504,7 +494,6 @@ class L2VPNBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)

View File

@ -578,6 +578,7 @@ class FHRPGroupForm(NetBoxModelForm):
role=FHRP_PROTOCOL_ROLE_MAPPINGS.get(self.cleaned_data['protocol'], IPAddressRoleChoices.ROLE_VIP),
assigned_object=instance
)
ipaddress.populate_custom_field_defaults()
ipaddress.save()
# Check that the new IPAddress conforms with any assigned object-level permissions

View File

@ -27,6 +27,28 @@ __all__ = (
)
class IPAddressFamilyType(graphene.ObjectType):
value = graphene.Int()
label = graphene.String()
def __init__(self, value):
self.value = value
self.label = f'IPv{value}'
class BaseIPAddressFamilyType:
'''
Base type for models that need to expose their IPAddress family type.
'''
family = graphene.Field(IPAddressFamilyType)
def resolve_family(self, _):
# Note that self, is an instance of models.IPAddress
# thus resolves to the address family value.
return IPAddressFamilyType(self.family)
class ASNType(NetBoxObjectType):
asn = graphene.Field(BigInt)
@ -36,7 +58,7 @@ class ASNType(NetBoxObjectType):
filterset_class = filtersets.ASNFilterSet
class AggregateType(NetBoxObjectType):
class AggregateType(NetBoxObjectType, BaseIPAddressFamilyType):
class Meta:
model = models.Aggregate
@ -64,7 +86,7 @@ class FHRPGroupAssignmentType(BaseObjectType):
filterset_class = filtersets.FHRPGroupAssignmentFilterSet
class IPAddressType(NetBoxObjectType):
class IPAddressType(NetBoxObjectType, BaseIPAddressFamilyType):
assigned_object = graphene.Field('ipam.graphql.gfk_mixins.IPAddressAssignmentType')
class Meta:
@ -87,7 +109,7 @@ class IPRangeType(NetBoxObjectType):
return self.role or None
class PrefixType(NetBoxObjectType):
class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType):
class Meta:
model = models.Prefix

View File

@ -10,6 +10,7 @@ from ipam.models import *
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device, create_test_virtualmachine
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
from tenancy.models import Tenant, TenantGroup
from rest_framework import serializers
class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
@ -851,6 +852,26 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'address': ['2001:db8::1/64', '2001:db8::1/65']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# Check for valid edge cases. Note that Postgres inet type
# only accepts netmasks in the int form, so the filterset
# casts netmasks in the xxx.xxx.xxx.xxx format.
params = {'address': ['24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'address': ['10.0.0.1/255.255.255.0']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'address': ['10.0.0.1/255.255.255.0', '10.0.0.1/25']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# Check for invalid input.
params = {'address': ['/24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
params = {'address': ['10.0.0.1/255.255.999.0']} # Invalid netmask
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
# Check for partially invalid input.
params = {'address': ['10.0.0.1', '/24', '10.0.0.10/24']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mask_length(self):
params = {'mask_length': '24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)

View File

@ -216,6 +216,13 @@ class CustomFieldsMixin(models.Model):
return dict(groups)
def populate_custom_field_defaults(self):
"""
Apply the default value for each custom field
"""
for cf in self.custom_fields:
self.custom_field_data[cf.name] = cf.default
def clean(self):
super().clean()
from extras.models import CustomField

View File

@ -24,7 +24,7 @@ PREFERENCES = {
'pagination.per_page': UserPreference(
label=_('Page length'),
choices=get_page_lengths(),
description=_('The number of objects to display per page'),
description=_('The default number of objects to display per page'),
coerce=lambda x: int(x)
),
'pagination.placement': UserPreference(

View File

@ -24,7 +24,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup
#
VERSION = '3.4.5'
VERSION = '3.4.6'
# Hostname
HOSTNAME = platform.node()

View File

@ -1,5 +1,6 @@
from dataclasses import dataclass
from typing import Optional
from urllib.parse import quote
import django_tables2 as tables
from django.conf import settings
@ -8,7 +9,6 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.encoding import escape_uri_path
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.safestring import mark_safe
@ -235,7 +235,7 @@ class ActionsColumn(tables.Column):
model = table.Meta.model
request = getattr(table, 'context', {}).get('request')
url_appendix = f'?return_url={escape_uri_path(request.get_full_path())}' if request else ''
url_appendix = f'?return_url={quote(request.get_full_path())}' if request else ''
html = ''
# Compile actions menu

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4,6 +4,7 @@ import { initMoveButtons } from './moveOptions';
import { initReslug } from './reslug';
import { initSelectAll } from './selectAll';
import { initSelectMultiple } from './selectMultiple';
import { initMarkdownPreviews } from './markdownPreview';
export function initButtons(): void {
for (const func of [
@ -13,6 +14,7 @@ export function initButtons(): void {
initSelectAll,
initSelectMultiple,
initMoveButtons,
initMarkdownPreviews,
]) {
func();
}

View File

@ -0,0 +1,45 @@
import { isTruthy } from 'src/util';
/**
* interface for htmx configRequest event
*/
declare global {
interface HTMLElementEventMap {
'htmx:configRequest': CustomEvent<{
parameters: Record<string, string>;
headers: Record<string, string>;
}>;
}
}
function initMarkdownPreview(markdownWidget: HTMLDivElement) {
const previewButton = markdownWidget.querySelector('button.preview-button') as HTMLButtonElement;
const textarea = markdownWidget.querySelector('textarea') as HTMLTextAreaElement;
const preview = markdownWidget.querySelector('div.preview') as HTMLDivElement;
/**
* Make sure the textarea has style attribute height
* So that it can be copied over to preview div.
*/
if (!isTruthy(textarea.style.height)) {
const { height } = textarea.getBoundingClientRect();
textarea.style.height = `${height}px`;
}
/**
* Add the value of the textarea to the body of the htmx request
* and copy the height of text are to the preview div
*/
previewButton.addEventListener('htmx:configRequest', e => {
e.detail.parameters = { text: textarea.value || '' };
e.detail.headers['X-CSRFToken'] = window.CSRF_TOKEN;
preview.style.minHeight = textarea.style.height;
preview.innerHTML = '';
});
}
export function initMarkdownPreviews(): void {
for (const markdownWidget of document.querySelectorAll<HTMLDivElement>('.markdown-widget')) {
initMarkdownPreview(markdownWidget);
}
}

View File

@ -1,6 +1,5 @@
import { getElements, replaceAll, findFirstAdjacent } from '../util';
type InterfaceState = 'enabled' | 'disabled';
type ShowHide = 'show' | 'hide';
function isShowHide(value: unknown): value is ShowHide {
@ -27,54 +26,23 @@ class ButtonState {
* Underlying Button DOM Element
*/
public button: HTMLButtonElement;
/**
* Table rows with `data-enabled` set to `"enabled"`
*/
private enabledRows: NodeListOf<HTMLTableRowElement>;
/**
* Table rows with `data-enabled` set to `"disabled"`
*/
private disabledRows: NodeListOf<HTMLTableRowElement>;
constructor(button: HTMLButtonElement, table: HTMLTableElement) {
/**
* Table rows provided in constructor
*/
private rows: NodeListOf<HTMLTableRowElement>;
constructor(button: HTMLButtonElement, rows: NodeListOf<HTMLTableRowElement>) {
this.button = button;
this.enabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]');
this.disabledRows = table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]');
this.rows = rows;
}
/**
* This button's controlled type. For example, a button with the class `toggle-disabled` has
* directive 'disabled' because it controls the visibility of rows with
* `data-enabled="disabled"`. Likewise, `toggle-enabled` controls rows with
* `data-enabled="enabled"`.
* Remove visibility of button state rows.
*/
private get directive(): InterfaceState {
if (this.button.classList.contains('toggle-disabled')) {
return 'disabled';
} else if (this.button.classList.contains('toggle-enabled')) {
return 'enabled';
}
// If this class has been instantiated but doesn't contain these classes, it's probably because
// the classes are missing in the HTML template.
console.warn(this.button);
throw new Error('Toggle button does not contain expected class');
}
/**
* Toggle visibility of rows with `data-enabled="enabled"`.
*/
private toggleEnabledRows(): void {
for (const row of this.enabledRows) {
row.classList.toggle('d-none');
}
}
/**
* Toggle visibility of rows with `data-enabled="disabled"`.
*/
private toggleDisabledRows(): void {
for (const row of this.disabledRows) {
row.classList.toggle('d-none');
private hideRows(): void {
for (const row of this.rows) {
row.classList.add('d-none');
}
}
@ -111,17 +79,6 @@ class ButtonState {
}
}
/**
* Toggle visibility for the rows this element controls.
*/
private toggleRows(): void {
if (this.directive === 'enabled') {
this.toggleEnabledRows();
} else if (this.directive === 'disabled') {
this.toggleDisabledRows();
}
}
/**
* Toggle the DOM element's `data-state` attribute.
*/
@ -139,17 +96,20 @@ class ButtonState {
private toggle(): void {
this.toggleState();
this.toggleButton();
this.toggleRows();
}
/**
* When the button is clicked, toggle all controlled elements.
* When the button is clicked, toggle all controlled elements and hide rows based on
* buttonstate.
*/
public handleClick(event: Event): void {
const button = event.currentTarget as HTMLButtonElement;
if (button.isEqualNode(this.button)) {
this.toggle();
}
if (this.buttonState === 'hide') {
this.hideRows();
}
}
}
@ -174,14 +134,25 @@ class TableState {
// @ts-expect-error null handling is performed in the constructor
private disabledButton: ButtonState;
/**
* Instance of ButtonState for the 'show/hide virtual rows' button.
*/
// @ts-expect-error null handling is performed in the constructor
private virtualButton: ButtonState;
/**
* Underlying DOM Table Caption Element.
*/
private caption: Nullable<HTMLTableCaptionElement> = null;
/**
* All table rows in table
*/
private rows: NodeListOf<HTMLTableRowElement>;
constructor(table: HTMLTableElement) {
this.table = table;
this.rows = this.table.querySelectorAll('tr');
try {
const toggleEnabledButton = findFirstAdjacent<HTMLButtonElement>(
this.table,
@ -191,6 +162,10 @@ class TableState {
this.table,
'button.toggle-disabled',
);
const toggleVirtualButton = findFirstAdjacent<HTMLButtonElement>(
this.table,
'button.toggle-virtual',
);
const caption = this.table.querySelector('caption');
this.caption = caption;
@ -203,13 +178,28 @@ class TableState {
throw new TableStateError("Table is missing a 'toggle-disabled' button.", table);
}
if (toggleVirtualButton === null) {
throw new TableStateError("Table is missing a 'toggle-virtual' button.", table);
}
// Attach event listeners to the buttons elements.
toggleEnabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleDisabledButton.addEventListener('click', event => this.handleClick(event, this));
toggleVirtualButton.addEventListener('click', event => this.handleClick(event, this));
// Instantiate ButtonState for each button for state management.
this.enabledButton = new ButtonState(toggleEnabledButton, this.table);
this.disabledButton = new ButtonState(toggleDisabledButton, this.table);
this.enabledButton = new ButtonState(
toggleEnabledButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="enabled"]'),
);
this.disabledButton = new ButtonState(
toggleDisabledButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-enabled="disabled"]'),
);
this.virtualButton = new ButtonState(
toggleVirtualButton,
table.querySelectorAll<HTMLTableRowElement>('tr[data-type="virtual"]'),
);
} catch (err) {
if (err instanceof TableStateError) {
// This class is useless for tables that don't have toggle buttons.
@ -246,37 +236,42 @@ class TableState {
private toggleCaption(): void {
const showEnabled = this.enabledButton.buttonState === 'show';
const showDisabled = this.disabledButton.buttonState === 'show';
const showVirtual = this.virtualButton.buttonState === 'show';
if (showEnabled && !showDisabled) {
if (showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled Interfaces';
} else if (showEnabled && showDisabled) {
} else if (showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Enabled & Disabled Interfaces';
} else if (!showEnabled && showDisabled) {
} else if (!showEnabled && showDisabled && !showVirtual) {
this.captionText = 'Showing Disabled Interfaces';
} else if (!showEnabled && !showDisabled) {
this.captionText = 'Hiding Enabled & Disabled Interfaces';
} else if (!showEnabled && !showDisabled && !showVirtual) {
this.captionText = 'Hiding Enabled, Disabled & Virtual Interfaces';
} else if (!showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Virtual Interfaces';
} else if (showEnabled && !showDisabled && showVirtual) {
this.captionText = 'Showing Enabled & Virtual Interfaces';
} else if (showEnabled && showDisabled && showVirtual) {
this.captionText = 'Showing Enabled, Disabled & Virtual Interfaces';
} else {
this.captionText = '';
}
}
/**
* When toggle buttons are clicked, pass the event to the relevant button's handler and update
* this instance's state.
* When toggle buttons are clicked, reapply visability all rows and
* pass the event to all button handlers
*
* @param event onClick event for toggle buttons.
* @param instance Instance of TableState (`this` cannot be used since that's context-specific).
*/
public handleClick(event: Event, instance: TableState): void {
const button = event.currentTarget as HTMLButtonElement;
const enabled = button.isEqualNode(instance.enabledButton.button);
const disabled = button.isEqualNode(instance.disabledButton.button);
if (enabled) {
instance.enabledButton.handleClick(event);
} else if (disabled) {
instance.disabledButton.handleClick(event);
for (const row of this.rows) {
row.classList.remove('d-none');
}
instance.enabledButton.handleClick(event);
instance.disabledButton.handleClick(event);
instance.virtualButton.handleClick(event);
instance.toggleCaption();
}
}

View File

@ -236,12 +236,12 @@ table {
}
th.asc > a::after {
content: "\f0140";
content: '\f0140';
font-family: 'Material Design Icons';
}
th.desc > a::after {
content: "\f0143";
content: '\f0143';
font-family: 'Material Design Icons';
}
@ -416,18 +416,18 @@ nav.search {
}
}
// Styles for the quicksearch and its clear button;
// Styles for the quicksearch and its clear button;
// Overrides input-group styles and adds transition effects
.quicksearch {
input[type="search"] {
border-radius: $border-radius !important;
input[type='search'] {
border-radius: $border-radius !important;
}
button {
margin-left: -32px !important;
z-index: 100 !important;
outline: none !important;
border-radius: $border-radius !important;
border-radius: $border-radius !important;
transition: visibility 0s, opacity 0.2s linear;
}
@ -998,9 +998,24 @@ div.card-overlay {
padding: 8px;
}
/* Markdown widget */
.markdown-widget {
.nav-link {
border-bottom: 0;
&.active {
background-color: var(--nbx-body-bg);
}
}
.nav-tabs {
background-color: var(--nbx-pre-bg);
}
}
// Preformatted text blocks
td pre {
margin-bottom: 0
margin-bottom: 0;
}
pre.block {
padding: $spacer;

View File

@ -7,5 +7,6 @@
<ul class="dropdown-menu">
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
</ul>
{% endblock extra_table_controls %}

View File

@ -2,7 +2,7 @@ from django import forms
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import *
from utilities.forms import CommentField, DynamicModelChoiceField, SmallTextarea
from utilities.forms import CommentField, DynamicModelChoiceField
__all__ = (
'ContactBulkEditForm',
@ -106,7 +106,6 @@ class ContactBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)

View File

@ -27,7 +27,7 @@ class CommentField(forms.CharField):
"""
A textarea with support for Markdown rendering. Exists mostly just to add a standard `help_text`.
"""
widget = forms.Textarea
widget = widgets.MarkdownWidget
help_text = f"""
<i class="mdi mdi-information-outline"></i>
<a href="{static('docs/reference/markdown/')}" target="_blank" tabindex="-1">

View File

@ -180,7 +180,7 @@ class ImportForm(BootstrapMixin, forms.Form):
if 'data_file' in self.files:
self.data_field = 'data_file'
file = self.files.get('data_file')
data = file.read().decode('utf-8')
data = file.read().decode('utf-8-sig')
else:
data = self.cleaned_data['data']

View File

@ -195,6 +195,7 @@ def parse_csv(reader):
# `site.slug` header, to indicate the related site is being referenced by its slug.
for header in next(reader):
header = header.strip()
if '.' in header:
field, to_field = header.split('.', 1)
headers[field] = to_field

View File

@ -16,6 +16,7 @@ __all__ = (
'ColorSelect',
'DatePicker',
'DateTimePicker',
'MarkdownWidget',
'NumericArrayField',
'SelectDurationWidget',
'SelectSpeedWidget',
@ -116,6 +117,10 @@ class SelectDurationWidget(forms.NumberInput):
template_name = 'widgets/select_duration.html'
class MarkdownWidget(forms.Textarea):
template_name = 'widgets/markdown_input.html'
class NumericArrayField(SimpleArrayField):
def clean(self, value):

View File

@ -76,8 +76,6 @@ def get_paginate_count(request):
if 'per_page' in request.GET:
try:
per_page = int(request.GET.get('per_page'))
if request.user.is_authenticated:
request.user.config.set('pagination.per_page', per_page, commit=True)
return _max_allowed(per_page)
except ValueError:
pass

View File

@ -6,7 +6,7 @@
{# Render the field label, except for: #}
{# 1. Checkboxes (label appears to the right of the field #}
{# 2. Textareas with no label set (will expand across entire row) #}
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' and not label %}
{% if field|widget_type == 'checkboxinput' or field|widget_type == 'textarea' or field|widget_type == 'markdownwidget' and not label %}
{% else %}
<label for="{{ field.id_for_label }}" class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}">
{{ label }}

View File

@ -0,0 +1,22 @@
<div class="border rounded markdown-widget">
<ul class="nav nav-tabs px-3 pt-2 rounded-top border-0">
<li class="nav-item" role="presentation">
<button class="nav-link active " id="{{ widget.name }}-input-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-input" type="button" role="tab" aria-controls="{{ widget.name }}-input" aria-selected="true">
Write
</button>
</li>
<li class="nav-item" role="presentation">
<button hx-target="#{{ widget.name }}-preview" hx-swap="innerHTML" hx-post="{% url 'extras:render_markdown' %}" class="nav-link preview-button" id="{{ widget.name }}-markdown-preview-tab" data-bs-toggle="tab" data-bs-target="#{{ widget.name }}-markdown-preview" type="button" role="tab" aria-controls="{{ widget.name }}-markdown-preview" aria-selected="false">
Preview
</button>
</li>
</ul>
<div class="tab-content bg-body rounded-bottom border-top">
<div class="tab-pane show active" id="{{ widget.name }}-input" role="tabpanel" aria-labelledby="{{ widget.name }}-input-tab">
{% include "django/forms/widgets/textarea.html" %}
</div>
<div class="tab-pane show" id="{{ widget.name }}-markdown-preview" role="tabpanel" aria-labelledby="{{ widget.name }}-markdown-preview-tab">
<div id="{{ widget.name }}-preview" class="preview px-3 py-2">Testing</div>
</div>
</div>
</div>

View File

@ -17,6 +17,8 @@ from utilities.api import get_graphql_type_for_model
from .base import ModelTestCase
from .utils import disable_warnings
from ipam.graphql.types import IPAddressFamilyType
__all__ = (
'APITestCase',
@ -460,6 +462,8 @@ class APIViewTestCases:
# TODO: Come up with something more elegant
# Temporary hack to support automated testing of reverse generic relations
fields_string += f'{field_name} {{ id }}\n'
elif inspect.isclass(field.type) and issubclass(field.type, IPAddressFamilyType):
fields_string += f'{field_name} {{ value, label }}\n'
else:
fields_string += f'{field_name}\n'

View File

@ -359,18 +359,18 @@ def prepare_cloned_fields(instance):
return QueryDict(urlencode(params), mutable=True)
def shallow_compare_dict(source_dict, destination_dict, exclude=None):
def shallow_compare_dict(source_dict, destination_dict, exclude=tuple()):
"""
Return a new dictionary of the different keys. The values of `destination_dict` are returned. Only the equality of
the first layer of keys/values is checked. `exclude` is a list or tuple of keys to be ignored.
"""
difference = {}
for key in destination_dict:
if source_dict.get(key) != destination_dict[key]:
if isinstance(exclude, (list, tuple)) and key in exclude:
continue
difference[key] = destination_dict[key]
for key, value in destination_dict.items():
if key in exclude:
continue
if source_dict.get(key) != value:
difference[key] = value
return difference

View File

@ -238,7 +238,9 @@ class VirtualMachineFilterSet(
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(comments__icontains=value)
Q(comments__icontains=value) |
Q(primary_ip4__address__startswith=value) |
Q(primary_ip6__address__startswith=value)
)
def _has_primary_ip(self, queryset, name, value):

View File

@ -9,7 +9,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, SmallTextarea, StaticSelect
DynamicModelMultipleChoiceField, StaticSelect
)
from virtualization.choices import *
from virtualization.models import *
@ -90,7 +90,6 @@ class ClusterBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)
@ -163,7 +162,6 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label=_('Comments')
)

View File

@ -5,7 +5,7 @@ from dcim.choices import LinkStatusChoices
from ipam.models import VLAN
from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField, SmallTextarea
from utilities.forms import add_blank_choice, CommentField, DynamicModelChoiceField
from wireless.choices import *
from wireless.constants import SSID_MAX_LENGTH
from wireless.models import *
@ -74,7 +74,6 @@ class WirelessLANBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)
@ -119,7 +118,6 @@ class WirelessLinkBulkEditForm(NetBoxModelBulkEditForm):
required=False
)
comments = CommentField(
widget=SmallTextarea,
label='Comments'
)

View File

@ -1,6 +1,6 @@
bleach==5.0.1
Django==4.1.7
django-cors-headers==3.13.0
django-cors-headers==3.14.0
django-debug-toolbar==3.8.1
django-filter==22.1
django-graphiql-debug-toolbar==0.2.0
@ -8,9 +8,9 @@ django-mptt==0.14
django-pglocks==1.0.4
django-prometheus==2.2.0
django-redis==5.2.0
django-rich==1.4.0
django-rich==1.5.0
django-rq==2.7.0
django-tables2==2.5.2
django-tables2==2.5.3
django-taggit==3.1.0
django-timezone-field==5.0
djangorestframework==3.14.0
@ -19,13 +19,13 @@ graphene-django==3.0.0
gunicorn==20.1.0
Jinja2==3.1.2
Markdown==3.3.7
mkdocs-material==9.0.13
mkdocs-material==9.1.2
mkdocstrings[python-legacy]==0.20.0
netaddr==0.8.0
Pillow==9.4.0
psycopg2-binary==2.9.5
PyYAML==6.0
sentry-sdk==1.15.0
sentry-sdk==1.16.0
social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0
svgwrite==1.4.3