diff --git a/CHANGELOG.md b/CHANGELOG.md index 82949ff16..124b60a95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,121 @@ -v2.5.8 (FUTURE) +2.5.13 (FUTURE) + +## Enhancements + +* [#2813](https://github.com/digitalocean/netbox/issues/2813) - Add tenant group filters +* [#3085](https://github.com/digitalocean/netbox/issues/3085) - Catch all exceptions during export template rendering +* [#3138](https://github.com/digitalocean/netbox/issues/3138) - Add 2.5GE and 5GE interface form factors +* [#3183](https://github.com/digitalocean/netbox/issues/3183) - Enable bulk deletion of sites +* [#3186](https://github.com/digitalocean/netbox/issues/3186) - Add interface name filter for IP addresses ## Bug Fixes +* [#3132](https://github.com/digitalocean/netbox/issues/3132) - Circuit termination missing from available cable termination types +* [#3150](https://github.com/digitalocean/netbox/issues/3150) - Fix formatting of cable length during cable trace +* [#3190](https://github.com/digitalocean/netbox/issues/3190) - Fix custom field rendering for Jinja2 export templates + +--- + +2.5.12 (2019-05-01) + +## Bug Fixes + +* [#3127](https://github.com/digitalocean/netbox/issues/3127) - Fix natural ordering of device components + +--- + +2.5.11 (2019-04-29) + +## Notes + +This release upgrades the Django framework to version 2.2. + +## Enhancements + +* [#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 + +* [#2621](https://github.com/digitalocean/netbox/issues/2621) - Upgrade Django requirement to 2.2 to fix object deletion issue in the changelog middleware +* [#3072](https://github.com/digitalocean/netbox/issues/3072) - Preserve multiselect filter values when updating per-page count for list views +* [#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) + +## Enhancements + +* [#2435](https://github.com/digitalocean/netbox/issues/2435) - Printer friendly CSS + +## Bug Fixes + +* [#2065](https://github.com/digitalocean/netbox/issues/2065) - Correct documentation for VM interface serializer * [#2705](https://github.com/digitalocean/netbox/issues/2705) - Fix endpoint grouping in API docs +* [#2781](https://github.com/digitalocean/netbox/issues/2781) - Fix filtering of sites/devices/VMs by multiple regions * [#2923](https://github.com/digitalocean/netbox/issues/2923) - Provider filter form's site field should be blank by default * [#2938](https://github.com/digitalocean/netbox/issues/2938) - Enforce deterministic ordering of device components returned by API * [#2939](https://github.com/digitalocean/netbox/issues/2939) - Exclude circuit terminations from API interface connections endpoint +* [#2940](https://github.com/digitalocean/netbox/issues/2940) - Allow CSV import of prefixes/IPs to VRF without an RD assigned +* [#2944](https://github.com/digitalocean/netbox/issues/2944) - Record the deletion of an IP address in the changelog of its parent interface (if any) +* [#2952](https://github.com/digitalocean/netbox/issues/2952) - Added the `slug` field to the Tenant filter for use in the API and search function +* [#2954](https://github.com/digitalocean/netbox/issues/2954) - Remove trailing slashes to fix root/template paths on Windows +* [#2961](https://github.com/digitalocean/netbox/issues/2961) - Prevent exception when exporting inventory items belonging to unnamed devices +* [#2962](https://github.com/digitalocean/netbox/issues/2962) - Increase ExportTemplate `mime_type` field length +* [#2966](https://github.com/digitalocean/netbox/issues/2966) - Accept `null` cable length_unit via API +* [#2972](https://github.com/digitalocean/netbox/issues/2972) - Improve ContentTypeField serializer to elegantly handle invalid data +* [#2976](https://github.com/digitalocean/netbox/issues/2976) - Add delete button to tag view +* [#2980](https://github.com/digitalocean/netbox/issues/2980) - Improve rendering time for API docs +* [#2982](https://github.com/digitalocean/netbox/issues/2982) - Correct CSS class assignment on color picker +* [#2984](https://github.com/digitalocean/netbox/issues/2984) - Fix logging of unlabeled cable ID on cable deletion +* [#2985](https://github.com/digitalocean/netbox/issues/2985) - Fix pagination page length for rack elevations --- diff --git a/README.md b/README.md index 39ab1afc4..8b9df7f2c 100644 --- a/README.md +++ b/README.md @@ -45,13 +45,13 @@ and run `upgrade.sh`. ## Supported SDK -- [pynetbox](https://github.com/digitalocean/pynetbox) Python API client library for Netbox. +- [pynetbox](https://github.com/digitalocean/pynetbox) - A Python API client library for Netbox ## Community SDK -- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) A ruby client library for Netbox v2. +- [netbox-client-ruby](https://github.com/ninech/netbox-client-ruby) - A Ruby client library for Netbox +- [powerbox](https://github.com/BatmanAMA/powerbox) - A PowerShell library for Netbox ## Ansible Inventory -- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) Ansible dynamic inventory script for Netbox. - +- [netbox-as-ansible-inventory](https://github.com/AAbouZaid/netbox-as-ansible-inventory) - Ansible dynamic inventory script for Netbox diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index 08e11fe56..6dd686594 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -30,7 +30,7 @@ psql -c 'create database netbox' psql netbox < netbox.sql ``` -Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. +Keep in mind that PostgreSQL user accounts and permissions are not included with the dump: You will need to create those manually if you want to fully replicate the original database (see the [installation docs](../installation/1-postgresql.md)). When setting up a development instance of NetBox, it's strongly recommended to use different credentials anyway. ## Export the Database Schema diff --git a/docs/api/overview.md b/docs/api/overview.md index 1115759d8..00ff9c27e 100644 --- a/docs/api/overview.md +++ b/docs/api/overview.md @@ -261,7 +261,7 @@ A list of objects retrieved via the API can be filtered by passing one or more q GET /api/ipam/prefixes/?status=1 ``` -The same filter can be incldued multiple times. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: +Certain filters can be included multiple times within a single request. These will effect a logical OR and return objects matching any of the given values. For example, the following will return all active and reserved prefixes: ``` GET /api/ipam/prefixes/?status=1&status=2 diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 65ac588b6..f8bd70e88 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -283,6 +283,7 @@ REDIS = { 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, + 'SSL': False, } ``` @@ -315,3 +316,9 @@ The TCP port to use when connecting to the Redis server. Default: None The password to use when authenticating to the Redis server (optional). + +### SSL + +Default: False + +Use secure sockets layer to encrypt the connections to the Redis server. diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 12955eeca..4decb7166 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -3,13 +3,13 @@ from django.db.models import Q from dcim.models import Site from extras.filters import CustomFieldFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from .constants import CIRCUIT_STATUS_CHOICES from .models import Provider, Circuit, CircuitTermination, CircuitType -class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): +class ProviderFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -54,7 +54,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): +class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -87,16 +87,6 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=CIRCUIT_STATUS_CHOICES, null_value=None ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='terminations__site', queryset=Site.objects.all(), diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 4deee57c9..100c6334f 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -4,6 +4,7 @@ from taggit.forms import TagField from dcim.models import Site from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, CommentField, CSVChoiceField, @@ -265,8 +266,9 @@ class CircuitBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEdit ] -class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): +class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Circuit + field_order = ['q', 'type', 'provider', 'status', 'site', 'tenant_group', 'tenant', 'commit_rate'] q = forms.CharField( required=False, label='Search' @@ -292,16 +294,6 @@ class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, widget=StaticSelect2Multiple() ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py index c6a215db8..60b6a7f7c 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -11,7 +11,7 @@ CIRCUITTYPE_ACTIONS = """ {% if perms.circuit.change_circuittype %} - + {% endif %} """ @@ -59,7 +59,7 @@ class CircuitTypeTable(BaseTable): name = tables.LinkColumn() circuit_count = tables.Column(verbose_name='Circuits') actions = tables.TemplateColumn( - template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=CIRCUITTYPE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index c17400a35..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,12 +503,16 @@ 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) - length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False) + length_unit = ChoiceField(choices=CABLE_LENGTH_UNIT_CHOICES, required=False, allow_null=True) class Meta: model = Cable 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 0b81e68bf..ea7bded2b 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -75,6 +75,8 @@ IFACE_FF_100ME_FIXED = 800 IFACE_FF_1GE_FIXED = 1000 IFACE_FF_1GE_GBIC = 1050 IFACE_FF_1GE_SFP = 1100 +IFACE_FF_2GE_FIXED = 1120 +IFACE_FF_5GE_FIXED = 1130 IFACE_FF_10GE_FIXED = 1150 IFACE_FF_10GE_CX4 = 1170 IFACE_FF_10GE_SFP_PLUS = 1200 @@ -83,6 +85,7 @@ IFACE_FF_10GE_XENPAK = 1310 IFACE_FF_10GE_X2 = 1320 IFACE_FF_25GE_SFP28 = 1350 IFACE_FF_40GE_QSFP_PLUS = 1400 +IFACE_FF_50GE_QSFP28 = 1420 IFACE_FF_100GE_CFP = 1500 IFACE_FF_100GE_CFP2 = 1510 IFACE_FF_100GE_CFP4 = 1520 @@ -149,6 +152,8 @@ IFACE_FF_CHOICES = [ [ [IFACE_FF_100ME_FIXED, '100BASE-TX (10/100ME)'], [IFACE_FF_1GE_FIXED, '1000BASE-T (1GE)'], + [IFACE_FF_2GE_FIXED, '2.5GBASE-T (2.5GE)'], + [IFACE_FF_5GE_FIXED, '5GBASE-T (5GE)'], [IFACE_FF_10GE_FIXED, '10GBASE-T (10GE)'], [IFACE_FF_10GE_CX4, '10GBASE-CX4 (10GE)'], ] @@ -164,6 +169,7 @@ IFACE_FF_CHOICES = [ [IFACE_FF_10GE_X2, 'X2 (10GE)'], [IFACE_FF_25GE_SFP28, 'SFP28 (25GE)'], [IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'], + [IFACE_FF_50GE_QSFP28, 'QSFP28 (50GE)'], [IFACE_FF_100GE_CFP, 'CFP (100GE)'], [IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'], [IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'], @@ -316,6 +322,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'], @@ -323,6 +330,7 @@ DEVICE_STATUS_CHOICES = [ [DEVICE_STATUS_STAGED, 'Staged'], [DEVICE_STATUS_FAILED, 'Failed'], [DEVICE_STATUS_INVENTORY, 'Inventory'], + [DEVICE_STATUS_DECOMMISSIONING, 'Decommissioning'], ] # Site statuses @@ -343,6 +351,7 @@ STATUS_CLASSES = { 3: 'primary', 4: 'danger', 5: 'default', + 6: 'warning', } # Console/power/interface connection statuses @@ -355,7 +364,7 @@ CONNECTION_STATUS_CHOICES = [ # Cable endpoint types CABLE_TERMINATION_TYPES = [ - 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', + 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', ] # Cable types 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 96ecefafd..f9f0a57d6 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -6,9 +6,12 @@ from netaddr import EUI from netaddr.core import AddrFormatError from extras.filters import CustomFieldFilterSet +from tenancy.filtersets import TenancyFilterSet from tenancy.models import Tenant from utilities.constants import COLOR_CHOICES -from utilities.filters import NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter +from utilities.filters import ( + NameSlugSearchFilterSet, NullableCharFieldFilter, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter +) from virtualization.models import Cluster from .constants import * from .models import ( @@ -36,7 +39,7 @@ class RegionFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): +class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -49,25 +52,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): choices=SITE_STATUS_CHOICES, null_value=None ) - region_id = django_filters.NumberFilter( - method='filter_region', - field_name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - field_name='slug', - label='Region (slug)', - ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='region__in', to_field_name='slug', - label='Tenant (slug)', + label='Region (slug)', ) tag = TagFilter() @@ -95,16 +89,6 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet): pass return queryset.filter(qs_filter) - def filter_region(self, queryset, name, value): - try: - region = Region.objects.get(**{name: value}) - except ObjectDoesNotExist: - return queryset.none() - return queryset.filter( - Q(region=region) | - Q(region__in=region.get_descendants()) - ) - class RackGroupFilter(NameSlugSearchFilterSet): site_id = django_filters.ModelMultipleChoiceFilter( @@ -130,7 +114,7 @@ class RackRoleFilter(NameSlugSearchFilterSet): fields = ['name', 'slug', 'color'] -class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): +class RackFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -160,16 +144,6 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) status = django_filters.MultipleChoiceFilter( choices=RACK_STATUS_CHOICES, null_value=None @@ -206,7 +180,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): ) -class RackReservationFilter(django_filters.FilterSet): +class RackReservationFilter(TenancyFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -241,16 +215,6 @@ class RackReservationFilter(django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) user_id = django_filters.ModelMultipleChoiceFilter( queryset=User.objects.all(), label='User (ID)', @@ -456,7 +420,7 @@ class PlatformFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class DeviceFilter(CustomFieldFilterSet): +class DeviceFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -491,16 +455,6 @@ class DeviceFilter(CustomFieldFilterSet): to_field_name='slug', label='Role (slug)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) platform_id = django_filters.ModelMultipleChoiceFilter( queryset=Platform.objects.all(), label='Platform (ID)', @@ -513,14 +467,15 @@ class DeviceFilter(CustomFieldFilterSet): ) name = NullableCharFieldFilter() asset_tag = NullableCharFieldFilter() - region_id = django_filters.NumberFilter( - method='filter_region', - field_name='pk', + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', label='Region (ID)', ) - region = django_filters.CharFilter( - method='filter_region', - field_name='slug', + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', label='Region (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( @@ -619,16 +574,6 @@ class DeviceFilter(CustomFieldFilterSet): Q(comments__icontains=value) ).distinct() - def filter_region(self, queryset, name, value): - try: - region = Region.objects.get(**{name: value}) - except ObjectDoesNotExist: - return queryset.none() - return queryset.filter( - Q(site__region=region) | - Q(site__region__in=region.get_descendants()) - ) - def _mac_address(self, queryset, name, value): value = value.strip() if not value: @@ -984,6 +929,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 @@ -994,6 +947,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 bb69a4218..1f7fa679c 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,7 +13,8 @@ from timezone_field import TimeZoneFormField from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from ipam.models import IPAddress, VLAN, VLANGroup from tenancy.forms import TenancyForm -from tenancy.models import Tenant +from tenancy.forms import TenancyFilterForm +from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ColorSelect, CommentField, @@ -256,8 +257,9 @@ class SiteBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): +class SiteFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Site + field_order = ['q', 'status', 'region', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -276,16 +278,6 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): value_field="slug", ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) # @@ -596,8 +588,9 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): +class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -619,16 +612,6 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=RACK_STATUS_CHOICES, required=False, @@ -689,40 +672,6 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm): return unit_choices -class RackReservationFilterForm(BootstrapMixin, forms.Form): - q = forms.CharField( - required=False, - label='Search' - ) - site = FilterChoiceField( - queryset=Site.objects.all(), - to_field_name='slug', - widget=APISelectMultiple( - api_url="/api/dcim/sites/", - value_field="slug", - ) - ) - group_id = FilterChoiceField( - queryset=RackGroup.objects.select_related('site'), - label='Rack group', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/dcim/rack-groups/", - null_option=True, - ) - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) - - class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RackReservation.objects.all(), @@ -751,6 +700,31 @@ class RackReservationBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = [] +class RackReservationFilterForm(BootstrapMixin, TenancyFilterForm): + field_order = ['q', 'site', 'group_id', 'tenant_group', 'tenant'] + q = forms.CharField( + required=False, + label='Search' + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + group_id = FilterChoiceField( + queryset=RackGroup.objects.select_related('site'), + label='Rack group', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/dcim/rack-groups/", + null_option=True, + ) + ) + + # # Manufacturers # @@ -1643,8 +1617,12 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ] -class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): +class DeviceFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Device + field_order = [ + 'q', 'region', 'site', 'rack_group_id', 'rack_id', 'status', 'role', 'tenant_group', 'tenant', + 'manufacturer_id', 'device_type_id', 'mac_address', 'has_primary_ip', + ] q = forms.CharField( required=False, label='Search' @@ -1700,17 +1678,6 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): widget=APISelectMultiple( api_url="/api/dcim/device-roles/", value_field="slug", - null_option=True, - ) - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, ) ) manufacturer_id = FilterChoiceField( @@ -2707,12 +2674,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, @@ -2767,6 +2734,10 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) + device = forms.CharField( + required=False, + label='Device name' + ) # @@ -3102,9 +3073,31 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) + ) + tenant_group = FilterChoiceField( + queryset=TenantGroup.objects.all(), + to_field_name='slug', + null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenant-groups/", + value_field="slug", + null_option=True, + filter_for={ + 'tenant': 'group' + } + ) ) tenant = FilterChoiceField( queryset=Tenant.objects.all(), to_field_name='slug', null_label='-- None --', + widget=APISelectMultiple( + api_url="/api/tenancy/tenants/", + value_field="slug", + null_option=True, + ) ) diff --git a/netbox/dcim/managers.py b/netbox/dcim/managers.py index feaa09d74..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): @@ -64,11 +48,15 @@ class InterfaceManager(Manager): The original `name` field is considered in its entirety to serve as a fallback in the event interfaces do not match any of the prescribed fields. + + The `id` field is included to enforce deterministic ordering of interfaces in similar vein of other device + components. """ sql_col = '{}.name'.format(self.model._meta.db_table) ordering = [ - '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', + '_slot', '_subslot', '_position', '_subposition', '_type', '_id', '_channel', '_vc', 'name', 'pk' + ] fields = { diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 49879beb1..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'] @@ -2423,7 +2438,7 @@ class InventoryItem(ComponentModel): def to_csv(self): return ( - self.device.name or '{' + self.device.pk + '}', + self.device.name or '{{{}}}'.format(self.device.pk), self.name, self.manufacturer.name if self.manufacturer else None, self.part_id, @@ -2557,16 +2572,15 @@ class Cable(ChangeLoggedModel): ('termination_b_type', 'termination_b_id'), ) - def __init__(self, *args, **kwargs): - - super().__init__(*args, **kwargs) - - # Create an ID string for use by __str__(). We have to save a copy of pk since it's nullified after .delete() - # is called. - self.id_string = '#{}'.format(self.pk) - def __str__(self): - return self.label or self.id_string + if self.label: + return self.label + + # Save a copy of the PK on the instance since it's nullified if .delete() is called + if not hasattr(self, 'id_string'): + self.id_string = '#{}'.format(self.pk) + + return self.id_string def get_absolute_url(self): return reverse('dcim:cable', args=[self.pk]) diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index 5649c10ef..0266eb010 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 %} """ @@ -196,7 +196,7 @@ class RegionTable(BaseTable): slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( template_code=REGION_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -239,7 +239,7 @@ class RackGroupTable(BaseTable): slug = tables.Column() actions = tables.TemplateColumn( template_code=RACKGROUP_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -258,7 +258,7 @@ class RackRoleTable(BaseTable): rack_count = tables.Column(verbose_name='Racks') color = tables.TemplateColumn(COLOR_LABEL, verbose_name='Color') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, + actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): @@ -305,11 +305,11 @@ class RackDetailTable(RackTable): class RackReservationTable(BaseTable): pk = ToggleColumn() - tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')]) + tenant = tables.TemplateColumn(template_code=COL_TENANT) rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')]) unit_list = tables.Column(orderable=False, verbose_name='Units') actions = tables.TemplateColumn( - template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): @@ -327,7 +327,7 @@ class ManufacturerTable(BaseTable): devicetype_count = tables.Column(verbose_name='Device Types') platform_count = tables.Column(verbose_name='Platforms') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right'}}, + actions = tables.TemplateColumn(template_code=MANUFACTURER_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): @@ -463,7 +463,7 @@ class DeviceRoleTable(BaseTable): slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( template_code=DEVICEROLE_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -492,7 +492,7 @@ class PlatformTable(BaseTable): ) actions = tables.TemplateColumn( template_code=PLATFORM_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) @@ -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' ) @@ -779,7 +779,7 @@ class VirtualChassisTable(BaseTable): member_count = tables.Column(verbose_name='Members') actions = tables.TemplateColumn( template_code=VIRTUALCHASSIS_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 21d620af1..07cf4b010 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -25,6 +25,7 @@ urlpatterns = [ url(r'^sites/add/$', views.SiteCreateView.as_view(), name='site_add'), url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'), url(r'^sites/edit/$', views.SiteBulkEditView.as_view(), name='site_bulk_edit'), + url(r'^sites/delete/$', views.SiteBulkDeleteView.as_view(), name='site_bulk_delete'), url(r'^sites/(?P[\w-]+)/$', views.SiteView.as_view(), name='site'), url(r'^sites/(?P[\w-]+)/edit/$', views.SiteEditView.as_view(), name='site_edit'), url(r'^sites/(?P[\w-]+)/delete/$', views.SiteDeleteView.as_view(), name='site_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index dfe94625e..85d37b29f 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1,5 +1,6 @@ import re +from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.paginator import EmptyPage, PageNotAnInteger @@ -246,6 +247,14 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): default_return_url = 'dcim:site_list' +class SiteBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_site' + queryset = Site.objects.select_related('region', 'tenant') + filter = filters.SiteFilter + table = tables.SiteTable + default_return_url = 'dcim:site_list' + + # # Rack groups # @@ -353,8 +362,9 @@ class RackElevationListView(View): total_count = racks.count() # Pagination - paginator = EnhancedPaginator(racks, 25) + per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) page_number = request.GET.get('page', 1) + paginator = EnhancedPaginator(racks, per_page) try: page = paginator.page(page_number) except PageNotAnInteger: 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/apps.py b/netbox/extras/apps.py index 2d4517c26..6e6083691 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -22,6 +22,7 @@ class ExtrasConfig(AppConfig): port=settings.REDIS_PORT, db=settings.REDIS_DATABASE, password=settings.REDIS_PASSWORD or None, + ssl=settings.REDIS_SSL, ) rs.ping() except redis.exceptions.ConnectionError: 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 0c57b3ffb..3a2f216ae 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 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/0017_exporttemplate_mime_type_length.py b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py new file mode 100644 index 000000000..29283e0d1 --- /dev/null +++ b/netbox/extras/migrations/0017_exporttemplate_mime_type_length.py @@ -0,0 +1,18 @@ +# Generated by Django 2.1.7 on 2019-03-05 18:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0016_exporttemplate_add_cable'), + ] + + operations = [ + migrations.AlterField( + model_name='exporttemplate', + name='mime_type', + field=models.CharField(blank=True, max_length=50), + ), + ] 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 d3b9f4eff..4c1f9cb76 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 @@ -105,6 +106,7 @@ class CustomFieldModel(models.Model): class Meta: abstract = True + @property def cf(self): """ Name-based CustomFieldValue accessor for use in templates @@ -355,9 +357,13 @@ 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=15, + max_length=50, blank=True ) file_extension = models.CharField( @@ -374,16 +380,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 +746,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/querysets.py b/netbox/extras/querysets.py index 439323c94..70c93968f 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -1,6 +1,24 @@ +from collections import OrderedDict + from django.db.models import Q, QuerySet +class CustomFieldQueryset: + """ + Annotate custom fields on objects within a QuerySet. + """ + def __init__(self, queryset, custom_fields): + self.queryset = queryset + self.model = queryset.model + self.custom_fields = custom_fields + + def __iter__(self): + for obj in self.queryset: + values_dict = {cfv.field_id: cfv.value for cfv in obj.custom_field_values.all()} + obj.custom_fields = OrderedDict([(field, values_dict.get(field.pk)) for field in self.custom_fields]) + yield obj + + class ConfigContextQuerySet(QuerySet): def get_for_object(self, obj): diff --git a/netbox/extras/tables.py b/netbox/extras/tables.py index 5fab8910f..f6933bf48 100644 --- a/netbox/extras/tables.py +++ b/netbox/extras/tables.py @@ -68,7 +68,7 @@ class TagTable(BaseTable): ) actions = tables.TemplateColumn( template_code=TAG_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) 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/filters.py b/netbox/ipam/filters.py index f7125ceb0..a5464e4d0 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -6,14 +6,14 @@ from netaddr.core import AddrFormatError from dcim.models import Site, Device, Interface from extras.filters import CustomFieldFilterSet -from tenancy.models import Tenant +from tenancy.filtersets import TenancyFilterSet from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from virtualization.models import VirtualMachine from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF -class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VRFFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -22,16 +22,6 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): method='search', label='Search', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) tag = TagFilter() def search(self, queryset, name, value): @@ -59,7 +49,7 @@ class RIRFilter(NameSlugSearchFilterSet): fields = ['name', 'slug', 'is_private'] -class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): +class AggregateFilter(CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -107,7 +97,7 @@ class RoleFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): +class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -146,16 +136,6 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='rd', label='VRF (RD)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -254,7 +234,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): return queryset.filter(prefix__net_mask_length=value) -class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): +class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -285,16 +265,6 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='rd', label='VRF (RD)', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) device = django_filters.CharFilter( method='filter_device', field_name='name', @@ -316,6 +286,12 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='name', label='Virtual machine (name)', ) + interface = django_filters.ModelMultipleChoiceFilter( + field_name='interface__name', + queryset=Interface.objects.all(), + to_field_name='name', + label='Interface (ID)', + ) interface_id = django_filters.ModelMultipleChoiceFilter( queryset=Interface.objects.all(), label='Interface (ID)', @@ -394,7 +370,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet): fields = ['name', 'slug'] -class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): +class VLANFilter(TenancyFilterSet, CustomFieldFilterSet): id__in = NumericInFilter( field_name='id', lookup_expr='in' @@ -423,16 +399,6 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): to_field_name='slug', label='Group', ) - tenant_id = django_filters.ModelMultipleChoiceFilter( - queryset=Tenant.objects.all(), - label='Tenant (ID)', - ) - tenant = django_filters.ModelMultipleChoiceFilter( - field_name='tenant__slug', - queryset=Tenant.objects.all(), - to_field_name='slug', - label='Tenant (slug)', - ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=Role.objects.all(), label='Role (ID)', diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index d0e25f580..d7c0b1a0c 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -6,6 +6,7 @@ from taggit.forms import TagField from dcim.models import Site, Rack, Device, Interface from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.forms import TenancyForm +from tenancy.forms import TenancyFilterForm from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, @@ -97,22 +98,13 @@ class VRFBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm ] -class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VRFFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VRF + field_order = ['q', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) # @@ -349,11 +341,11 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldForm): class PrefixCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField( + vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), - required=False, to_field_name='rd', - help_text='Route distinguisher of parent VRF', + required=False, + help_text='Route distinguisher of parent VRF (or {ID})', error_messages={ 'invalid_choice': 'VRF not found.', } @@ -497,8 +489,12 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF ] -class PrefixFilterForm(BootstrapMixin, CustomFieldFilterForm): +class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Prefix + field_order = [ + 'q', 'within_include', 'family', 'mask_length', 'vrf_id', 'status', 'site', 'role', 'tenant_group', 'tenant', + 'is_pool', 'expand', + ] q = forms.CharField( required=False, label='Search' @@ -524,24 +520,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, - ) - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", null_option=True, ) ) @@ -764,11 +748,11 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldForm): class IPAddressCSVForm(forms.ModelForm): - vrf = forms.ModelChoiceField( + vrf = FlexibleModelChoiceField( queryset=VRF.objects.all(), - required=False, to_field_name='rd', - help_text='Route distinguisher of the assigned VRF', + required=False, + help_text='Route distinguisher of parent VRF (or {ID})', error_messages={ 'invalid_choice': 'VRF not found.', } @@ -946,8 +930,11 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): ) -class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): +class IPAddressFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = IPAddress + field_order = [ + 'q', 'parent', 'family', 'mask_length', 'vrf_id', 'status', 'role', 'tenant_group', 'tenant', + ] q = forms.CharField( required=False, label='Search' @@ -973,24 +960,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, - ) - ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", null_option=True, ) ) @@ -1225,8 +1200,9 @@ class VLANBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor ] -class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): +class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = VLAN + field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' @@ -1250,16 +1226,6 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): null_option=True, ) ) - tenant = FilterChoiceField( - queryset=Tenant.objects.all(), - to_field_name='slug', - null_label='-- None --', - widget=APISelectMultiple( - api_url="/api/tenancy/tenants/", - value_field="slug", - null_option=True, - ) - ) status = forms.MultipleChoiceField( choices=VLAN_STATUS_CHOICES, required=False, diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 181852ad3..a2f7bbe07 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -1,7 +1,7 @@ import netaddr from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation -from django.core.exceptions import ValidationError +from django.core.exceptions import ValidationError, ObjectDoesNotExist from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Q @@ -10,8 +10,9 @@ from django.urls import reverse from taggit.managers import TaggableManager from dcim.models import Interface -from extras.models import CustomFieldModel +from extras.models import CustomFieldModel, ObjectChange from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object from .constants import * from .fields import IPNetworkField, IPAddressField from .querysets import PrefixQuerySet @@ -629,6 +630,27 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): self.family = self.address.version super().save(*args, **kwargs) + def log_change(self, user, request_id, action): + """ + Include the connected Interface (if any). + """ + + # It's possible that an IPAddress can be deleted _after_ its parent Interface, in which case trying to resolve + # the interface will raise DoesNotExist. + try: + parent_obj = self.interface + except ObjectDoesNotExist: + parent_obj = None + + ObjectChange( + user=user, + request_id=request_id, + changed_object=self, + related_object=parent_obj, + action=action, + object_data=serialize_object(self) + ).save() + def to_csv(self): # Determine if this IP is primary for a Device diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 026cbc980..9578b4407 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 %} """ @@ -203,7 +203,7 @@ class RIRTable(BaseTable): name = tables.LinkColumn(verbose_name='Name') is_private = BooleanColumn(verbose_name='Private') aggregate_count = tables.Column(verbose_name='Aggregates') - actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + actions = tables.TemplateColumn(template_code=RIR_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): model = RIR @@ -288,7 +288,7 @@ class RoleTable(BaseTable): orderable=False, verbose_name='VLANs' ) - actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') + actions = tables.TemplateColumn(template_code=ROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): model = Role @@ -319,6 +319,7 @@ class PrefixTable(BaseTable): class PrefixDetailTable(PrefixTable): utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(PrefixTable.Meta): fields = ('pk', 'prefix', 'status', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description') @@ -349,6 +350,7 @@ class IPAddressDetailTable(IPAddressTable): nat_inside = tables.LinkColumn( 'ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)' ) + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(IPAddressTable.Meta): fields = ( @@ -392,7 +394,7 @@ class VLANGroupTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') vlan_count = tables.Column(verbose_name='VLANs') slug = tables.Column(verbose_name='Slug') - actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right'}}, + actions = tables.TemplateColumn(template_code=VLANGROUP_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='') class Meta(BaseTable.Meta): @@ -423,6 +425,7 @@ class VLANTable(BaseTable): class VLANDetailTable(VLANTable): prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') + tenant = tables.TemplateColumn(template_code=COL_TENANT) class Meta(VLANTable.Meta): fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') @@ -437,7 +440,7 @@ class VLANMemberTable(BaseTable): ) actions = tables.TemplateColumn( template_code=VLAN_MEMBER_ACTIONS, - attrs={'td': {'class': 'text-right'}}, + attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) 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/configuration.example.py b/netbox/netbox/configuration.example.py index d7a9cf2ed..145ebf0e6 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -132,6 +132,7 @@ REDIS = { 'PASSWORD': '', 'DATABASE': 0, 'DEFAULT_TIMEOUT': 300, + 'SSL': False, } # The file path where custom reports will be stored. A trailing slash is not needed. Note that the default value of diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index a85a5d78e..1708c07f0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ except ImportError: ) -VERSION = '2.5.8-dev' +VERSION = '2.5.13-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -131,6 +131,7 @@ REDIS_PORT = REDIS.get('PORT', 6379) REDIS_PASSWORD = REDIS.get('PASSWORD', '') REDIS_DATABASE = REDIS.get('DATABASE', 0) REDIS_DEFAULT_TIMEOUT = REDIS.get('DEFAULT_TIMEOUT', 300) +REDIS_SSL = REDIS.get('SSL', False) # Email EMAIL_HOST = EMAIL.get('SERVER') @@ -197,7 +198,7 @@ ROOT_URLCONF = 'netbox.urls' TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [BASE_DIR + '/templates/'], + 'DIRS': [BASE_DIR + '/templates'], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -223,7 +224,7 @@ USE_I18N = True USE_TZ = True # Static files (CSS, JavaScript, Images) -STATIC_ROOT = BASE_DIR + '/static/' +STATIC_ROOT = BASE_DIR + '/static' STATIC_URL = '/{}static/'.format(BASE_PATH) STATICFILES_DIRS = ( os.path.join(BASE_DIR, "project-static"), @@ -291,6 +292,7 @@ RQ_QUEUES = { 'DB': REDIS_DATABASE, 'PASSWORD': REDIS_PASSWORD, 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, + 'SSL': REDIS_SSL, } } @@ -315,6 +317,7 @@ SWAGGER_SETTINGS = { 'utilities.custom_inspectors.IdInFilterInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ], + 'DEFAULT_MODEL_DEPTH': 1, 'DEFAULT_PAGINATOR_INSPECTORS': [ 'utilities.custom_inspectors.NullablePaginatorInspector', 'drf_yasg.inspectors.DjangoRestResponsePagination', diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index ad618b5d1..26ca50220 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -49,6 +49,19 @@ footer p { } } +/* Printer friendly CSS class and various fixes for printing. */ +@media print { + body { + padding-top: 0px; + } + a[href]:after { + content: none !important; + } + .noprint { + display: none !important; + } +} + /* Collapse the nav menu on displays less than 960px wide */ @media (max-width: 959px) { .navbar-header { @@ -575,4 +588,4 @@ td .progress { } textarea { font-family: Consolas, Lucida Console, monospace; -} \ No newline at end of file +} diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 438882805..96d59ace5 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -90,6 +90,10 @@ $(document).ready(function() { // Assign color picker selection classes function colorPickerClassCopy(data, container) { if (data.element) { + // Remove any existing color-selection classes + $(container).attr('class', function(i, c) { + return c.replace(/(^|\s)color-selection-\S+/g, ''); + }); $(container).addClass($(data.element).attr("class")); } return data.text; @@ -151,10 +155,14 @@ $(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_visible && is_nullable) { + parameters[param_name] = "null"; } }); @@ -243,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 39d260a6d..1f937f54b 100644 --- a/netbox/secrets/tables.py +++ b/netbox/secrets/tables.py @@ -8,7 +8,7 @@ SECRETROLE_ACTIONS = """ {% if perms.secrets.change_secretrole %} - + {% endif %} """ @@ -23,7 +23,7 @@ class SecretRoleTable(BaseTable): secret_count = tables.Column(verbose_name='Secrets') slug = tables.Column(verbose_name='Slug') actions = tables.TemplateColumn( - template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='' + template_code=SECRETROLE_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name='' ) class Meta(BaseTable.Meta): 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/_base.html b/netbox/templates/_base.html index 9101e08f7..02b6bb32c 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -54,7 +54,7 @@ {% now 'Y-m-d H:i:s T' %} - + Docs · API · diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index edbab3ed4..890b2a880 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -4,7 +4,7 @@ {% block title %}{{ circuit }}{% endblock %} {% block header %} - + Circuits @@ -25,7 +25,7 @@ - + {% if perms.circuits.change_circuit %} diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html index 81e09c32b..d686bdf7a 100644 --- a/netbox/templates/circuits/circuit_list.html +++ b/netbox/templates/circuits/circuit_list.html @@ -2,7 +2,7 @@ {% load buttons %} {% block content %} - + {% if perms.circuits.add_circuit %} {% add_button 'circuits:circuit_add' %} {% import_button 'circuits:circuit_import' %} @@ -14,7 +14,7 @@ {% include 'utilities/obj_table.html' with bulk_edit_url='circuits:circuit_bulk_edit' bulk_delete_url='circuits:circuit_bulk_delete' %} - + {% include 'inc/search_panel.html' %} {% include 'inc/tags_panel.html' %} diff --git a/netbox/templates/circuits/circuittype_list.html b/netbox/templates/circuits/circuittype_list.html index 2b9469042..654d4ab09 100644 --- a/netbox/templates/circuits/circuittype_list.html +++ b/netbox/templates/circuits/circuittype_list.html @@ -2,7 +2,7 @@ {% load buttons %} {% block content %} - + {% if perms.circuits.add_circuittype %} {% add_button 'circuits:circuittype_add' %} {% import_button 'circuits:circuittype_import' %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index a31f093c9..3dd5d973f 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -5,7 +5,7 @@ {% block title %}{{ provider }}{% endblock %} {% block header %} - + Providers @@ -25,7 +25,7 @@ - + {% if show_graphs %} @@ -172,7 +172,7 @@ {% endfor %} {% if perms.circuits.add_circuit %} -
{% now 'Y-m-d H:i:s T' %}
Docs · API · diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index edbab3ed4..890b2a880 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -4,7 +4,7 @@ {% block title %}{{ circuit }}{% endblock %} {% block header %} -