diff --git a/CHANGELOG.md b/CHANGELOG.md index 557c56c80..2ecdcd652 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,62 @@ -v2.5.9 (FUTURE) +2.5.11 (FUTURE) ## Enhancements -* [#2902](https://github.com/digitalocean/netbox/issues/2902) - Replace supervisord with systemd -* [#3011](https://github.com/digitalocean/netbox/issues/3011) - Add SSL support for django-rq (requires django-rq v1.3.1+) +* [#2986](https://github.com/digitalocean/netbox/issues/2986) - Improve natural ordering of device components +* [#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 -* [#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 * [#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 * [#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 +* [#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) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index 1cddeffb2..60b6a7f7c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """ {% if perms.circuit.change_circuittype %} - + {% endif %} """ diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 4c65a3a19..d8bf68e12 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField @@ -502,8 +503,12 @@ class InventoryItemSerializer(TaggitSerializer, ValidatedModelSerializer): # class CableSerializer(ValidatedModelSerializer): - termination_a_type = ContentTypeField() - termination_b_type = ContentTypeField() + termination_a_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_b = serializers.SerializerMethodField(read_only=True) status = ChoiceField(choices=CONNECTION_STATUS_CHOICES, required=False) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8fddc7129..8964e7fcb 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,7 +1,7 @@ from collections import OrderedDict 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.shortcuts import get_object_or_404 from drf_yasg import openapi @@ -35,7 +35,7 @@ from .exceptions import MissingFilterException class DCIMFieldChoicesViewSet(FieldChoicesViewSet): fields = ( - (Cable, ['length_unit', 'status', 'type']), + (Cable, ['length_unit', 'status', 'termination_a_type', 'termination_b_type', 'type']), (ConsolePort, ['connection_status']), (Device, ['face', 'status']), (DeviceType, ['subdevice_role']), @@ -419,7 +419,9 @@ class PowerOutletViewSet(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' ).prefetch_related( 'ip_addresses', 'tags' diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index af2547bc4..926b97130 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -318,6 +318,7 @@ DEVICE_STATUS_PLANNED = 2 DEVICE_STATUS_STAGED = 3 DEVICE_STATUS_FAILED = 4 DEVICE_STATUS_INVENTORY = 5 +DEVICE_STATUS_DECOMMISSIONING = 6 DEVICE_STATUS_CHOICES = [ [DEVICE_STATUS_ACTIVE, 'Active'], [DEVICE_STATUS_OFFLINE, 'Offline'], @@ -325,6 +326,7 @@ DEVICE_STATUS_CHOICES = [ [DEVICE_STATUS_STAGED, 'Staged'], [DEVICE_STATUS_FAILED, 'Failed'], [DEVICE_STATUS_INVENTORY, 'Inventory'], + [DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'], ] # Site statuses @@ -345,6 +347,7 @@ STATUS_CLASSES = { 3: 'primary', 4: 'danger', 5: 'default', + 6: 'warning', } # Console/power/interface connection statuses diff --git a/netbox/dcim/fields.py b/netbox/dcim/fields.py index 8d4bfba35..9624ce0a3 100644 --- a/netbox/dcim/fields.py +++ b/netbox/dcim/fields.py @@ -31,7 +31,7 @@ class MACAddressField(models.Field): try: return EUI(value, version=48, dialect=mac_unix_expanded_uppercase) except AddrFormatError as e: - raise ValidationError(e) + raise ValidationError("Invalid MAC address format: {}".format(value)) def db_type(self, connection): return 'macaddr' diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index dda904f1c..7c190176b 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -1,5 +1,7 @@ import django_filters 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 netaddr import EUI from netaddr.core import AddrFormatError @@ -967,6 +969,14 @@ class CableFilter(django_filters.FilterSet): color = django_filters.MultipleChoiceFilter( 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: model = Cable @@ -977,6 +987,16 @@ class CableFilter(django_filters.FilterSet): return queryset 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): site = django_filters.CharFilter( diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 06cc7ffe2..71ad45409 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2706,12 +2706,12 @@ class CableBulkEditForm(BootstrapMixin, BulkEditForm): status = forms.ChoiceField( choices=add_blank_choice(CONNECTION_STATUS_CHOICES), required=False, + widget=StaticSelect2(), initial='' ) label = forms.CharField( max_length=100, - required=False, - widget=StaticSelect2() + required=False ) color = forms.CharField( max_length=6, @@ -2766,6 +2766,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) + device = forms.CharField( + required=False, + label='Device name' + ) # diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index 9e4e5fca2..53f627a5b 100644 --- a/netbox/dcim/managers.py +++ b/netbox/dcim/managers.py @@ -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)" -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): def connectable(self): diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 004d7b1aa..f8e8a028e 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -23,7 +23,7 @@ from utilities.utils import serialize_object, to_meters from .constants import * from .exceptions import LoopDetected from .fields import ASNField, MACAddressField -from .managers import DeviceComponentManager, InterfaceManager +from .managers import InterfaceManager class ComponentTemplateModel(models.Model): @@ -1004,7 +1004,7 @@ class ConsolePortTemplate(ComponentTemplateModel): max_length=50 ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1027,7 +1027,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel): max_length=50 ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1050,7 +1050,7 @@ class PowerPortTemplate(ComponentTemplateModel): max_length=50 ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1073,7 +1073,7 @@ class PowerOutletTemplate(ComponentTemplateModel): max_length=50 ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1139,7 +1139,7 @@ class FrontPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1188,7 +1188,7 @@ class RearPortTemplate(ComponentTemplateModel): validators=[MinValueValidator(1), MaxValueValidator(64)] ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1211,7 +1211,7 @@ class DeviceBayTemplate(ComponentTemplateModel): max_length=50 ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() class Meta: ordering = ['device_type', 'name'] @@ -1704,6 +1704,21 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): filter |= Q(device__virtual_chassis=self.virtual_chassis, mgmt_only=False) 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): """ Return the set of child Devices installed in DeviceBays within this Device. @@ -1742,7 +1757,7 @@ class ConsolePort(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name'] @@ -1785,7 +1800,7 @@ class ConsoleServerPort(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name'] @@ -1834,7 +1849,7 @@ class PowerPort(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name'] @@ -1877,7 +1892,7 @@ class PowerOutlet(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name'] @@ -2198,7 +2213,7 @@ class FrontPort(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name', 'type', 'rear_port', 'rear_port_position', 'description'] @@ -2264,7 +2279,7 @@ class RearPort(CableTermination, ComponentModel): blank=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name', 'type', 'positions', 'description'] @@ -2311,7 +2326,7 @@ class DeviceBay(ComponentModel): null=True ) - objects = DeviceComponentManager() + objects = NaturalOrderingManager() tags = TaggableManager() csv_headers = ['device', 'name', 'installed_device'] diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 436b9053d..a36b11e65 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -44,7 +44,7 @@ REGION_ACTIONS = """ {% if perms.dcim.change_region %} - + {% endif %} """ @@ -56,7 +56,7 @@ RACKGROUP_ACTIONS = """ {% if perms.dcim.change_rackgroup %} - + {% endif %} @@ -67,7 +67,7 @@ RACKROLE_ACTIONS = """ {% if perms.dcim.change_rackrole %} - + {% endif %} """ @@ -88,7 +88,7 @@ RACKRESERVATION_ACTIONS = """ {% if perms.dcim.change_rackreservation %} - + {% endif %} """ @@ -97,7 +97,7 @@ MANUFACTURER_ACTIONS = """ {% if perms.dcim.change_manufacturer %} - + {% endif %} """ @@ -106,7 +106,7 @@ DEVICEROLE_ACTIONS = """ {% if perms.dcim.change_devicerole %} - + {% endif %} """ @@ -131,7 +131,7 @@ PLATFORM_ACTIONS = """ {% if perms.dcim.change_platform %} - + {% endif %} """ @@ -168,7 +168,7 @@ VIRTUALCHASSIS_ACTIONS = """ {% if perms.dcim.change_virtualchassis %} - + {% endif %} """ @@ -733,18 +733,18 @@ class InterfaceConnectionTable(BaseTable): ) device_b = tables.LinkColumn( viewname='dcim:device', - accessor=Accessor('connected_endpoint.device'), - args=[Accessor('connected_endpoint.device.pk')], + accessor=Accessor('_connected_interface.device'), + args=[Accessor('_connected_interface.device.pk')], verbose_name='Device B' ) interface_b = tables.LinkColumn( viewname='dcim:interface', - accessor=Accessor('connected_endpoint.name'), - args=[Accessor('connected_endpoint.pk')], + accessor=Accessor('_connected_interface'), + args=[Accessor('_connected_interface.pk')], verbose_name='Interface B' ) description_b = tables.Column( - accessor=Accessor('connected_endpoint.description'), + accessor=Accessor('_connected_interface.description'), verbose_name='Description' ) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 7643562bb..cca783bc6 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,3 +1,4 @@ +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers from taggit.models import Tag @@ -15,7 +16,8 @@ from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantG from tenancy.models import Tenant, TenantGroup from users.api.nested_serializers import NestedUserSerializer 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 * @@ -53,10 +55,17 @@ class RenderedGraphSerializer(serializers.ModelSerializer): # class ExportTemplateSerializer(ValidatedModelSerializer): + template_language = ChoiceField( + choices=TEMPLATE_LANGUAGE_CHOICES, + default=TEMPLATE_LANGUAGE_JINJA2 + ) class Meta: 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): - content_type = ContentTypeField() + content_type = ContentTypeField( + queryset=ContentType.objects.all() + ) parent = serializers.SerializerMethodField(read_only=True) class Meta: @@ -205,14 +216,25 @@ class ReportDetailSerializer(ReportSerializer): # class ObjectChangeSerializer(serializers.ModelSerializer): - user = NestedUserSerializer(read_only=True) - content_type = ContentTypeField(read_only=True) - changed_object = serializers.SerializerMethodField(read_only=True) + user = NestedUserSerializer( + 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: model = ObjectChange 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): @@ -221,9 +243,14 @@ class ObjectChangeSerializer(serializers.ModelSerializer): """ if obj.changed_object is None: return None - serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') - if serializer is None: + + try: + serializer = get_serializer_for_model(obj.changed_object, prefix='Nested') + except SerializerNotFound: return obj.object_repr - context = {'request': self.context['request']} + context = { + 'request': self.context['request'] + } data = serializer(obj.changed_object, context=context).data + return data diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 0453b1f1c..2150cb5b5 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -10,7 +10,7 @@ from taggit.models import Tag from extras import filters 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 utilities.api import FieldChoicesViewSet, IsAuthenticatedOrLoginNotRequired, ModelViewSet @@ -23,8 +23,9 @@ from . import serializers class ExtrasFieldChoicesViewSet(FieldChoicesViewSet): fields = ( - (CustomField, ['type']), + (ExportTemplate, ['template_language']), (Graph, ['type']), + (ObjectChange, ['action']), ) @@ -115,7 +116,9 @@ class TopologyMapViewSet(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 filterset_class = filters.TagFilter diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 51fc398f7..13c15cbba 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -56,6 +56,14 @@ EXPORTTEMPLATE_MODELS = [ '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 TOPOLOGYMAP_TYPE_NETWORK = 1 TOPOLOGYMAP_TYPE_CONSOLE = 2 diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index d0a801b48..d5457a5a6 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -82,7 +82,7 @@ class ExportTemplateFilter(django_filters.FilterSet): class Meta: model = ExportTemplate - fields = ['content_type', 'name'] + fields = ['content_type', 'name', 'template_language'] class TagFilter(django_filters.FilterSet): diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index b48482c93..54eee0c5c 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -4,7 +4,6 @@ from django import forms from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist -from mptt.forms import TreeNodeMultipleChoiceField from taggit.forms import TagField from taggit.models import Tag @@ -12,7 +11,7 @@ from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup from utilities.forms import ( add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, - FilterChoiceField, FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField, + FilterChoiceField, LaxURLField, JSONField, SlugField, ) from .constants import ( CF_FILTER_DISABLED, CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT, CF_TYPE_URL, diff --git a/netbox/extras/middleware.py b/netbox/extras/middleware.py index 38dde6275..be878918b 100644 --- a/netbox/extras/middleware.py +++ b/netbox/extras/middleware.py @@ -37,7 +37,7 @@ def _record_object_deleted(request, instance, **kwargs): if hasattr(instance, 'log_change'): 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): @@ -83,7 +83,7 @@ class ObjectChangeMiddleware(object): obj.log_change(request.user, request.id, action) # Enqueue webhooks - enqueue_webhooks(obj, action) + enqueue_webhooks(obj, request.user, request.id, action) # Housekeeping: 1% chance of clearing out expired ObjectChanges if _thread_locals.changed_objects and settings.CHANGELOG_RETENTION and random.randint(1, 100) == 1: diff --git a/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py b/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py new file mode 100644 index 000000000..1177ac2fb --- /dev/null +++ b/netbox/extras/migrations/0018_exporttemplate_add_jinja2.py @@ -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), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 1b106a62a..da8f09a50 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -1,7 +1,6 @@ from collections import OrderedDict from datetime import date -import graphviz from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey 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.template import Template, Context from django.urls import reverse +import graphviz +from jinja2 import Environment from dcim.constants import CONNECTION_STATUS_CONNECTED from utilities.utils import deepmerge, foreground_color @@ -355,6 +356,10 @@ class ExportTemplate(models.Model): max_length=200, blank=True ) + template_language = models.PositiveSmallIntegerField( + choices=TEMPLATE_LANGUAGE_CHOICES, + default=TEMPLATE_LANGUAGE_JINJA2 + ) template_code = models.TextField() mime_type = models.CharField( max_length=50, @@ -374,16 +379,36 @@ class ExportTemplate(models.Model): def __str__(self): 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): """ 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 - output = template.render(Context({'queryset': queryset})) - - # Replace CRLF-style line terminators - output = output.replace('\r\n', '\n') # Build the response response = HttpResponse(output, content_type=mime_type) @@ -720,7 +745,7 @@ class ConfigContextModel(models.Model): data = deepmerge(data, context.data) # 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) return data diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 713143af8..2f088eb77 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -30,7 +30,7 @@ from .tables import ConfigContextTable, ObjectChangeTable, TagTable, TaggedItemT class TagListView(ObjectListView): queryset = Tag.objects.annotate( - items=Count('taggit_taggeditem_items') + items=Count('taggit_taggeditem_items', distinct=True) ).order_by( 'name' ) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 12dc7558b..1ad050866 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -9,7 +9,7 @@ from utilities.api import get_serializer_for_model 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 to be processed @@ -47,5 +47,7 @@ def enqueue_webhooks(instance, action): serializer.data, instance._meta.model_name, action, - str(datetime.datetime.now()) + str(datetime.datetime.now()), + user.username, + request_id ) diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index 5a680f5d1..45d996f9b 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -10,7 +10,7 @@ from extras.constants import WEBHOOK_CT_JSON, WEBHOOK_CT_X_WWW_FORM_ENCODED, OBJ @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 """ @@ -18,6 +18,8 @@ def process_webhook(webhook, data, model_name, event, timestamp): 'event': dict(OBJECTCHANGE_ACTION_CHOICES)[event].lower(), 'timestamp': timestamp, 'model': model_name, + 'username': username, + 'request_id': request_id, 'data': data } headers = { diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 030266188..9b2c45371 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -128,6 +128,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): # class PrefixSerializer(TaggitSerializer, CustomFieldModelSerializer): + family = ChoiceField(choices=AF_CHOICES, read_only=True) site = NestedSiteSerializer(required=False, allow_null=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) @@ -189,6 +190,7 @@ class IPAddressInterfaceSerializer(WritableNestedSerializer): class IPAddressSerializer(TaggitSerializer, CustomFieldModelSerializer): + family = ChoiceField(choices=AF_CHOICES, read_only=True) vrf = NestedVRFSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True) status = ChoiceField(choices=IPADDRESS_STATUS_CHOICES, required=False) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 1274164ca..f96f1a6a2 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -524,14 +524,12 @@ class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Mask length', widget=StaticSelect2() ) - vrf = FilterChoiceField( + vrf_id = FilterChoiceField( queryset=VRF.objects.all(), - to_field_name='rd', label='VRF', null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", - value_field="rd", null_option=True, ) ) @@ -973,14 +971,12 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): label='Mask length', widget=StaticSelect2() ) - vrf = FilterChoiceField( + vrf_id = FilterChoiceField( queryset=VRF.objects.all(), - to_field_name='rd', label='VRF', null_label='-- Global --', widget=APISelectMultiple( api_url="/api/ipam/vrfs/", - value_field="rd", null_option=True, ) ) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 3d46452b2..fb48dda24 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -30,7 +30,7 @@ RIR_ACTIONS = """ {% if perms.ipam.change_rir %} - + {% endif %} """ @@ -52,7 +52,7 @@ ROLE_ACTIONS = """ {% if perms.ipam.change_role %} - + {% endif %} """ @@ -152,7 +152,7 @@ VLANGROUP_ACTIONS = """ {% endif %} {% endwith %} {% if perms.ipam.change_vlangroup %} - + {% endif %} """ diff --git a/netbox/netbox/api.py b/netbox/netbox/api.py index 60c493be7..d8592f341 100644 --- a/netbox/netbox/api.py +++ b/netbox/netbox/api.py @@ -147,18 +147,18 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): # 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`. """ - if hasattr(view_cls, 'queryset'): + if hasattr(view, '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 else: # 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, 'ViewSet') name = formatting.camelcase_to_spaces(name) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 389fead42..da429823b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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__))) diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 81ddb9ffd..96d59ace5 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -156,11 +156,12 @@ $(document).ready(function() { filter_for_elements.each(function(index, filter_for_element) { var param_name = $(filter_for_element).attr(attr_name); var is_nullable = $(filter_for_element).attr("nullable"); + var is_visible = $(filter_for_element).is(":visible"); var value = $(filter_for_element).val(); - if (param_name && value) { + if (param_name && is_visible && value) { parameters[param_name] = value; - } else if (param_name && is_nullable) { + } else if (param_name && is_visible && is_nullable) { parameters[param_name] = "null"; } }); @@ -250,7 +251,7 @@ $(document).ready(function() { ajax: { delay: 250, - url: "/api/extras/tags/", + url: netbox_api_path + "extras/tags/", data: function(params) { // Paging. Note that `params.page` indexes at 1 diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py index a547ef4f8..1f937f54b 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """ {% if perms.secrets.change_secretrole %} - + {% endif %} """ diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py index 91d8caf0d..99b725528 100644 --- a/netbox/secrets/views.py +++ b/netbox/secrets/views.py @@ -120,6 +120,8 @@ def secret_add(request, pk): secret.plaintext = str(form.cleaned_data['plaintext']) secret.encrypt(master_key) secret.save() + form.save_m2m() + messages.success(request, "Added new secret: {}.".format(secret)) if '_addanother' in request.POST: return redirect('dcim:device_addsecret', pk=device.pk) diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html index 7798bf9e0..aa5869e5e 100644 --- a/netbox/templates/ipam/vlan.html +++ b/netbox/templates/ipam/vlan.html @@ -59,7 +59,7 @@ {% block content %}
-
+
VLAN @@ -136,7 +136,7 @@ {% include 'inc/custom_fields_panel.html' with obj=vlan %} {% include 'extras/inc/tags_panel.html' with tags=vlan.tags.all url='ipam:vlan_list' %}
-
+
Prefixes diff --git a/netbox/tenancy/tables.py b/netbox/tenancy/tables.py index 2938a4aae..884bdc3df 100644 --- a/netbox/tenancy/tables.py +++ b/netbox/tenancy/tables.py @@ -8,7 +8,7 @@ TENANTGROUP_ACTIONS = """ {% if perms.tenancy.change_tenantgroup %} - + {% endif %} """ diff --git a/netbox/users/views.py b/netbox/users/views.py index 6ec984936..0ff4a8049 100644 --- a/netbox/users/views.py +++ b/netbox/users/views.py @@ -1,7 +1,10 @@ +from django.conf import settings 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.decorators import login_required 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.shortcuts import get_object_or_404, redirect, render 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()): 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 auth_login(request, form.get_user()) messages.info(request, "Logged in as {}.".format(request.user)) diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py index b65a7841b..fbebd09ff 100644 --- a/netbox/utilities/api.py +++ b/netbox/utilities/api.py @@ -21,6 +21,10 @@ class ServiceUnavailable(APIException): default_detail = "Service temporarily unavailable, please try again later." +class SerializerNotFound(Exception): + pass + + def get_serializer_for_model(model, prefix=''): """ Dynamically resolve and return the appropriate serializer for a model. @@ -32,7 +36,9 @@ def get_serializer_for_model(model, prefix=''): try: return dynamic_import(serializer_name) 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 + @property + def choices(self): + return self._choices + class ContentTypeField(RelatedField): """ @@ -110,10 +120,6 @@ class ContentTypeField(RelatedField): "invalid": "Invalid value. Specify a content type as '.'.", } - # 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): try: app_label, model = data.split('.') @@ -234,9 +240,10 @@ class ModelViewSet(_ModelViewSet): # exists request = self.get_serializer_context()['request'] if request.query_params.get('brief', False): - serializer_class = get_serializer_for_model(self.queryset.model, prefix='Nested') - if serializer_class is not None: - return serializer_class + try: + return get_serializer_for_model(self.queryset.model, prefix='Nested') + except SerializerNotFound: + pass # Fall back to the hard-coded serializer class return self.serializer_class @@ -256,10 +263,14 @@ class FieldChoicesViewSet(ViewSet): self._fields = OrderedDict() for cls, field_list in self.fields: for field_name in field_list: + model_name = cls._meta.verbose_name.lower().replace(' ', '-') key = ':'.join([model_name, field_name]) + + serializer = get_serializer_for_model(cls)() 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]: for k2, v2 in v: choices.append({ diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py index f6cb06abd..6034dd8dc 100644 --- a/netbox/virtualization/tables.py +++ b/netbox/virtualization/tables.py @@ -11,7 +11,7 @@ CLUSTERTYPE_ACTIONS = """ {% if perms.virtualization.change_clustertype %} - + {% endif %} """ @@ -20,7 +20,7 @@ CLUSTERGROUP_ACTIONS = """ {% if perms.virtualization.change_clustergroup %} - + {% endif %} """ diff --git a/netbox/virtualization/tests/test_api.py b/netbox/virtualization/tests/test_api.py index 91792f8fb..328484a89 100644 --- a/netbox/virtualization/tests/test_api.py +++ b/netbox/virtualization/tests/test_api.py @@ -622,7 +622,7 @@ class InterfaceTest(APITestCase): 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) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) diff --git a/requirements.txt b/requirements.txt index 49e7cf39e..0bc96db8d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -Django>=2.1.5,<2.2 +Django>=2.2,<2.3 django-cors-headers==2.4.0 django-debug-toolbar==1.11 django-filter==2.0.0 @@ -10,6 +10,7 @@ django-timezone-field==3.0 djangorestframework==3.9.0 drf-yasg[validation]==1.14.0 graphviz==0.10.1 +Jinja2==2.10 Markdown==2.6.11 netaddr==0.7.19 Pillow==5.3.0