mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 12:12:53 -06:00
commit
b493d739bd
@ -39,6 +39,12 @@ Each custom selection field must have at least two choices. These are specified
|
||||
|
||||
If a default value is specified for a selection field, it must exactly match one of the provided choices.
|
||||
|
||||
## Custom Fields in Templates
|
||||
|
||||
Several features within NetBox, such as export templates and webhooks, utilize Jinja2 templating. For convenience, objects which support custom field assignment expose custom field data through the `cf` property. This is a bit cleaner than accessing custom field data through the actual field (`custom_field_data`).
|
||||
|
||||
For example, a custom field named `foo123` on the Site model is accessible on an instance as `{{ site.cf.foo123 }}`.
|
||||
|
||||
## Custom Fields and the REST API
|
||||
|
||||
When retrieving an object via the REST API, all of its custom data will be included within the `custom_fields` attribute. For example, below is the partial output of a site with two custom fields defined:
|
||||
|
@ -18,6 +18,14 @@ Height: {{ rack.u_height }}U
|
||||
|
||||
To access custom fields of an object within a template, use the `cf` attribute. For example, `{{ obj.cf.color }}` will return the value (if any) for a custom field named `color` on `obj`.
|
||||
|
||||
If you need to use the config context data in an export template, you'll should use the function `get_config_context` to get all the config context data. For example:
|
||||
```
|
||||
{% for server in queryset %}
|
||||
{% set data = server.get_config_context() %}
|
||||
{{ data.syslog }}
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
A MIME type and file extension can optionally be defined for each export template. The default MIME type is `text/plain`.
|
||||
|
||||
## Example
|
||||
|
@ -2,6 +2,13 @@
|
||||
|
||||
NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to serve a proxy for operational data, fetching live data from network devices and returning it to a requester via its REST API. Note that NetBox does not store any NAPALM data locally.
|
||||
|
||||
The NetBox UI will display tabs for status, LLDP neighbors, and configuration under the device view if the following conditions are met:
|
||||
|
||||
* Device status is "Active"
|
||||
* A primary IP has been assigned to the device
|
||||
* A platform with a NAPALM driver has been assigned
|
||||
* The authenticated user has the `dcim.napalm_read_device` permission
|
||||
|
||||
!!! note
|
||||
To enable this integration, the NAPALM library must be installed. See [installation steps](../../installation/3-netbox/#napalm) for more information.
|
||||
|
||||
|
@ -10,7 +10,7 @@ NetBox v2.9 introduced a new object-based permissions framework, which replace's
|
||||
| ----------- | ----------- |
|
||||
| `{"status": "active"}` | Status is active |
|
||||
| `{"status__in": ["planned", "reserved"]}` | Status is active **OR** reserved |
|
||||
| `{"status": "active", "role": "testing"}` | Status is active **OR** role is testing |
|
||||
| `{"status": "active", "role": "testing"}` | Status is active **AND** role is testing |
|
||||
| `{"name__startswith": "Foo"}` | Name starts with "Foo" (case-sensitive) |
|
||||
| `{"name__iendswith": "bar"}` | Name ends with "bar" (case-insensitive) |
|
||||
| `{"vid__gte": 100, "vid__lt": 200}` | VLAN ID is greater than or equal to 100 **AND** less than 200 |
|
||||
|
@ -66,6 +66,7 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
|
||||
* `PASSWORD` - Redis password (if set)
|
||||
* `DATABASE` - Numeric database ID
|
||||
* `SSL` - Use SSL connection to Redis
|
||||
* `INSECURE_SKIP_TLS_VERIFY` - Set to `True` to **disable** TLS certificate verification (not recommended)
|
||||
|
||||
An example configuration is provided below:
|
||||
|
||||
|
@ -1,5 +1,28 @@
|
||||
# NetBox v2.10
|
||||
|
||||
## v2.10.9 (2021-04-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5526](https://github.com/netbox-community/netbox/issues/5526) - Add MAC address search field to VM interfaces list
|
||||
* [#5756](https://github.com/netbox-community/netbox/issues/5756) - Omit child devices from non-racked devices list under rack view
|
||||
* [#5840](https://github.com/netbox-community/netbox/issues/5840) - Add column to cable termination objects to display cable color
|
||||
* [#6054](https://github.com/netbox-community/netbox/issues/6054) - Display NAPALM-enabled device tabs only when relevant
|
||||
* [#6083](https://github.com/netbox-community/netbox/issues/6083) - Support disabling TLS certificate validation for Redis
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5805](https://github.com/netbox-community/netbox/issues/5805) - Fix missing custom field filters for cables, rack reservations
|
||||
* [#6070](https://github.com/netbox-community/netbox/issues/6070) - Add missing `count_ipaddresses` attribute to VMInterface serializer
|
||||
* [#6073](https://github.com/netbox-community/netbox/issues/6073) - Permit users to manage their own REST API tokens without needing explicit permission
|
||||
* [#6081](https://github.com/netbox-community/netbox/issues/6081) - Fix interface connections REST API endpoint
|
||||
* [#6082](https://github.com/netbox-community/netbox/issues/6082) - Support colons in webhook header values
|
||||
* [#6108](https://github.com/netbox-community/netbox/issues/6108) - Do not infer tenant assignment from parent objects for prefixes, IP addresses
|
||||
* [#6117](https://github.com/netbox-community/netbox/issues/6117) - Handle exception when attempting to assign an MPTT-enabled model as its own parent
|
||||
* [#6131](https://github.com/netbox-community/netbox/issues/6131) - Correct handling of boolean fields when cloning objects
|
||||
|
||||
---
|
||||
|
||||
## v2.10.8 (2021-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
@ -779,7 +779,7 @@ class CablePathSerializer(serializers.ModelSerializer):
|
||||
|
||||
class InterfaceConnectionSerializer(ValidatedModelSerializer):
|
||||
interface_a = serializers.SerializerMethodField()
|
||||
interface_b = NestedInterfaceSerializer(source='connected_endpoint')
|
||||
interface_b = NestedInterfaceSerializer(source='_path.destination')
|
||||
connected_endpoint_reachable = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
|
@ -2,6 +2,7 @@ import socket
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import F
|
||||
from django.http import HttpResponseForbidden, HttpResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
@ -580,6 +581,8 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.prefetch_related('device', '_path').filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_path__destination_type__app_label='dcim',
|
||||
_path__destination_type__model='interface',
|
||||
_path__destination_id__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
)
|
||||
|
@ -868,7 +868,7 @@ class RackReservationBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomField
|
||||
nullable_fields = []
|
||||
|
||||
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm):
|
||||
class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = RackReservation
|
||||
field_order = ['q', 'region', 'site', 'group_id', 'user_id', 'tenant_group', 'tenant']
|
||||
q = forms.CharField(
|
||||
@ -3966,7 +3966,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFo
|
||||
})
|
||||
|
||||
|
||||
class CableFilterForm(BootstrapMixin, forms.Form):
|
||||
class CableFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
model = Cable
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
|
@ -478,6 +478,10 @@ class BaseInterface(models.Model):
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def count_ipaddresses(self):
|
||||
return self.ip_addresses.count()
|
||||
|
||||
|
||||
@extras_features('export_templates', 'webhooks', 'custom_links')
|
||||
class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
@ -615,10 +619,6 @@ class Interface(CableTermination, PathEndpoint, ComponentModel, BaseInterface):
|
||||
def is_lag(self):
|
||||
return self.type == InterfaceTypeChoices.TYPE_LAG
|
||||
|
||||
@property
|
||||
def count_ipaddresses(self):
|
||||
return self.ip_addresses.count()
|
||||
|
||||
|
||||
#
|
||||
# Pass-through ports
|
||||
|
@ -111,6 +111,12 @@ class RackGroup(MPTTModel, ChangeLoggedModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An MPTT model cannot be its own parent
|
||||
if self.pk and self.parent_id == self.pk:
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
# Parent RackGroup (if any) must belong to the same Site
|
||||
if self.parent and self.parent.site != self.site:
|
||||
raise ValidationError(f"Parent rack group ({self.parent}) must belong to the same site ({self.site})")
|
||||
|
@ -7,6 +7,7 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from dcim.choices import *
|
||||
from dcim.constants import *
|
||||
from django.core.exceptions import ValidationError
|
||||
from dcim.fields import ASNField
|
||||
from extras.models import ChangeLoggedModel, CustomFieldModel, ObjectChange, TaggedItem
|
||||
from extras.utils import extras_features
|
||||
@ -87,6 +88,15 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An MPTT model cannot be its own parent
|
||||
if self.pk and self.parent_id == self.pk:
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
|
@ -230,6 +230,11 @@ class CableTerminationTable(BaseTable):
|
||||
cable = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
cable_color = ColorColumn(
|
||||
accessor='cable.color',
|
||||
orderable=False,
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
cable_peer = tables.TemplateColumn(
|
||||
accessor='_cable_peer',
|
||||
template_code=CABLETERMINATION,
|
||||
@ -255,7 +260,8 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags',
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
|
||||
'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
@ -274,7 +280,8 @@ class DeviceConsolePortTable(ConsolePortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsolePort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
|
||||
row_attrs = {
|
||||
@ -289,7 +296,10 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection',
|
||||
'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@ -308,7 +318,8 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = ConsoleServerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_peer', 'connection', 'tags', 'actions'
|
||||
'pk', 'name', 'label', 'type', 'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
'actions'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'type', 'description', 'cable', 'connection', 'actions')
|
||||
row_attrs = {
|
||||
@ -325,7 +336,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'maximum_draw', 'allocated_draw', 'cable',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
'cable_color', 'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||
|
||||
@ -345,8 +356,8 @@ class DevicePowerPortTable(PowerPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_peer',
|
||||
'connection', 'tags', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
||||
@ -368,8 +379,8 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_peer',
|
||||
'connection', 'tags',
|
||||
'pk', 'device', 'name', 'label', 'type', 'description', 'power_port', 'feed_leg', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||
|
||||
@ -388,8 +399,8 @@ class DevicePowerOutletTable(PowerOutletTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = PowerOutlet
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_peer', 'connection',
|
||||
'tags', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'cable_color',
|
||||
'cable_peer', 'connection', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
|
||||
@ -424,7 +435,8 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address',
|
||||
'description', 'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
'description', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
|
||||
'tagged_vlans',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
@ -450,7 +462,8 @@ class DeviceInterfaceTable(InterfaceTable):
|
||||
model = Interface
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'description',
|
||||
'cable', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans', 'actions',
|
||||
'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan', 'tagged_vlans',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'enabled', 'type', 'lag', 'mtu', 'mode', 'description', 'ip_addresses', 'cable',
|
||||
@ -477,7 +490,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable',
|
||||
'cable_peer', 'tags',
|
||||
'cable_color', 'cable_peer', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description')
|
||||
|
||||
@ -497,8 +510,8 @@ class DeviceFrontPortTable(FrontPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = FrontPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
|
||||
'tags', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_color',
|
||||
'cable_peer', 'tags', 'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
|
||||
@ -516,7 +529,10 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = ('pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags')
|
||||
fields = (
|
||||
'pk', 'device', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer',
|
||||
'tags',
|
||||
)
|
||||
default_columns = ('pk', 'device', 'name', 'label', 'type', 'description')
|
||||
|
||||
|
||||
@ -535,7 +551,8 @@ class DeviceRearPortTable(RearPortTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = RearPort
|
||||
fields = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'tags', 'actions',
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_color', 'cable_peer', 'tags',
|
||||
'actions',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
|
||||
|
@ -4,7 +4,6 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import PowerFeed, PowerPanel
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, LinkedCountColumn, TagColumn, ToggleColumn
|
||||
from .devices import CableTerminationTable
|
||||
from .template_code import POWERFEED_CABLE, POWERFEED_CABLETERMINATION
|
||||
|
||||
__all__ = (
|
||||
'PowerFeedTable',
|
||||
@ -69,7 +68,7 @@ class PowerFeedTable(CableTerminationTable):
|
||||
model = PowerFeed
|
||||
fields = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
|
||||
'max_utilization', 'cable', 'cable_peer', 'connection', 'available_power', 'tags',
|
||||
'max_utilization', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power', 'tags',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
|
||||
|
@ -342,10 +342,11 @@ class RackView(generic.ObjectView):
|
||||
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'group', 'role')
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Get 0U and child devices located within the rack
|
||||
# Get 0U devices located within the rack
|
||||
nonracked_devices = Device.objects.filter(
|
||||
rack=instance,
|
||||
position__isnull=True
|
||||
position__isnull=True,
|
||||
parent_bay__isnull=True
|
||||
).prefetch_related('device_type__manufacturer')
|
||||
|
||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||
|
@ -140,7 +140,7 @@ class Webhook(models.Model):
|
||||
ret = {}
|
||||
data = render_jinja2(self.additional_headers, context)
|
||||
for line in data.splitlines():
|
||||
header, value = line.split(':')
|
||||
header, value = line.split(':', 1)
|
||||
ret[header.strip()] = value.strip()
|
||||
return ret
|
||||
|
||||
|
@ -109,18 +109,6 @@ VLAN_MEMBER_TAGGED = """
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
TENANT_LINK = """
|
||||
{% if record.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.tenant.slug %}" title="{{ record.tenant.description }}">{{ record.tenant }}</a>
|
||||
{% elif record.vrf.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=record.vrf.tenant.slug %}" title="{{ record.vrf.tenant.description }}">{{ record.vrf.tenant }}</a>*
|
||||
{% elif object.tenant %}
|
||||
<a href="{% url 'tenancy:tenant' slug=object.tenant.slug %}" title="{{ object.tenant.description }}">{{ object.tenant }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
||||
#
|
||||
# VRFs
|
||||
@ -210,8 +198,8 @@ class AggregateTable(BaseTable):
|
||||
prefix = tables.LinkColumn(
|
||||
verbose_name='Aggregate'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
tenant = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
date_added = tables.DateColumn(
|
||||
format="Y-m-d",
|
||||
@ -281,8 +269,8 @@ class PrefixTable(BaseTable):
|
||||
template_code=VRF_LINK,
|
||||
verbose_name='VRF'
|
||||
)
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
tenant = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
site = tables.Column(
|
||||
linkify=True
|
||||
@ -349,8 +337,8 @@ class IPAddressTable(BaseTable):
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
tenant = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
assigned_object = tables.Column(
|
||||
linkify=True,
|
||||
@ -430,8 +418,8 @@ class InterfaceIPAddressTable(BaseTable):
|
||||
verbose_name='VRF'
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=TENANT_LINK
|
||||
tenant = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
actions = ButtonsColumn(
|
||||
model=IPAddress
|
||||
|
@ -34,6 +34,9 @@ REDIS = {
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'SSL': False,
|
||||
# Set this to True to skip TLS certificate verification
|
||||
# This can expose the connection to attacks, be careful
|
||||
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
||||
},
|
||||
'caching': {
|
||||
'HOST': 'localhost',
|
||||
@ -44,6 +47,9 @@ REDIS = {
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'SSL': False,
|
||||
# Set this to True to skip TLS certificate verification
|
||||
# This can expose the connection to attacks, be careful
|
||||
# 'INSECURE_SKIP_TLS_VERIFY': False,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.10.8'
|
||||
VERSION = '2.10.9'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -215,6 +215,7 @@ TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
|
||||
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
|
||||
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
||||
TASKS_REDIS_SKIP_TLS_VERIFY = TASKS_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
|
||||
|
||||
# Caching
|
||||
if 'caching' not in REDIS:
|
||||
@ -233,6 +234,7 @@ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default'
|
||||
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
|
||||
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
|
||||
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
|
||||
CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', False)
|
||||
|
||||
|
||||
#
|
||||
@ -398,21 +400,14 @@ if CACHING_REDIS_USING_SENTINEL:
|
||||
'password': CACHING_REDIS_PASSWORD,
|
||||
}
|
||||
else:
|
||||
if CACHING_REDIS_SSL:
|
||||
REDIS_CACHE_CON_STRING = 'rediss://'
|
||||
else:
|
||||
REDIS_CACHE_CON_STRING = 'redis://'
|
||||
|
||||
if CACHING_REDIS_PASSWORD:
|
||||
REDIS_CACHE_CON_STRING = '{}:{}@'.format(REDIS_CACHE_CON_STRING, CACHING_REDIS_PASSWORD)
|
||||
|
||||
REDIS_CACHE_CON_STRING = '{}{}:{}/{}'.format(
|
||||
REDIS_CACHE_CON_STRING,
|
||||
CACHING_REDIS_HOST,
|
||||
CACHING_REDIS_PORT,
|
||||
CACHING_REDIS_DATABASE
|
||||
)
|
||||
CACHEOPS_REDIS = REDIS_CACHE_CON_STRING
|
||||
CACHEOPS_REDIS = {
|
||||
'host': CACHING_REDIS_HOST,
|
||||
'port': CACHING_REDIS_PORT,
|
||||
'db': CACHING_REDIS_DATABASE,
|
||||
'password': CACHING_REDIS_PASSWORD,
|
||||
'ssl': CACHING_REDIS_SSL,
|
||||
'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||
}
|
||||
|
||||
if not CACHE_TIMEOUT:
|
||||
CACHEOPS_ENABLED = False
|
||||
@ -560,6 +555,7 @@ else:
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
}
|
||||
|
||||
|
@ -153,16 +153,17 @@
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% if perms.dcim.napalm_read_device %}
|
||||
{% if object.status != 'active' %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='Device must be in active status' %}
|
||||
{% elif not object.platform %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No platform assigned to this device' %}
|
||||
{% elif not object.platform.napalm_driver %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' with disabled_message='No NAPALM driver assigned for this platform' %}
|
||||
{% else %}
|
||||
{% include 'dcim/inc/device_napalm_tabs.html' %}
|
||||
{% endif %}
|
||||
{% if perms.dcim.napalm_read_device and object.status == 'active' and object.primary_ip and object.platform.napalm_driver %}
|
||||
{# NAPALM-enabled tabs #}
|
||||
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if perms.extras.view_configcontext %}
|
||||
<li role="presentation"{% if active_tab == 'config-context' %} class="active"{% endif %}>
|
||||
|
@ -1,15 +0,0 @@
|
||||
{% if not disabled_message %}
|
||||
<li role="presentation"{% if active_tab == 'status' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_status' pk=object.pk %}">Status</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'lldp-neighbors' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_lldp_neighbors' pk=object.pk %}">LLDP Neighbors</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'config' %} class="active"{% endif %}>
|
||||
<a href="{% url 'dcim:device_config' pk=object.pk %}">Configuration</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Status</a></li>
|
||||
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">LLDP Neighbors</a></li>
|
||||
<li role="presentation" class="disabled"><a href="#" title="{{ disabled_message }}">Configuration</a></li>
|
||||
{% endif %}
|
@ -1,6 +1,7 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
@ -28,54 +29,56 @@
|
||||
{% block sidebar %}{% endblock %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="table-responsive">
|
||||
{% with bulk_edit_url=content_type.model_class|validated_viewname:"bulk_edit" bulk_delete_url=content_type.model_class|validated_viewname:"bulk_delete" %}
|
||||
{% if permissions.change or permissions.delete %}
|
||||
<form method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
{% if table.paginator.num_pages > 1 %}
|
||||
<div id="select_all_box" class="hidden panel panel-default noprint">
|
||||
<div class="panel-body">
|
||||
<div class="checkbox-inline">
|
||||
<label for="select_all">
|
||||
<input type="checkbox" id="select_all" name="_all" />
|
||||
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
|
||||
</label>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
|
||||
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if permissions.change or permissions.delete %}
|
||||
<form method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
|
||||
{% if table.paginator.num_pages > 1 %}
|
||||
<div id="select_all_box" class="hidden panel panel-default noprint">
|
||||
<div class="panel-body">
|
||||
<div class="checkbox-inline">
|
||||
<label for="select_all">
|
||||
<input type="checkbox" id="select_all" name="_all" />
|
||||
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
|
||||
</label>
|
||||
</div>
|
||||
<div class="pull-right">
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
|
||||
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% render_table table 'inc/table.html' %}
|
||||
<div class="pull-left noprint">
|
||||
{% block bulk_buttons %}{% endblock %}
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include table_template|default:'responsive_table.html' %}
|
||||
<div class="pull-left noprint">
|
||||
{% block bulk_buttons %}{% endblock %}
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% include table_template|default:'responsive_table.html' %}
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
|
@ -11,12 +11,8 @@
|
||||
<div class="panel-heading">
|
||||
<div class="pull-right noprint">
|
||||
<a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
|
||||
{% if perms.users.change_token %}
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
{% endif %}
|
||||
{% if perms.users.delete_token %}
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
{% endif %}
|
||||
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
|
||||
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
|
||||
</div>
|
||||
<i class="mdi mdi-key"></i>
|
||||
<samp><span id="token_{{ token.pk }}">{{ token.key }}</span></samp>
|
||||
@ -55,16 +51,10 @@
|
||||
{% empty %}
|
||||
<p>You do not have any API tokens.</p>
|
||||
{% endfor %}
|
||||
{% if perms.users.add_token %}
|
||||
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Add a token
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="alert alert-info text-center" role="alert">
|
||||
You do not have permission to create new API tokens. If needed, ask an administrator to enable token creation for your account or an assigned group.
|
||||
</div>
|
||||
{% endif %}
|
||||
<a href="{% url 'user:token_add' %}" class="btn btn-primary">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Add a token
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
from mptt.models import MPTTModel, TreeForeignKey
|
||||
@ -74,6 +75,15 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
|
||||
object_data=serialize_object(self, exclude=['level', 'lft', 'rght', 'tree_id'])
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# An MPTT model cannot be its own parent
|
||||
if self.pk and self.parent_id == self.pk:
|
||||
raise ValidationError({
|
||||
"parent": "Cannot assign self as parent."
|
||||
})
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Tenant(ChangeLoggedModel, CustomFieldModel):
|
||||
|
@ -6,7 +6,7 @@ from django.contrib.auth import login as auth_login, logout as auth_logout, upda
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.auth.models import update_last_login
|
||||
from django.contrib.auth.signals import user_logged_in
|
||||
from django.http import HttpResponseForbidden, HttpResponseRedirect
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.decorators import method_decorator
|
||||
@ -282,13 +282,9 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
|
||||
def get(self, request, pk=None):
|
||||
|
||||
if pk is not None:
|
||||
if not request.user.has_perm('users.change_token'):
|
||||
return HttpResponseForbidden()
|
||||
if pk:
|
||||
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
|
||||
else:
|
||||
if not request.user.has_perm('users.add_token'):
|
||||
return HttpResponseForbidden()
|
||||
token = Token(user=request.user)
|
||||
|
||||
form = TokenForm(instance=token)
|
||||
@ -302,11 +298,11 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
|
||||
def post(self, request, pk=None):
|
||||
|
||||
if pk is not None:
|
||||
if pk:
|
||||
token = get_object_or_404(Token.objects.filter(user=request.user), pk=pk)
|
||||
form = TokenForm(request.POST, instance=token)
|
||||
else:
|
||||
token = Token()
|
||||
token = Token(user=request.user)
|
||||
form = TokenForm(request.POST)
|
||||
|
||||
if form.is_valid():
|
||||
@ -314,7 +310,7 @@ class TokenEditView(LoginRequiredMixin, View):
|
||||
token.user = request.user
|
||||
token.save()
|
||||
|
||||
msg = "Modified token {}".format(token) if pk else "Created token {}".format(token)
|
||||
msg = f"Modified token {token}" if pk else f"Created token {token}"
|
||||
messages.success(request, msg)
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
|
@ -224,12 +224,12 @@ def prepare_cloned_fields(instance):
|
||||
field = instance._meta.get_field(field_name)
|
||||
field_value = field.value_from_object(instance)
|
||||
|
||||
# Swap out False with URL-friendly value
|
||||
# Pass False as null for boolean fields
|
||||
if field_value is False:
|
||||
field_value = ''
|
||||
params.append((field_name, ''))
|
||||
|
||||
# Omit empty values
|
||||
if field_value not in (None, ''):
|
||||
elif field_value not in (None, ''):
|
||||
params.append((field_name, field_value))
|
||||
|
||||
# Copy tags
|
||||
|
@ -109,12 +109,13 @@ class VMInterfaceSerializer(TaggedObjectSerializer, ValidatedModelSerializer):
|
||||
required=False,
|
||||
many=True
|
||||
)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
fields = [
|
||||
'id', 'url', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags',
|
||||
'untagged_vlan', 'tagged_vlans', 'tags', 'count_ipaddresses',
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
|
@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
|
||||
|
||||
class VMInterfaceViewSet(ModelViewSet):
|
||||
queryset = VMInterface.objects.prefetch_related(
|
||||
'virtual_machine', 'tags', 'tagged_vlans'
|
||||
'virtual_machine', 'tags', 'tagged_vlans', 'ip_addresses',
|
||||
)
|
||||
serializer_class = serializers.VMInterfaceSerializer
|
||||
filterset_class = filters.VMInterfaceFilterSet
|
||||
|
@ -786,6 +786,10 @@ class VMInterfaceFilterForm(forms.Form):
|
||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||
)
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC address'
|
||||
)
|
||||
tag = TagFilterField(model)
|
||||
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
Django==3.1.7
|
||||
Django==3.1.8
|
||||
django-cacheops==5.1
|
||||
django-cors-headers==3.7.0
|
||||
django-debug-toolbar==3.2
|
||||
@ -6,17 +6,17 @@ django-filter==2.4.0
|
||||
django-mptt==0.12.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.1.0
|
||||
django-rq==2.4.0
|
||||
django-rq==2.4.1
|
||||
django-tables2==2.3.4
|
||||
django-taggit==1.3.0
|
||||
django-timezone-field==4.1.2
|
||||
djangorestframework==3.12.4
|
||||
drf-yasg[validation]==1.20.0
|
||||
gunicorn==20.0.4
|
||||
gunicorn==20.1.0
|
||||
Jinja2==2.11.3
|
||||
Markdown==3.3.4
|
||||
netaddr==0.8.0
|
||||
Pillow==8.1.2
|
||||
Pillow==8.2.0
|
||||
psycopg2-binary==2.8.6
|
||||
pycryptodome==3.10.1
|
||||
PyYAML==5.4.1
|
||||
|
Loading…
Reference in New Issue
Block a user