Merge branch 'develop' of https://github.com/digitalocean/netbox into 2902-systemd

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
dansheps 2019-04-29 11:10:03 -05:00
commit ceda530472
37 changed files with 311 additions and 120 deletions

View File

@ -1,18 +1,62 @@
v2.5.9 (FUTURE) 2.5.11 (FUTURE)
## Enhancements ## Enhancements
* [#2902](https://github.com/digitalocean/netbox/issues/2902) - Replace supervisord with systemd * [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components
* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+) * [#3023](https://github.com/digitalocean/netbox/issues/3023) - Add support for filtering cables by connected device
* [#3070](https://github.com/digitalocean/netbox/issues/3070) - Add decommissioning status for devices
## Bug Fixes ## Bug Fixes
* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes Deterministic Ordering of Interfaces * [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware
* [#3112](https://github.com/digitalocean/netbox/issues/3112) - Fix ordering of interface connections list by termination B name/device
* [#3116](https://github.com/digitalocean/netbox/issues/3116) - Fix `tagged_items` count in tags API endpoint
* [#3118](https://github.com/digitalocean/netbox/issues/3118) - Disable `last_login` update on login when maintenance mode is enabled
---
v2.5.10 (2019-04-08)
## Enhancements
* [#3052](https://github.com/digitalocean/netbox/issues/3052) - Add Jinja2 support for export templates
## Bug Fixes
* [#2937](https://github.com/digitalocean/netbox/issues/2937) - Redirect to list view after editing an object from list view
* [#3036](https://github.com/digitalocean/netbox/issues/3036) - DCIM interfaces API endpoint should not include VM interfaces
* [#3039](https://github.com/digitalocean/netbox/issues/3039) - Fix exception when retrieving change object for a component template via API
* [#3041](https://github.com/digitalocean/netbox/issues/3041) - Fix form widget for bulk cable label update
* [#3044](https://github.com/digitalocean/netbox/issues/3044) - Ignore site/rack fields when connecting a new cable via device search
* [#3046](https://github.com/digitalocean/netbox/issues/3046) - Fix exception at reports API endpoint
* [#3047](https://github.com/digitalocean/netbox/issues/3047) - Fix exception when writing mac address for an interface via API
---
v2.5.9 (2019-04-01)
## Enhancements
* [#2933](https://github.com/digitalocean/netbox/issues/2933) - Add username to outbound webhook requests
* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+)
* [#3025](https://github.com/digitalocean/netbox/issues/3025) - Add request ID to outbound webhook requests (for correlating all changes part of a single request)
## Bug Fixes
* [#2207](https://github.com/digitalocean/netbox/issues/2207) - Fixes deterministic ordering of interfaces
* [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering * [#2577](https://github.com/digitalocean/netbox/issues/2577) - Clarification of wording in API regarding filtering
* [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE * [#2924](https://github.com/digitalocean/netbox/issues/2924) - Add interface type for QSFP28 50GE
* [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry * [#2936](https://github.com/digitalocean/netbox/issues/2936) - Fix device role selection showing duplicate first entry
* [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable * [#2998](https://github.com/digitalocean/netbox/issues/2998) - Limit device query to non-racked devices if no rack selected when creating a cable
* [#3001](https://github.com/digitalocean/netbox/issues/3001) - Fix API representation of ObjectChange `action` and add `changed_object_type`
* [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering * [#3014](https://github.com/digitalocean/netbox/issues/3014) - Fixes VM Role filtering
* [#3019](https://github.com/digitalocean/netbox/issues/3019) - Fix tag population when running NetBox within a path
* [#3022](https://github.com/digitalocean/netbox/issues/3022) - Add missing cable termination types to DCIM `_choices` endpoint
* [#3026](https://github.com/digitalocean/netbox/issues/3026) - Tweak prefix/IP filter forms to filter using VRF ID rather than route distinguisher
* [#3027](https://github.com/digitalocean/netbox/issues/3027) - Ignore empty local context data when rendering config contexts
* [#3032](https://github.com/digitalocean/netbox/issues/3032) - Save assigned tags when creating a new secret
---
v2.5.8 (2019-03-11) v2.5.8 (2019-03-11)

View File

@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.circuit.change_circuittype %} {% if perms.circuit.change_circuittype %}
<a href="{% url 'circuits:circuittype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'circuits:circuittype_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """

View File

@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from rest_framework import serializers from rest_framework import serializers
from rest_framework.validators import UniqueTogetherValidator from rest_framework.validators import UniqueTogetherValidator
from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField
@ -502,8 +503,12 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer):
# #
class CableSerializer(ValidatedModelSerializer): class CableSerializer(ValidatedModelSerializer):
termination_a_type = ContentTypeField() termination_a_type = ContentTypeField(
termination_b_type = ContentTypeField() queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_b_type = ContentTypeField(
queryset=ContentType.objects.filter(model__in=CABLE_TERMINATION_TYPES)
)
termination_a = serializers.SerializerMethodField(read_only=True) termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True) termination_b = serializers.SerializerMethodField(read_only=True)
status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False)

View File

@ -1,7 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from django.conf import settings from django.conf import settings
from django.db.models import F, Q from django.db.models import F
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from drf_yasg import openapi from drf_yasg import openapi
@ -35,7 +35,7 @@ from .exceptions import MissingFilterException
class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = ( fields = (
(Cable, ['length_unit', 'status', 'type']), (Cable, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']),
(ConsolePort, ['connection_status']), (ConsolePort, ['connection_status']),
(Device, ['face', 'status']), (Device, ['face', 'status']),
(DeviceType, ['subdevice_role']), (DeviceType, ['subdevice_role']),
@ -419,7 +419,9 @@ class PowerOutletViewSet(CableTraceMixin, ModelViewSet):
class InterfaceViewSet(CableTraceMixin, ModelViewSet): class InterfaceViewSet(CableTraceMixin, ModelViewSet):
queryset = Interface.objects.select_related( queryset = Interface.objects.filter(
device__isnull=False
).select_related(
'device', '_connected_interface', '_connected_circuittermination', 'cable' 'device', '_connected_interface', '_connected_circuittermination', 'cable'
).prefetch_related( ).prefetch_related(
'ip_addresses', 'tags' 'ip_addresses', 'tags'

View File

@ -318,6 +318,7 @@ DEVICE_STATUS_PLANNED = 2
DEVICE_STATUS_STAGED = 3 DEVICE_STATUS_STAGED = 3
DEVICE_STATUS_FAILED = 4 DEVICE_STATUS_FAILED = 4
DEVICE_STATUS_INVENTORY = 5 DEVICE_STATUS_INVENTORY = 5
DEVICE_STATUS_DECOMMISSIONING = 6
DEVICE_STATUS_CHOICES = [ DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_ACTIVE, 'Active'], [DEVICE_STATUS_ACTIVE, 'Active'],
[DEVICE_STATUS_OFFLINE, 'Offline'], [DEVICE_STATUS_OFFLINE, 'Offline'],
@ -325,6 +326,7 @@ DEVICE_STATUS_CHOICES = [
[DEVICE_STATUS_STAGED, 'Staged'], [DEVICE_STATUS_STAGED, 'Staged'],
[DEVICE_STATUS_FAILED, 'Failed'], [DEVICE_STATUS_FAILED, 'Failed'],
[DEVICE_STATUS_INVENTORY, 'Inventory'], [DEVICE_STATUS_INVENTORY, 'Inventory'],
[DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'],
] ]
# Site statuses # Site statuses
@ -345,6 +347,7 @@ STATUS_CLASSES = {
3: 'primary', 3: 'primary',
4: 'danger', 4: 'danger',
5: 'default', 5: 'default',
6: 'warning',
} }
# Console/power/interface connection statuses # Console/power/interface connection statuses

View File

@ -31,7 +31,7 @@ class MACAddressField(models.Field):
try: try:
return EUI(value, version=48, dialect=mac_unix_expanded_uppercase) return EUI(value, version=48, dialect=mac_unix_expanded_uppercase)
except AddrFormatError as e: except AddrFormatError as e:
raise ValidationError(e) raise ValidationError("Invalid MAC address format: {}".format(value))
def db_type(self, connection): def db_type(self, connection):
return 'macaddr' return 'macaddr'

View File

@ -1,5 +1,7 @@
import django_filters import django_filters
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Q from django.db.models import Q
from netaddr import EUI from netaddr import EUI
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
@ -967,6 +969,14 @@ class CableFilter(django_filters.FilterSet):
color = django_filters.MultipleChoiceFilter( color = django_filters.MultipleChoiceFilter(
choices=COLOR_CHOICES choices=COLOR_CHOICES
) )
device = django_filters.CharFilter(
method='filter_connected_device',
field_name='name'
)
device_id = django_filters.CharFilter(
method='filter_connected_device',
field_name='pk'
)
class Meta: class Meta:
model = Cable model = Cable
@ -977,6 +987,16 @@ class CableFilter(django_filters.FilterSet):
return queryset return queryset
return queryset.filter(label__icontains=value) return queryset.filter(label__icontains=value)
def filter_connected_device(self, queryset, name, value):
if not value.strip():
return queryset
try:
device = Device.objects.get(**{name: value})
except ObjectDoesNotExist:
return queryset.none()
cable_pks = device.get_cables(pk_list=True)
return queryset.filter(pk__in=cable_pks)
class ConsoleConnectionFilter(django_filters.FilterSet): class ConsoleConnectionFilter(django_filters.FilterSet):
site = django_filters.CharFilter( site = django_filters.CharFilter(

View File

@ -2706,12 +2706,12 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm):
status = forms.ChoiceField( status = forms.ChoiceField(
choices=add_blank_choice(CONNECTION_STATUS_CHOICES), choices=add_blank_choice(CONNECTION_STATUS_CHOICES),
required=False, required=False,
widget=StaticSelect2(),
initial='' initial=''
) )
label = forms.CharField( label = forms.CharField(
max_length=100, max_length=100,
required=False, required=False
widget=StaticSelect2()
) )
color = forms.CharField( color = forms.CharField(
max_length=6, max_length=6,
@ -2766,6 +2766,10 @@ class CableFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=ColorSelect() widget=ColorSelect()
) )
device = forms.CharField(
required=False,
label='Device name'
)
# #

View File

@ -14,22 +14,6 @@ CHANNEL_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*:(\d{{1,9}})(\.\d{{1,9}})?$')
VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)" VC_RE = r"COALESCE(CAST(SUBSTRING({} FROM '^.*\.(\d{{1,9}})$') AS integer), 0)"
class DeviceComponentManager(Manager):
def get_queryset(self):
queryset = super().get_queryset()
table_name = self.model._meta.db_table
sql = r"CONCAT(REGEXP_REPLACE({}.name, '\d+$', ''), LPAD(SUBSTRING({}.name FROM '\d+$'), 8, '0'))"
# Pad any trailing digits to effect natural sorting
return queryset.extra(
select={
'name_padded': sql.format(table_name, table_name),
}
).order_by('name_padded', 'pk')
class InterfaceQuerySet(QuerySet): class InterfaceQuerySet(QuerySet):
def connectable(self): def connectable(self):

View File

@ -23,7 +23,7 @@ from utilities.utils import serialize_object, to_meters
from .constants import * from .constants import *
from .exceptions import LoopDetected from .exceptions import LoopDetected
from .fields import ASNField, MACAddressField from .fields import ASNField, MACAddressField
from .managers import DeviceComponentManager, InterfaceManager from .managers import InterfaceManager
class ComponentTemplateModel(models.Model): class ComponentTemplateModel(models.Model):
@ -1004,7 +1004,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
max_length=50 max_length=50
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1027,7 +1027,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
max_length=50 max_length=50
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1050,7 +1050,7 @@ class PowerPortTemplate(ComponentTemplateModel):
max_length=50 max_length=50
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1073,7 +1073,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
max_length=50 max_length=50
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1139,7 +1139,7 @@ class FrontPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[MinValueValidator(1), MaxValueValidator(64)]
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1188,7 +1188,7 @@ class RearPortTemplate(ComponentTemplateModel):
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[MinValueValidator(1), MaxValueValidator(64)]
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1211,7 +1211,7 @@ class DeviceBayTemplate(ComponentTemplateModel):
max_length=50 max_length=50
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
class Meta: class Meta:
ordering = ['device_type', 'name'] ordering = ['device_type', 'name']
@ -1704,6 +1704,21 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False)
return Interface.objects.filter(filter) return Interface.objects.filter(filter)
def get_cables(self, pk_list=False):
"""
Return a QuerySet or PK list matching all Cables connected to a component of this Device.
"""
cable_pks = []
for component_model in [
ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, FrontPort, RearPort
]:
cable_pks += component_model.objects.filter(
device=self, cable__isnull=False
).values_list('cable', flat=True)
if pk_list:
return cable_pks
return Cable.objects.filter(pk__in=cable_pks)
def get_children(self): def get_children(self):
""" """
Return the set of child Devices installed in DeviceBays within this Device. Return the set of child Devices installed in DeviceBays within this Device.
@ -1742,7 +1757,7 @@ class ConsolePort(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name'] csv_headers = ['device', 'name']
@ -1785,7 +1800,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name'] csv_headers = ['device', 'name']
@ -1834,7 +1849,7 @@ class PowerPort(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name'] csv_headers = ['device', 'name']
@ -1877,7 +1892,7 @@ class PowerOutlet(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name'] csv_headers = ['device', 'name']
@ -2198,7 +2213,7 @@ class FrontPort(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description']
@ -2264,7 +2279,7 @@ class RearPort(CableTermination, ComponentModel):
blank=True blank=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name', 'type', 'positions', 'description'] csv_headers = ['device', 'name', 'type', 'positions', 'description']
@ -2311,7 +2326,7 @@ class DeviceBay(ComponentModel):
null=True null=True
) )
objects = DeviceComponentManager() objects = NaturalOrderingManager()
tags = TaggableManager() tags = TaggableManager()
csv_headers = ['device', 'name', 'installed_device'] csv_headers = ['device', 'name', 'installed_device']

View File

@ -44,7 +44,7 @@ REGION_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_region %} {% if perms.dcim.change_region %}
<a href="{% url 'dcim:region_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:region_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -56,7 +56,7 @@ RACKGROUP_ACTIONS = """
<i class="fa fa-eye"></i> <i class="fa fa-eye"></i>
</a> </a>
{% if perms.dcim.change_rackgroup %} {% if perms.dcim.change_rackgroup %}
<a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning" title="Edit"> <a href="{% url 'dcim:rackgroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning" title="Edit">
<i class="glyphicon glyphicon-pencil"></i> <i class="glyphicon glyphicon-pencil"></i>
</a> </a>
{% endif %} {% endif %}
@ -67,7 +67,7 @@ RACKROLE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_rackrole %} {% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:rackrole_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -88,7 +88,7 @@ RACKRESERVATION_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_rackreservation %} {% if perms.dcim.change_rackreservation %}
<a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:rackreservation_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -97,7 +97,7 @@ MANUFACTURER_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_manufacturer %} {% if perms.dcim.change_manufacturer %}
<a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:manufacturer_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -106,7 +106,7 @@ DEVICEROLE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_devicerole %} {% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:devicerole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -131,7 +131,7 @@ PLATFORM_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_platform %} {% if perms.dcim.change_platform %}
<a href="{% url 'dcim:platform_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:platform_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -168,7 +168,7 @@ VIRTUALCHASSIS_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.dcim.change_virtualchassis %} {% if perms.dcim.change_virtualchassis %}
<a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'dcim:virtualchassis_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -733,18 +733,18 @@ class InterfaceConnectionTable(BaseTable):
) )
device_b = tables.LinkColumn( device_b = tables.LinkColumn(
viewname='dcim:device', viewname='dcim:device',
accessor=Accessor('connected_endpoint.device'), accessor=Accessor('_connected_interface.device'),
args=[Accessor('connected_endpoint.device.pk')], args=[Accessor('_connected_interface.device.pk')],
verbose_name='Device B' verbose_name='Device B'
) )
interface_b = tables.LinkColumn( interface_b = tables.LinkColumn(
viewname='dcim:interface', viewname='dcim:interface',
accessor=Accessor('connected_endpoint.name'), accessor=Accessor('_connected_interface'),
args=[Accessor('connected_endpoint.pk')], args=[Accessor('_connected_interface.pk')],
verbose_name='Interface B' verbose_name='Interface B'
) )
description_b = tables.Column( description_b = tables.Column(
accessor=Accessor('connected_endpoint.description'), accessor=Accessor('_connected_interface.description'),
verbose_name='Description' verbose_name='Description'
) )

View File

@ -1,3 +1,4 @@
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from taggit.models import Tag from taggit.models import Tag
@ -15,7 +16,8 @@ from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantG
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.api.nested_serializers import NestedUserSerializer from users.api.nested_serializers import NestedUserSerializer
from utilities.api import ( from utilities.api import (
ChoiceField, ContentTypeField, get_serializer_for_model, SerializedPKRelatedField, ValidatedModelSerializer, ChoiceField, ContentTypeField, get_serializer_for_model, SerializerNotFound, SerializedPKRelatedField,
ValidatedModelSerializer,
) )
from .nested_serializers import * from .nested_serializers import *
@ -53,10 +55,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
# #
class ExportTemplateSerializer(ValidatedModelSerializer): class ExportTemplateSerializer(ValidatedModelSerializer):
template_language = ChoiceField(
choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2
)
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['id', 'content_type', 'name', 'description', 'template_code', 'mime_type', 'file_extension'] fields = [
'id', 'content_type', 'name', 'description', 'template_language', 'template_code', 'mime_type',
'file_extension',
]
# #
@ -88,7 +97,9 @@ class TagSerializer(ValidatedModelSerializer):
# #
class ImageAttachmentSerializer(ValidatedModelSerializer): class ImageAttachmentSerializer(ValidatedModelSerializer):
content_type = ContentTypeField() content_type = ContentTypeField(
queryset=ContentType.objects.all()
)
parent = serializers.SerializerMethodField(read_only=True) parent = serializers.SerializerMethodField(read_only=True)
class Meta: class Meta:
@ -205,14 +216,25 @@ class ReportDetailSerializer(ReportSerializer):
# #
class ObjectChangeSerializer(serializers.ModelSerializer): class ObjectChangeSerializer(serializers.ModelSerializer):
user = NestedUserSerializer(read_only=True) user = NestedUserSerializer(
content_type = ContentTypeField(read_only=True) read_only=True
changed_object = serializers.SerializerMethodField(read_only=True) )
action = ChoiceField(
choices=OBJECTCHANGE_ACTION_CHOICES,
read_only=True
)
changed_object_type = ContentTypeField(
read_only=True
)
changed_object = serializers.SerializerMethodField(
read_only=True
)
class Meta: class Meta:
model = ObjectChange model = ObjectChange
fields = [ fields = [
'id', 'time', 'user', 'user_name', 'request_id', 'action', 'content_type', 'changed_object', 'object_data', 'id', 'time', 'user', 'user_name', 'request_id', 'action', 'changed_object_type', 'changed_object',
'object_data',
] ]
def get_changed_object(self, obj): def get_changed_object(self, obj):
@ -221,9 +243,14 @@ class ObjectChangeSerializer(serializers.ModelSerializer):
""" """
if obj.changed_object is None: if obj.changed_object is None:
return None return None
try:
serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') serializer = get_serializer_for_model(obj.changed_object, prefix='Nested')
if serializer is None: except SerializerNotFound:
return obj.object_repr return obj.object_repr
context = {'request': self.context['request']} context = {
'request': self.context['request']
}
data = serializer(obj.changed_object, context=context).data data = serializer(obj.changed_object, context=context).data
return data return data

View File

@ -10,7 +10,7 @@ from taggit.models import Tag
from extras import filters from extras import filters
from extras.models import ( from extras.models import (
ConfigContext, CustomField, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap, ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, TopologyMap,
) )
from extras.reports import get_report, get_reports from extras.reports import get_report, get_reports
from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet from utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet
@ -23,8 +23,9 @@ from . import serializers
class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): class ExtrasFieldChoicesViewSet(FieldChoicesViewSet):
fields = ( fields = (
(CustomField, ['type']), (ExportTemplate, ['template_language']),
(Graph, ['type']), (Graph, ['type']),
(ObjectChange, ['action']),
) )
@ -115,7 +116,9 @@ class TopologyMapViewSet(ModelViewSet):
# #
class TagViewSet(ModelViewSet): class TagViewSet(ModelViewSet):
queryset = Tag.objects.annotate(tagged_items=Count('taggit_taggeditem_items')) queryset = Tag.objects.annotate(
tagged_items=Count('taggit_taggeditem_items', distinct=True)
)
serializer_class = serializers.TagSerializer serializer_class = serializers.TagSerializer
filterset_class = filters.TagFilter filterset_class = filters.TagFilter

View File

@ -56,6 +56,14 @@ EXPORTTEMPLATE_MODELS = [
'cluster', 'virtualmachine', # Virtualization 'cluster', 'virtualmachine', # Virtualization
] ]
# ExportTemplate language choices
TEMPLATE_LANGUAGE_DJANGO = 10
TEMPLATE_LANGUAGE_JINJA2 = 20
TEMPLATE_LANGUAGE_CHOICES = (
(TEMPLATE_LANGUAGE_DJANGO, 'Django'),
(TEMPLATE_LANGUAGE_JINJA2, 'Jinja2'),
)
# Topology map types # Topology map types
TOPOLOGYMAP_TYPE_NETWORK = 1 TOPOLOGYMAP_TYPE_NETWORK = 1
TOPOLOGYMAP_TYPE_CONSOLE = 2 TOPOLOGYMAP_TYPE_CONSOLE = 2

View File

@ -82,7 +82,7 @@ class ExportTemplateFilter(django_filters.FilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['content_type', 'name'] fields = ['content_type', 'name', 'template_language']
class TagFilter(django_filters.FilterSet): class TagFilter(django_filters.FilterSet):

View File

@ -4,7 +4,6 @@ from django import forms
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from mptt.forms import TreeNodeMultipleChoiceField
from taggit.forms import TagField from taggit.forms import TagField
from taggit.models import Tag from taggit.models import Tag
@ -12,7 +11,7 @@ from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.forms import ( from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect,
FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, FilterChoiceField, LaxURLField, JSONField, SlugField,
) )
from .constants import ( from .constants import (
CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL,

View File

@ -37,7 +37,7 @@ def _record_object_deleted(request, instance, **kwargs):
if hasattr(instance, 'log_change'): if hasattr(instance, 'log_change'):
instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE) instance.log_change(request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
enqueue_webhooks(instance, OBJECTCHANGE_ACTION_DELETE) enqueue_webhooks(instance, request.user, request.id, OBJECTCHANGE_ACTION_DELETE)
class ObjectChangeMiddleware(object): class ObjectChangeMiddleware(object):
@ -83,7 +83,7 @@ class ObjectChangeMiddleware(object):
obj.log_change(request.user, request.id, action) obj.log_change(request.user, request.id, action)
# Enqueue webhooks # Enqueue webhooks
enqueue_webhooks(obj, action) enqueue_webhooks(obj, request.user, request.id, action)
# Housekeeping: 1% chance of clearing out expired ObjectChanges # Housekeeping: 1% chance of clearing out expired ObjectChanges
if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1:

View File

@ -0,0 +1,27 @@
# Generated by Django 2.1.7 on 2019-04-08 14:49
from django.db import migrations, models
def set_template_language(apps, schema_editor):
"""
Set the language for all existing ExportTemplates to Django (Jinja2 is the default for new ExportTemplates).
"""
ExportTemplate = apps.get_model('extras', 'ExportTemplate')
ExportTemplate.objects.update(template_language=10)
class Migration(migrations.Migration):
dependencies = [
('extras', '0017_exporttemplate_mime_type_length'),
]
operations = [
migrations.AddField(
model_name='exporttemplate',
name='template_language',
field=models.PositiveSmallIntegerField(default=20),
),
migrations.RunPython(set_template_language),
]

View File

@ -1,7 +1,6 @@
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
import graphviz
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
@ -12,6 +11,8 @@ from django.db.models import F, Q
from django.http import HttpResponse from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.urls import reverse from django.urls import reverse
import graphviz
from jinja2 import Environment
from dcim.constants import CONNECTION_STATUS_CONNECTED from dcim.constants import CONNECTION_STATUS_CONNECTED
from utilities.utils import deepmerge, foreground_color from utilities.utils import deepmerge, foreground_color
@ -355,6 +356,10 @@ class ExportTemplate(models.Model):
max_length=200, max_length=200,
blank=True blank=True
) )
template_language = models.PositiveSmallIntegerField(
choices=TEMPLATE_LANGUAGE_CHOICES,
default=TEMPLATE_LANGUAGE_JINJA2
)
template_code = models.TextField() template_code = models.TextField()
mime_type = models.CharField( mime_type = models.CharField(
max_length=50, max_length=50,
@ -374,16 +379,36 @@ class ExportTemplate(models.Model):
def __str__(self): def __str__(self):
return '{}: {}'.format(self.content_type, self.name) return '{}: {}'.format(self.content_type, self.name)
def render(self, queryset):
"""
Render the contents of the template.
"""
context = {
'queryset': queryset
}
if self.template_language == TEMPLATE_LANGUAGE_DJANGO:
template = Template(self.template_code)
output = template.render(Context(context))
elif self.template_language == TEMPLATE_LANGUAGE_JINJA2:
template = Environment().from_string(source=self.template_code)
output = template.render(**context)
else:
return None
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
return output
def render_to_response(self, queryset): def render_to_response(self, queryset):
""" """
Render the template to an HTTP response, delivered as a named file attachment Render the template to an HTTP response, delivered as a named file attachment
""" """
template = Template(self.template_code) output = self.render(queryset)
mime_type = 'text/plain' if not self.mime_type else self.mime_type mime_type = 'text/plain' if not self.mime_type else self.mime_type
output = template.render(Context({'queryset': queryset}))
# Replace CRLF-style line terminators
output = output.replace('\r\n', '\n')
# Build the response # Build the response
response = HttpResponse(output, content_type=mime_type) response = HttpResponse(output, content_type=mime_type)
@ -720,7 +745,7 @@ class ConfigContextModel(models.Model):
data = deepmerge(data, context.data) data = deepmerge(data, context.data)
# If the object has local config context data defined, merge it last # If the object has local config context data defined, merge it last
if self.local_context_data is not None: if self.local_context_data:
data = deepmerge(data, self.local_context_data) data = deepmerge(data, self.local_context_data)
return data return data

View File

@ -30,7 +30,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT
class TagListView(ObjectListView): class TagListView(ObjectListView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items') items=Count('taggit_taggeditem_items', distinct=True)
).order_by( ).order_by(
'name' 'name'
) )

View File

@ -9,7 +9,7 @@ from utilities.api import get_serializer_for_model
from .constants import WEBHOOK_MODELS from .constants import WEBHOOK_MODELS
def enqueue_webhooks(instance, action): def enqueue_webhooks(instance, user, request_id, action):
""" """
Find Webhook(s) assigned to this instance + action and enqueue them Find Webhook(s) assigned to this instance + action and enqueue them
to be processed to be processed
@ -47,5 +47,7 @@ def enqueue_webhooks(instance, action):
serializer.data, serializer.data,
instance._meta.model_name, instance._meta.model_name,
action, action,
str(datetime.datetime.now()) str(datetime.datetime.now()),
user.username,
request_id
) )

View File

@ -10,7 +10,7 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ
@job('default') @job('default')
def process_webhook(webhook, data, model_name, event, timestamp): def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
""" """
Make a POST request to the defined Webhook Make a POST request to the defined Webhook
""" """
@ -18,6 +18,8 @@ def process_webhook(webhook, data, model_name, event, timestamp):
'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(),
'timestamp': timestamp, 'timestamp': timestamp,
'model': model_name, 'model': model_name,
'username': username,
'request_id': request_id,
'data': data 'data': data
} }
headers = { headers = {

View File

@ -128,6 +128,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer):
# #
class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=AF_CHOICES, read_only=True)
site = NestedSiteSerializer(required=False, allow_null=True) site = NestedSiteSerializer(required=False, allow_null=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -189,6 +190,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer):
class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer):
family = ChoiceField(choices=AF_CHOICES, read_only=True)
vrf = NestedVRFSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False)

View File

@ -524,14 +524,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='Mask length', label='Mask length',
widget=StaticSelect2() widget=StaticSelect2()
) )
vrf = FilterChoiceField( vrf_id = FilterChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF', label='VRF',
null_label='-- Global --', null_label='-- Global --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vrfs/", api_url="/api/ipam/vrfs/",
value_field="rd",
null_option=True, null_option=True,
) )
) )
@ -973,14 +971,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm):
label='Mask length', label='Mask length',
widget=StaticSelect2() widget=StaticSelect2()
) )
vrf = FilterChoiceField( vrf_id = FilterChoiceField(
queryset=VRF.objects.all(), queryset=VRF.objects.all(),
to_field_name='rd',
label='VRF', label='VRF',
null_label='-- Global --', null_label='-- Global --',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url="/api/ipam/vrfs/", api_url="/api/ipam/vrfs/",
value_field="rd",
null_option=True, null_option=True,
) )
) )

View File

@ -30,7 +30,7 @@ RIR_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_rir %} {% if perms.ipam.change_rir %}
<a href="{% url 'ipam:rir_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:rir_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -52,7 +52,7 @@ ROLE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.ipam.change_role %} {% if perms.ipam.change_role %}
<a href="{% url 'ipam:role_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:role_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """
{% endif %} {% endif %}
{% endwith %} {% endwith %}
{% if perms.ipam.change_vlangroup %} {% if perms.ipam.change_vlangroup %}
<a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'ipam:vlangroup_edit' pk=record.pk %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """

View File

@ -147,18 +147,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
# Miscellaneous # Miscellaneous
# #
def get_view_name(view_cls, suffix=None): def get_view_name(view, suffix=None):
""" """
Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`. Derive the view name from its associated model, if it has one. Fall back to DRF's built-in `get_view_name`.
""" """
if hasattr(view_cls, 'queryset'): if hasattr(view, 'queryset'):
# Determine the model name from the queryset. # Determine the model name from the queryset.
name = view_cls.queryset.model._meta.verbose_name name = view.queryset.model._meta.verbose_name
name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word name = ' '.join([w[0].upper() + w[1:] for w in name.split()]) # Capitalize each word
else: else:
# Replicate DRF's built-in behavior. # Replicate DRF's built-in behavior.
name = view_cls.__name__ name = view.__class__.__name__
name = formatting.remove_trailing_string(name, 'View') name = formatting.remove_trailing_string(name, 'View')
name = formatting.remove_trailing_string(name, 'ViewSet') name = formatting.remove_trailing_string(name, 'ViewSet')
name = formatting.camelcase_to_spaces(name) name = formatting.camelcase_to_spaces(name)

View File

@ -22,7 +22,7 @@ except ImportError:
) )
VERSION = '2.5.9-dev' VERSION = '2.5.11-dev'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -156,11 +156,12 @@ $(document).ready(function() {
filter_for_elements.each(function(index, filter_for_element) { filter_for_elements.each(function(index, filter_for_element) {
var param_name = $(filter_for_element).attr(attr_name); var param_name = $(filter_for_element).attr(attr_name);
var is_nullable = $(filter_for_element).attr("nullable"); var is_nullable = $(filter_for_element).attr("nullable");
var is_visible = $(filter_for_element).is(":visible");
var value = $(filter_for_element).val(); var value = $(filter_for_element).val();
if (param_name && value) { if (param_name && is_visible && value) {
parameters[param_name] = value; parameters[param_name] = value;
} else if (param_name && is_nullable) { } else if (param_name && is_visible && is_nullable) {
parameters[param_name] = "null"; parameters[param_name] = "null";
} }
}); });
@ -250,7 +251,7 @@ $(document).ready(function() {
ajax: { ajax: {
delay: 250, delay: 250,
url: "/api/extras/tags/", url: netbox_api_path + "extras/tags/",
data: function(params) { data: function(params) {
// Paging. Note that `params.page` indexes at 1 // Paging. Note that `params.page` indexes at 1

View File

@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.secrets.change_secretrole %} {% if perms.secrets.change_secretrole %}
<a href="{% url 'secrets:secretrole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'secrets:secretrole_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """

View File

@ -120,6 +120,8 @@ def secret_add(request, pk):
secret.plaintext = str(form.cleaned_data['plaintext']) secret.plaintext = str(form.cleaned_data['plaintext'])
secret.encrypt(master_key) secret.encrypt(master_key)
secret.save() secret.save()
form.save_m2m()
messages.success(request, "Added new secret: {}.".format(secret)) messages.success(request, "Added new secret: {}.".format(secret))
if '_addanother' in request.POST: if '_addanother' in request.POST:
return redirect('dcim:device_addsecret', pk=device.pk) return redirect('dcim:device_addsecret', pk=device.pk)

View File

@ -59,7 +59,7 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>VLAN</strong> <strong>VLAN</strong>
@ -136,7 +136,7 @@
{% include 'inc/custom_fields_panel.html' with obj=vlan %} {% include 'inc/custom_fields_panel.html' with obj=vlan %}
{% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %} {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
</div> </div>
<div class="col-md-6"> <div class="col-md-8">
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">
<strong>Prefixes</strong> <strong>Prefixes</strong>

View File

@ -8,7 +8,7 @@ TENANTGROUP_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.tenancy.change_tenantgroup %} {% if perms.tenancy.change_tenantgroup %}
<a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'tenancy:tenantgroup_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """

View File

@ -1,7 +1,10 @@
from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
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 HttpResponseForbidden, HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -44,6 +47,11 @@ class LoginView(View):
if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()): if not is_safe_url(url=redirect_to, allowed_hosts=request.get_host()):
redirect_to = reverse('home') redirect_to = reverse('home')
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
if settings.MAINTENANCE_MODE:
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
# Authenticate user # Authenticate user
auth_login(request, form.get_user()) auth_login(request, form.get_user())
messages.info(request, "Logged in as {}.".format(request.user)) messages.info(request, "Logged in as {}.".format(request.user))

View File

@ -21,6 +21,10 @@ class ServiceUnavailable(APIException):
default_detail = "Service temporarily unavailable, please try again later." default_detail = "Service temporarily unavailable, please try again later."
class SerializerNotFound(Exception):
pass
def get_serializer_for_model(model, prefix=''): def get_serializer_for_model(model, prefix=''):
""" """
Dynamically resolve and return the appropriate serializer for a model. Dynamically resolve and return the appropriate serializer for a model.
@ -32,7 +36,9 @@ def get_serializer_for_model(model, prefix=''):
try: try:
return dynamic_import(serializer_name) return dynamic_import(serializer_name)
except AttributeError: except AttributeError:
return None raise SerializerNotFound(
"Could not determine serializer for {}.{} with prefix '{}'".format(app_name, model_name, prefix)
)
# #
@ -100,6 +106,10 @@ class ChoiceField(Field):
return data return data
@property
def choices(self):
return self._choices
class ContentTypeField(RelatedField): class ContentTypeField(RelatedField):
""" """
@ -110,10 +120,6 @@ class ContentTypeField(RelatedField):
"invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.", "invalid": "Invalid value. Specify a content type as '<app_label>.<model_name>'.",
} }
# Can't set this as an attribute because it raises an exception when the field is read-only
def get_queryset(self):
return ContentType.objects.all()
def to_internal_value(self, data): def to_internal_value(self, data):
try: try:
app_label, model = data.split('.') app_label, model = data.split('.')
@ -234,9 +240,10 @@ class ModelViewSet(_ModelViewSet):
# exists # exists
request = self.get_serializer_context()['request'] request = self.get_serializer_context()['request']
if request.query_params.get('brief', False): if request.query_params.get('brief', False):
serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested') try:
if serializer_class is not None: return get_serializer_for_model(self.queryset.model, prefix='Nested')
return serializer_class except SerializerNotFound:
pass
# Fall back to the hard-coded serializer class # Fall back to the hard-coded serializer class
return self.serializer_class return self.serializer_class
@ -256,10 +263,14 @@ class FieldChoicesViewSet(ViewSet):
self._fields = OrderedDict() self._fields = OrderedDict()
for cls, field_list in self.fields: for cls, field_list in self.fields:
for field_name in field_list: for field_name in field_list:
model_name = cls._meta.verbose_name.lower().replace(' ', '-') model_name = cls._meta.verbose_name.lower().replace(' ', '-')
key = ':'.join([model_name, field_name]) key = ':'.join([model_name, field_name])
serializer = get_serializer_for_model(cls)()
choices = [] choices = []
for k, v in cls._meta.get_field(field_name).choices:
for k, v in serializer.get_fields()[field_name].choices.items():
if type(v) in [list, tuple]: if type(v) in [list, tuple]:
for k2, v2 in v: for k2, v2 in v:
choices.append({ choices.append({

View File

@ -11,7 +11,7 @@ CLUSTERTYPE_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.virtualization.change_clustertype %} {% if perms.virtualization.change_clustertype %}
<a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'virtualization:clustertype_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """
@ -20,7 +20,7 @@ CLUSTERGROUP_ACTIONS = """
<i class="fa fa-history"></i> <i class="fa fa-history"></i>
</a> </a>
{% if perms.virtualization.change_clustergroup %} {% if perms.virtualization.change_clustergroup %}
<a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a> <a href="{% url 'virtualization:clustergroup_edit' slug=record.slug %}?return_url={{ request.path }}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %} {% endif %}
""" """

View File

@ -622,7 +622,7 @@ class InterfaceTest(APITestCase):
def test_delete_interface(self): def test_delete_interface(self):
url = reverse('dcim-api:interface-detail', kwargs={'pk': self.interface1.pk}) url = reverse('virtualization-api:interface-detail', kwargs={'pk': self.interface1.pk})
response = self.client.delete(url, **self.header) response = self.client.delete(url, **self.header)
self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT)

View File

@ -1,4 +1,4 @@
Django>=2.1.5,<2.2 Django>=2.2,<2.3
django-cors-headers==2.4.0 django-cors-headers==2.4.0
django-debug-toolbar==1.11 django-debug-toolbar==1.11
django-filter==2.0.0 django-filter==2.0.0
@ -10,6 +10,7 @@ django-timezone-field==3.0
djangorestframework==3.9.0 djangorestframework==3.9.0
drf-yasg[validation]==1.14.0 drf-yasg[validation]==1.14.0
graphviz==0.10.1 graphviz==0.10.1
Jinja2==2.10
Markdown==2.6.11 Markdown==2.6.11
netaddr==0.7.19 netaddr==0.7.19
Pillow==5.3.0 Pillow==5.3.0