1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge branch 'develop' into feature

This commit is contained in:
jeremystretch
2023-03-28 14:19:08 -04:00
20 changed files with 109 additions and 38 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.4.6 placeholder: v3.4.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.4.6 placeholder: v3.4.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -129,7 +129,8 @@ social-auth-core
# Django app for social-auth-core # Django app for social-auth-core
# https://github.com/python-social-auth/social-app-django # https://github.com/python-social-auth/social-app-django
social-auth-app-django # See https://github.com/python-social-auth/social-app-django/issues/429
social-auth-app-django==5.0.0
# SVG image rendering (used for rack elevations) # SVG image rendering (used for rack elevations)
# https://github.com/mozman/svgwrite # https://github.com/mozman/svgwrite

View File

@ -16,7 +16,7 @@ If true, NetBox will automatically create local accounts for users authenticated
Default: `'netbox.authentication.RemoteUserBackend'` Default: `'netbox.authentication.RemoteUserBackend'`
This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. This is the Python path to the custom [Django authentication backend](https://docs.djangoproject.com/en/stable/topics/auth/customizing/) to use for external user authentication. NetBox provides two built-in backends (listed below), though custom authentication backends may also be provided by other packages or plugins. Provide a string for a single backend, or an iterable for multiple backends, which will be attempted in the order given.
* `netbox.authentication.RemoteUserBackend` * `netbox.authentication.RemoteUserBackend`
* `netbox.authentication.LDAPBackend` * `netbox.authentication.LDAPBackend`

View File

@ -38,7 +38,7 @@ In order to send email, NetBox needs an email server configured. The following i
* `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally) * `SERVER` - Hostname or IP address of the email server (use `localhost` if running locally)
* `PORT` - TCP port to use for the connection (default: `25`) * `PORT` - TCP port to use for the connection (default: `25`)
* `USERNAME` - Username with which to authenticate * `USERNAME` - Username with which to authenticate
* `PASSSWORD` - Password with which to authenticate * `PASSWORD` - Password with which to authenticate
* `USE_SSL` - Use SSL when connecting to the server (default: `False`) * `USE_SSL` - Use SSL when connecting to the server (default: `False`)
* `USE_TLS` - Use TLS when connecting to the server (default: `False`) * `USE_TLS` - Use TLS when connecting to the server (default: `False`)
* `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional) * `SSL_CERTFILE` - Path to the PEM-formatted SSL certificate file (optional)

View File

@ -1,15 +1,31 @@
# NetBox v3.4 # NetBox v3.4
## v3.4.7 (FUTURE) ## v3.4.8 (FUTURE)
---
## v3.4.7 (2023-03-28)
### Enhancements ### Enhancements
* [#11645](https://github.com/netbox-community/netbox/issues/11645) - Automatically set the scheduled time when executing reports/scripts at a recurring interval
* [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms * [#11833](https://github.com/netbox-community/netbox/issues/11833) - Add fieldset support for custom script forms
* [#11973](https://github.com/netbox-community/netbox/issues/11833) - Use SSID for representing wireless links, if set
* [#11977](https://github.com/netbox-community/netbox/issues/11977) - Support designating multiple backends via `REMOTE_AUTH_BACKEND` config parameter
* [#11990](https://github.com/netbox-community/netbox/issues/11990) - Improve error reporting for duplicate CSV column headings
* [#11991](https://github.com/netbox-community/netbox/issues/11991) - Enable VDC assignment during bulk import/edit of interfaces
### Bug Fixes ### Bug Fixes
* [#11914](https://github.com/netbox-community/netbox/issues/11914) - Include parameters when exporting saved filters
* [#11933](https://github.com/netbox-community/netbox/issues/11933) - Fix cloning of saved filters
* [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type * [#11984](https://github.com/netbox-community/netbox/issues/11984) - Remove erroneous 802.3az PoE type
* [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list * [#11979](https://github.com/netbox-community/netbox/issues/11979) - Correct URL for tags in route targets list
* [#12008](https://github.com/netbox-community/netbox/issues/12008) - Enable cloning of export templates
* [#12029](https://github.com/netbox-community/netbox/issues/12029) - Restore missing description field on virtual chassis form
* [#12038](https://github.com/netbox-community/netbox/issues/12038) - Correct display of zero values for virtual chassis member priority
* [#12048](https://github.com/netbox-community/netbox/issues/12048) - Enable cloning of tags
* [#12058](https://github.com/netbox-community/netbox/issues/12058) - Enable cloning of config contexts
--- ---

View File

@ -1160,6 +1160,14 @@ class InterfaceBulkEditForm(
}, },
label=_('LAG') label=_('LAG')
) )
vdcs = DynamicModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
label='Virtual Device Contexts',
query_params={
'device_id': '$device',
}
)
speed = forms.IntegerField( speed = forms.IntegerField(
required=False, required=False,
widget=SelectSpeedWidget(), widget=SelectSpeedWidget(),
@ -1222,14 +1230,14 @@ class InterfaceBulkEditForm(
fieldsets = ( fieldsets = (
(None, ('module', 'type', 'label', 'speed', 'duplex', 'description')), (None, ('module', 'type', 'label', 'speed', 'duplex', 'description')),
('Addressing', ('vrf', 'mac_address', 'wwn')), ('Addressing', ('vrf', 'mac_address', 'wwn')),
('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Operation', ('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
('PoE', ('poe_mode', 'poe_type')), ('PoE', ('poe_mode', 'poe_type')),
('Related Interfaces', ('parent', 'bridge', 'lag')), ('Related Interfaces', ('parent', 'bridge', 'lag')),
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
) )
nullable_fields = ( nullable_fields = (
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'description', 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
) )

View File

@ -12,7 +12,9 @@ from extras.models import ConfigTemplate
from ipam.models import VRF from ipam.models import VRF
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField from utilities.forms import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField, CSVModelMultipleChoiceField
)
from virtualization.models import Cluster from virtualization.models import Cluster
from wireless.choices import WirelessRoleChoices from wireless.choices import WirelessRoleChoices
from .common import ModuleCommonForm from .common import ModuleCommonForm
@ -691,6 +693,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Parent LAG interface') help_text=_('Parent LAG interface')
) )
vdcs = CSVModelMultipleChoiceField(
queryset=VirtualDeviceContext.objects.all(),
required=False,
to_field_name='name',
help_text='VDC names separated by commas, encased with double quotes (e.g. "vdc1, vdc2, vdc3")'
)
type = CSVChoiceField( type = CSVChoiceField(
choices=InterfaceTypeChoices, choices=InterfaceTypeChoices,
help_text=_('Physical medium') help_text=_('Physical medium')
@ -730,7 +738,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
model = Interface model = Interface
fields = ( fields = (
'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled', 'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
'mark_connected', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode', 'mark_connected', 'mac_address', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags' 'vrf', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'tags'
) )
@ -746,6 +754,7 @@ class InterfaceImportForm(NetBoxModelImportForm):
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params) self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params) self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params) self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
self.fields['vdcs'].queryset = self.fields['vdcs'].queryset.filter(**params)
def clean_enabled(self): def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data # Make sure enabled is True when it's not included in the uploaded data
@ -754,6 +763,12 @@ class InterfaceImportForm(NetBoxModelImportForm):
else: else:
return self.cleaned_data['enabled'] return self.cleaned_data['enabled']
def clean_vdcs(self):
for vdc in self.cleaned_data['vdcs']:
if vdc.device != self.cleaned_data['device']:
raise forms.ValidationError(f"VDC {vdc} is not assigned to device {self.cleaned_data['device']}")
return self.cleaned_data['vdcs']
class FrontPortImportForm(NetBoxModelImportForm): class FrontPortImportForm(NetBoxModelImportForm):
device = CSVModelChoiceField( device = CSVModelChoiceField(

View File

@ -359,7 +359,7 @@ class VirtualChassisCreateForm(NetBoxModelForm):
class Meta: class Meta:
model = VirtualChassis model = VirtualChassis
fields = [ fields = [
'name', 'domain', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags', 'name', 'domain', 'description', 'region', 'site_group', 'site', 'rack', 'members', 'initial_position', 'tags',
] ]
def clean(self): def clean(self):

View File

@ -1,6 +1,7 @@
import json
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.http import QueryDict
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
@ -147,11 +148,10 @@ class SavedFilterForm(BootstrapMixin, forms.ModelForm):
def __init__(self, *args, initial=None, **kwargs): def __init__(self, *args, initial=None, **kwargs):
# Convert any parameters delivered via initial data to a dictionary # Convert any parameters delivered via initial data to JSON data
if initial and 'parameters' in initial: if initial and 'parameters' in initial:
if type(initial['parameters']) is str: if type(initial['parameters']) is str:
# TODO: Make a utility function for this initial['parameters'] = json.loads(initial['parameters'])
initial['parameters'] = dict(QueryDict(initial['parameters']).lists())
super().__init__(*args, initial=initial, **kwargs) super().__init__(*args, initial=initial, **kwargs)
@ -277,8 +277,14 @@ class ConfigContextForm(BootstrapMixin, SyncedDataMixin, forms.ModelForm):
'tenants', 'tags', 'data_source', 'data_file', 'tenants', 'tags', 'data_source', 'data_file',
) )
def __init__(self, *args, **kwargs): def __init__(self, *args, initial=None, **kwargs):
super().__init__(*args, **kwargs)
# Convert data delivered via initial data to JSON data
if initial and 'data' in initial:
if type(initial['data']) is str:
initial['data'] = json.loads(initial['data'])
super().__init__(*args, initial=initial, **kwargs)
# Disable data field when a DataFile has been set # Disable data field when a DataFile has been set
if self.instance.data_file: if self.instance.data_file:

View File

@ -25,12 +25,16 @@ class ReportForm(BootstrapMixin, forms.Form):
help_text=_("Interval at which this report is re-run (in minutes)") help_text=_("Interval at which this report is re-run (in minutes)")
) )
def clean_schedule_at(self): def clean(self):
scheduled_time = self.cleaned_data['schedule_at'] scheduled_time = self.cleaned_data['schedule_at']
if scheduled_time and scheduled_time < timezone.now(): if scheduled_time and scheduled_time < local_now():
raise forms.ValidationError(_('Scheduled time must be in the future.')) raise forms.ValidationError(_('Scheduled time must be in the future.'))
return scheduled_time # When interval is used without schedule at, raise an exception
if self.cleaned_data['interval'] and not scheduled_time:
self.cleaned_data['schedule_at'] = local_now()
return self.cleaned_data
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -52,7 +52,7 @@ class ScriptForm(BootstrapMixin, forms.Form):
# When interval is used without schedule at, raise an exception # When interval is used without schedule at, raise an exception
if self.cleaned_data['_interval'] and not scheduled_time: if self.cleaned_data['_interval'] and not scheduled_time:
raise forms.ValidationError(_('Scheduled time must be set when recurs is used.')) self.cleaned_data['_schedule_at'] = local_now()
return self.cleaned_data return self.cleaned_data

View File

@ -2,7 +2,6 @@ from django.conf import settings
from django.core.validators import ValidationError from django.core.validators import ValidationError
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from jinja2.loaders import BaseLoader from jinja2.loaders import BaseLoader
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
@ -10,7 +9,7 @@ from jinja2.sandbox import SandboxedEnvironment
from extras.querysets import ConfigContextQuerySet from extras.querysets import ConfigContextQuerySet
from netbox.config import get_config from netbox.config import get_config
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin, SyncedDataMixin, TagsMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin
from utilities.jinja2 import ConfigTemplateLoader from utilities.jinja2 import ConfigTemplateLoader
from utilities.utils import deepmerge from utilities.utils import deepmerge
@ -25,7 +24,7 @@ __all__ = (
# Config contexts # Config contexts
# #
class ConfigContext(SyncedDataMixin, ChangeLoggedModel): class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel):
""" """
A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned A ConfigContext represents a set of arbitrary data available to any Device or VirtualMachine matching its assigned
qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B qualifiers (region, site, etc.). For example, the data stored in a ConfigContext assigned to site A and tenant B
@ -114,6 +113,12 @@ class ConfigContext(SyncedDataMixin, ChangeLoggedModel):
objects = ConfigContextQuerySet.as_manager() objects = ConfigContextQuerySet.as_manager()
clone_fields = (
'weight', 'is_active', 'regions', 'site_groups', 'sites', 'locations', 'device_types',
'roles', 'platforms', 'cluster_types', 'cluster_groups', 'clusters', 'tenant_groups',
'tenants', 'tags', 'data',
)
class Meta: class Meta:
ordering = ['weight', 'name'] ordering = ['weight', 'name']

View File

@ -250,7 +250,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
) )
clone_fields = ( clone_fields = (
'enabled', 'weight', 'group_name', 'button_class', 'new_window', 'content_types', 'enabled', 'weight', 'group_name', 'button_class', 'new_window',
) )
class Meta: class Meta:
@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
} }
class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
content_types = models.ManyToManyField( content_types = models.ManyToManyField(
to=ContentType, to=ContentType,
related_name='export_templates', related_name='export_templates',
@ -318,6 +318,10 @@ class ExportTemplate(SyncedDataMixin, ExportTemplatesMixin, ChangeLoggedModel):
help_text=_("Download file as attachment") help_text=_("Download file as attachment")
) )
clone_fields = (
'content_types', 'template_code', 'mime_type', 'file_extension', 'as_attachment',
)
class Meta: class Meta:
ordering = ('name',) ordering = ('name',)
@ -417,7 +421,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
parameters = models.JSONField() parameters = models.JSONField()
clone_fields = ( clone_fields = (
'enabled', 'weight', 'content_types', 'weight', 'enabled', 'parameters',
) )
class Meta: class Meta:

View File

@ -5,7 +5,7 @@ from django.utils.text import slugify
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from netbox.models import ChangeLoggedModel from netbox.models import ChangeLoggedModel
from netbox.models.features import ExportTemplatesMixin from netbox.models.features import CloningMixin, ExportTemplatesMixin
from utilities.choices import ColorChoices from utilities.choices import ColorChoices
from utilities.fields import ColorField from utilities.fields import ColorField
@ -19,7 +19,7 @@ __all__ = (
# Tags # Tags
# #
class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase): class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField( id = models.BigAutoField(
primary_key=True primary_key=True
) )
@ -31,6 +31,10 @@ class Tag(ExportTemplatesMixin, ChangeLoggedModel, TagBase):
blank=True, blank=True,
) )
clone_fields = (
'color', 'description',
)
class Meta: class Meta:
ordering = ['name'] ordering = ['name']

View File

@ -1,3 +1,4 @@
import json
from collections import defaultdict from collections import defaultdict
from functools import cached_property from functools import cached_property
@ -115,7 +116,11 @@ class CloningMixin(models.Model):
for field_name in getattr(self, 'clone_fields', []): for field_name in getattr(self, 'clone_fields', []):
field = self._meta.get_field(field_name) field = self._meta.get_field(field_name)
field_value = field.value_from_object(self) field_value = field.value_from_object(self)
if field_value not in (None, ''): if field_value and isinstance(field, models.ManyToManyField):
attrs[field_name] = [v.pk for v in field_value]
elif field_value and isinstance(field, models.JSONField):
attrs[field_name] = json.dumps(field_value)
elif field_value not in (None, ''):
attrs[field_name] = field_value attrs[field_name] = field_value
# Include tags (if applicable) # Include tags (if applicable)

View File

@ -394,8 +394,10 @@ TEMPLATES = [
] ]
# Set up authentication backends # Set up authentication backends
if type(REMOTE_AUTH_BACKEND) not in (list, tuple):
REMOTE_AUTH_BACKEND = [REMOTE_AUTH_BACKEND]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
REMOTE_AUTH_BACKEND, *REMOTE_AUTH_BACKEND,
'netbox.authentication.ObjectPermissionBackend', 'netbox.authentication.ObjectPermissionBackend',
] ]

View File

@ -141,7 +141,7 @@
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %} {% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
</td> </td>
<td> <td>
{{ vc_member.vc_priority|default:"" }} {{ vc_member.vc_priority|placeholder }}
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@ -8,6 +8,7 @@
</div> </div>
{% render_field form.name %} {% render_field form.name %}
{% render_field form.domain %} {% render_field form.domain %}
{% render_field form.description %}
{% render_field form.tags %} {% render_field form.tags %}
</div> </div>

View File

@ -3,7 +3,7 @@ boto3==1.26.91
Django==4.1.7 Django==4.1.7
django-cors-headers==3.14.0 django-cors-headers==3.14.0
django-debug-toolbar==3.8.1 django-debug-toolbar==3.8.1
django-filter==22.1 django-filter==23.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.14 django-mptt==0.14
django-pglocks==1.0.4 django-pglocks==1.0.4
@ -21,18 +21,18 @@ graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==20.1.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.1.2 mkdocs-material==9.1.4
mkdocstrings[python-legacy]==0.20.0 mkdocstrings[python-legacy]==0.20.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==9.4.0 Pillow==9.4.0
psycopg2-binary==2.9.5 psycopg2-binary==2.9.5
PyYAML==6.0 PyYAML==6.0
sentry-sdk==1.16.0 sentry-sdk==1.18.0
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core[openidconnect]==4.3.0 social-auth-core[openidconnect]==4.4.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.3.0 tablib==3.4.0
tzdata==2022.7 tzdata==2023.2
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0