diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 58df29914..55bdf3a9d 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -138,6 +138,7 @@ IFACE_TYPE_SUMMITSTACK512 = 5330 # Other IFACE_TYPE_OTHER = 32767 +IFACE_TYPE_PHONE = 32700 IFACE_TYPE_CHOICES = [ [ @@ -249,6 +250,7 @@ IFACE_TYPE_CHOICES = [ 'Other', [ [IFACE_TYPE_OTHER, 'Other'], + [IFACE_TYPE_PHONE, 'Phone'], ] ], ] diff --git a/netbox/ipphone/__init__.py b/netbox/ipphone/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipphone/admin.py b/netbox/ipphone/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/netbox/ipphone/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/netbox/ipphone/api/nested_serializers.py b/netbox/ipphone/api/nested_serializers.py new file mode 100644 index 000000000..178da88fd --- /dev/null +++ b/netbox/ipphone/api/nested_serializers.py @@ -0,0 +1,35 @@ +from rest_framework import serializers + +from ipphone.models import Phone +from utilities.api import WritableNestedSerializer + +__all__ = [ + 'NestedPhoneSerializer', + # 'NestedVRFSerializer', +] + + +# +# VRFs +# + +# class NestedVRFSerializer(WritableNestedSerializer): +# url = serializers.HyperlinkedIdentityField(view_name='ipphone-api:vrf-detail') +# prefix_count = serializers.IntegerField(read_only=True) + +# class Meta: +# model = VRF +# fields = ['id', 'url', 'name', 'rd', 'prefix_count'] + + +# +# IP addresses +# + + +class NestedPhoneSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='ipphone-api:phone-detail') + + class Meta: + model = Phone + fields = ['id', 'ipphonepartition', 'phone_number'] diff --git a/netbox/ipphone/api/serializers.py b/netbox/ipphone/api/serializers.py new file mode 100644 index 000000000..79c733c13 --- /dev/null +++ b/netbox/ipphone/api/serializers.py @@ -0,0 +1,62 @@ +from collections import OrderedDict + +from rest_framework import serializers +from rest_framework.reverse import reverse +from rest_framework.validators import UniqueTogetherValidator +from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField + +from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer +from dcim.models import Interface +from extras.api.customfields import CustomFieldModelSerializer +from ipphone.constants import * +from ipphone.models import Phone, IPPhonePartition +from utilities.api import ( + ChoiceField, SerializedPKRelatedField, ValidatedModelSerializer, WritableNestedSerializer, +) +from virtualization.api.nested_serializers import NestedVirtualMachineSerializer +from .nested_serializers import * + +# +# IPPhonePartitions +# + +class IPPhonePartitionSerializer(TaggitSerializer, CustomFieldModelSerializer): + tags = TagListSerializerField(required=False) + + class Meta: + model = IPPhonePartition + fields = [ + 'id', 'name', 'enforce_unique', 'description', 'tags', 'custom_fields', + ] + + +# +# Phone Numbers +# + +class PhoneInterfaceSerializer(WritableNestedSerializer): + url = serializers.SerializerMethodField() + device = NestedDeviceSerializer(read_only=True) + + class Meta: + model = Interface + fields = [ + 'id', 'url', 'device', 'name', + ] + + def get_url(self, obj): + url_name = 'dcim-api:interface-detail' + return reverse(url_name, kwargs={'pk': obj.pk}, request=self.context['request']) + + +class PhoneSerializer(TaggitSerializer, CustomFieldModelSerializer): + status = ChoiceField(choices=PHONE_STATUS_CHOICES, required=False) + interface = PhoneInterfaceSerializer(required=False, allow_null=True) + tags = TagListSerializerField(required=False) + + class Meta: + model = Phone + fields = [ + 'id', 'ipphonepartition', 'phone_number', 'status', 'interface', 'description', 'tags', 'custom_fields', 'created', 'last_updated', + ] + diff --git a/netbox/ipphone/api/urls.py b/netbox/ipphone/api/urls.py new file mode 100644 index 000000000..fc8d89852 --- /dev/null +++ b/netbox/ipphone/api/urls.py @@ -0,0 +1,27 @@ +from rest_framework import routers + +from . import views + + +class IPPHONERootView(routers.APIRootView): + """ + IPPHONE API root view + """ + def get_view_name(self): + return 'IPPHONE' + + +router = routers.DefaultRouter() +router.APIRootView = IPPHONERootView + +# Field choices +router.register(r'_choices', views.IPPHONEFieldChoicesViewSet, basename='field-choice') + +# IPPhonePartitions +router.register(r'ipphonepartitions', views.IPPhonePartitionViewSet) + +# IP addresses +router.register(r'phone_numbers', views.PhoneViewSet) + +app_name = 'ipphone-api' +urlpatterns = router.urls diff --git a/netbox/ipphone/api/views.py b/netbox/ipphone/api/views.py new file mode 100644 index 000000000..0b2d9f47b --- /dev/null +++ b/netbox/ipphone/api/views.py @@ -0,0 +1,45 @@ +from django.conf import settings +from django.db.models import Count +from django.shortcuts import get_object_or_404 +from rest_framework import status +from rest_framework.decorators import action +from rest_framework.exceptions import PermissionDenied +from rest_framework.response import Response + +from extras.api.views import CustomFieldModelViewSet +from ipphone import filters +from ipphone.models import Phone, IPPhonePartition +from utilities.api import FieldChoicesViewSet, ModelViewSet +from utilities.utils import get_subquery +from . import serializers + +# +# Field choices +# + +class IPPHONEFieldChoicesViewSet(FieldChoicesViewSet): + fields = ( + (Phone, ['phone_number'], ['ipphonepartition']), + (IPPhonePartition, ['name'], ['enforce_unique']), + ) + + +# +# Phone Numbers +# + +class PhoneViewSet(CustomFieldModelViewSet): + queryset = Phone.objects.prefetch_related( + 'interface__device__device_type', 'tags' + ) + serializer_class = serializers.PhoneSerializer + filterset_class = filters.PhoneFilter + +# +# IPPhonePartitions +# + +class IPPhonePartitionViewSet(CustomFieldModelViewSet): + queryset = IPPhonePartition.objects.all() + serializer_class = serializers.IPPhonePartitionSerializer + filterset_class = filters.IPPhonePartitionFilter \ No newline at end of file diff --git a/netbox/ipphone/apps.py b/netbox/ipphone/apps.py new file mode 100644 index 000000000..b15e00562 --- /dev/null +++ b/netbox/ipphone/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class IpphoneConfig(AppConfig): + name = 'ipphone' diff --git a/netbox/ipphone/constants.py b/netbox/ipphone/constants.py new file mode 100644 index 000000000..c2c3046e8 --- /dev/null +++ b/netbox/ipphone/constants.py @@ -0,0 +1,18 @@ + +# PHONE statuses +PHONE_STATUS_ACTIVE = 1 +PHONE_STATUS_INACTIVE = 2 +PHONE_STATUS_CHOICES = ( + (PHONE_STATUS_ACTIVE, 'Active'), + (PHONE_STATUS_INACTIVE, 'Inactive') +) + +# Bootstrap CSS classes +STATUS_CHOICE_CLASSES = { + 0: 'default', + 1: 'primary', + 2: 'info', + 3: 'danger', + 4: 'warning', + 5: 'success', +} \ No newline at end of file diff --git a/netbox/ipphone/filters.py b/netbox/ipphone/filters.py new file mode 100644 index 000000000..65f690ac8 --- /dev/null +++ b/netbox/ipphone/filters.py @@ -0,0 +1,96 @@ +import django_filters +import netaddr +from django.core.exceptions import ValidationError +from django.db.models import Q +from netaddr.core import AddrFormatError + +from dcim.models import Site, Device, Interface +from extras.filters import CustomFieldFilterSet +from tenancy.filtersets import TenancyFilterSet +from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter +from .constants import PHONE_STATUS_CHOICES +from .models import Phone, IPPhonePartition + +class PhoneFilter(CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + parent = django_filters.CharFilter( + method='search_by_parent', + label='Parent prefix', + ) + phone_number = django_filters.CharFilter( + method='filter_phone_number', + label='PhoneNumber', + ) + ipphonepartition_id = django_filters.ModelMultipleChoiceFilter( + queryset=IPPhonePartition.objects.all(), + label='IPPhonePartition', + ) + 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)', + ) + status = django_filters.MultipleChoiceFilter( + choices=PHONE_STATUS_CHOICES, + null_value=None + ) + tag = TagFilter() + + class Meta: + model = Phone + fields = ['phone_number', 'ipphonepartition'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(description__icontains=value) | + Q(phone_number__istartswith=value) + ) + return queryset.filter(qs_filter) + + def search_by_parent(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + try: + query = str(value.strip()) + return queryset.filter(phone_number_contained=query) + except (AddrFormatError, ValueError): + return queryset.none() + + +class IPPhonePartitionFilter(TenancyFilterSet, CustomFieldFilterSet): + id__in = NumericInFilter( + field_name='id', + lookup_expr='in' + ) + q = django_filters.CharFilter( + method='search', + label='Search', + ) + tag = TagFilter() + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(name__icontains=value) | + Q(description__icontains=value) + ) + + class Meta: + model = IPPhonePartition + fields = ['name', 'enforce_unique'] \ No newline at end of file diff --git a/netbox/ipphone/forms.py b/netbox/ipphone/forms.py new file mode 100644 index 000000000..6baf89da1 --- /dev/null +++ b/netbox/ipphone/forms.py @@ -0,0 +1,314 @@ +from django import forms +from django.core.exceptions import MultipleObjectsReturned +from django.core.validators import MaxValueValidator, MinValueValidator +from taggit.forms import TagField + +from dcim.models import Site, Rack, Device, Interface +from extras.forms import AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm +from utilities.forms import ( + add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField, + CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, ReturnURLForm, SlugField, + StaticSelect2, StaticSelect2Multiple, BOOLEAN_WITH_BLANK_CHOICES +) +from virtualization.models import VirtualMachine +from .constants import PHONE_STATUS_CHOICES +from .models import Phone, IPPhonePartition + + +# +# IPPhonePartitions +# + +class IPPhonePartitionForm(BootstrapMixin, CustomFieldForm): + tags = TagField( + required=False + ) + + class Meta: + model = IPPhonePartition + fields = [ + 'name', 'enforce_unique', 'description', 'tags', + ] + + +class IPPhonePartitionCSVForm(forms.ModelForm): + class Meta: + model = IPPhonePartition + fields = IPPhonePartition.csv_headers + help_texts = { + 'name': 'IP Phone Partition name', + } + + +class IPPhonePartitionBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=IPPhonePartition.objects.all(), + widget=forms.MultipleHiddenInput() + ) + enforce_unique = forms.NullBooleanField( + required=False, + widget=BulkEditNullBooleanSelect(), + label='Enforce unique space' + ) + description = forms.CharField( + max_length=100, + required=False + ) + class Meta: + nullable_fields = [ + 'description', + ] + + +class IPPhonePartitionFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = IPPhonePartition + field_order = ['q'] + q = forms.CharField( + required=False, + label='Search' + ) + + +# +# Phone +# + +class PhoneForm(BootstrapMixin, ReturnURLForm, CustomFieldForm): + interface = forms.ModelChoiceField( + queryset=Interface.objects.all(), + required=False + ) + site = forms.ModelChoiceField( + queryset=Site.objects.all(), + required=False, + label='Site', + widget=APISelect( + api_url="/api/dcim/sites/", + filter_for={ + 'device': 'site_id' + } + ) + ) + device = ChainedModelChoiceField( + queryset=Device.objects.all(), + chains=( + ('site', 'site'), + ), + required=False, + label='Device', + widget=APISelect( + api_url='/api/dcim/devices/', + display_field='display_name', + filter_for={ + 'phone_number': 'device_id' + } + ) + ) + # phone_number = ChainedModelChoiceField( + # queryset=Phone.objects.all(), + # chains=( + # ('interface__device', 'device'), + # ), + # required=False, + # label='Phone Number', + # widget=APISelect( + # api_url='/api/ipphone/phones/', + # display_field='phone_number' + # ) + # ) + tags = TagField( + required=False + ) + + class Meta: + model = Phone + fields = [ + 'phone_number', 'ipphonepartition', 'status', 'description', 'interface', 'site', 'tags', + ] + widgets = { + 'status': StaticSelect2() + } + + def __init__(self, *args, **kwargs): + + # Initialize helper selectors + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}).copy() + if instance and instance.phone_number is not None: + initial['phone_number'] = instance.phone_number + else: + initial['phone_number'] = '' + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + + # Limit interface selections to those belonging to the parent device/VM + if self.instance and self.instance.interface: + self.fields['interface'].queryset = Interface.objects.filter( + device=self.instance.interface.device + ) + else: + self.fields['interface'].choices = [] + + def clean(self): + super().clean() + + def save(self, *args, **kwargs): + + phone = super().save(*args, **kwargs) + + # Assign/clear this Phone Number as the primary for the associated Device. + # parent = self.cleaned_data['interface'].parent + phone.save() + + return phone + + +class PhoneBulkCreateForm(BootstrapMixin, forms.Form): + # pattern = ExpandablePhoneField( + # label='Phone pattern' + # ) + pattern = '' + + +class PhoneBulkAddForm(BootstrapMixin, CustomFieldForm): + + class Meta: + model = Phone + fields = [ + 'phone_number', 'ipphonepartition', 'status', 'description' + ] + widgets = { + 'ipphonepartition': APISelect( + api_url="/api/ipphone/ipphonepartitions/" + ) + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + +class PhoneCSVForm(forms.ModelForm): + + status = CSVChoiceField( + choices=PHONE_STATUS_CHOICES, + help_text='Operational status' + ) + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of assigned device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + interface_name = forms.CharField( + help_text='Name of assigned interface', + required=False + ) + ipphonepartition = FlexibleModelChoiceField( + queryset=IPPhonePartition.objects.all(), + to_field_name='name', + required=False, + help_text='Parent IP Phone Partition (or {ID})', + error_messages={ + 'invalid_choice': 'IP Phone Partition not found.', + } + ) + + + class Meta: + model = Phone + fields = Phone.csv_headers + + def clean(self): + super().clean() + + device = self.cleaned_data.get('device') + interface_name = self.cleaned_data.get('interface_name') + + # Validate interface + if interface_name and device: + try: + self.instance.interface = Interface.objects.get(device=device, name=interface_name) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface {} for device {}".format( + interface_name, device + )) + elif interface_name: + raise forms.ValidationError("Interface given ({}) but parent device not specified".format( + interface_name + )) + elif device: + raise forms.ValidationError("Device specified ({}) but interface missing".format(device)) + + def save(self, *args, **kwargs): + + # Set interface + if self.cleaned_data['device'] and self.cleaned_data['interface_name']: + self.instance.interface = Interface.objects.get( + device=self.cleaned_data['device'], + name=self.cleaned_data['interface_name'] + ) + + phone_number = super().save(*args, **kwargs) + phone_number.save() + # parent = self.cleaned_data['device'] + # parent.save() + + return phone_number + + +class PhoneBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=Phone.objects.all(), + widget=forms.MultipleHiddenInput() + ) + status = forms.ChoiceField( + choices=add_blank_choice(PHONE_STATUS_CHOICES), + required=False, + widget=StaticSelect2() + ) + description = forms.CharField( + max_length=100, + required=False + ) + + class Meta: + nullable_fields = [ + 'description', + ] + + +class PhoneAssignForm(BootstrapMixin, forms.Form): + phone_number = forms.CharField( + label='Phone Number' + ) + + +class PhoneFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Phone + field_order = [ + 'q', 'parent', 'ipphonepartition', 'status' + ] + q = forms.CharField( + required=False, + label='Search' + ) + # parent = forms.CharField( + # required=False, + # widget=forms.TextInput( + # attrs={ + # 'placeholder': 'Prefix', + # } + # ), + # label='Parent Prefix' + # ) + status = forms.MultipleChoiceField( + choices=PHONE_STATUS_CHOICES, + required=False, + widget=StaticSelect2Multiple() + ) + diff --git a/netbox/ipphone/migrations/0001_initial.py b/netbox/ipphone/migrations/0001_initial.py new file mode 100644 index 000000000..6377b1bc6 --- /dev/null +++ b/netbox/ipphone/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 2.2.6 on 2019-10-04 17:40 + +from django.db import migrations, models +import django.db.models.deletion +import taggit.managers + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0075_cable_devices'), + ('extras', '0025_objectchange_time_index'), + ] + + operations = [ + migrations.CreateModel( + name='Phone', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('phone_number', models.CharField(max_length=15)), + ('status', models.PositiveSmallIntegerField(default=1)), + ('description', models.CharField(blank=True, max_length=100)), + ('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='phone', to='dcim.Interface')), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'Phone Number', + 'verbose_name_plural': 'Phone Numbers', + 'ordering': ['id', 'phone_number'], + }, + ), + ] diff --git a/netbox/ipphone/migrations/0002_auto_20191004_1636.py b/netbox/ipphone/migrations/0002_auto_20191004_1636.py new file mode 100644 index 000000000..f3ae98249 --- /dev/null +++ b/netbox/ipphone/migrations/0002_auto_20191004_1636.py @@ -0,0 +1,37 @@ +# Generated by Django 2.2.6 on 2019-10-04 22:36 + +from django.db import migrations, models +import taggit.managers + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0025_objectchange_time_index'), + ('ipphone', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='phone', + name='phone_number', + field=models.CharField(max_length=25), + ), + migrations.CreateModel( + name='IPPhonePartition', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('name', models.CharField(max_length=50)), + ('enforce_unique', models.BooleanField(default=True)), + ('description', models.CharField(blank=True, max_length=100)), + ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')), + ], + options={ + 'verbose_name': 'IPPhonePartition', + 'verbose_name_plural': 'IPPhonePartitions', + 'ordering': ['name'], + }, + ), + ] diff --git a/netbox/ipphone/migrations/0003_phone_ipphonepartition.py b/netbox/ipphone/migrations/0003_phone_ipphonepartition.py new file mode 100644 index 000000000..5f6ee6a68 --- /dev/null +++ b/netbox/ipphone/migrations/0003_phone_ipphonepartition.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.6 on 2019-10-07 17:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipphone', '0002_auto_20191004_1636'), + ] + + operations = [ + migrations.AddField( + model_name='phone', + name='ipphonepartition', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='ipphone.IPPhonePartition'), + ), + ] diff --git a/netbox/ipphone/migrations/__init__.py b/netbox/ipphone/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipphone/models.py b/netbox/ipphone/models.py new file mode 100644 index 000000000..25221a21a --- /dev/null +++ b/netbox/ipphone/models.py @@ -0,0 +1,185 @@ +import netaddr +from django.conf import settings +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError, ObjectDoesNotExist +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models +from django.db.models import F, Q +from django.db.models.expressions import RawSQL +from django.urls import reverse +from taggit.managers import TaggableManager + +from dcim.models import Interface +from extras.models import CustomFieldModel, ObjectChange, TaggedItem +from utilities.models import ChangeLoggedModel +from utilities.utils import serialize_object +from .constants import * + + +class PhoneManager(models.Manager): + + def search(self): + return Phone.objects.all() + + +class Phone(ChangeLoggedModel, CustomFieldModel): + + phone_number = models.CharField( + max_length=25, + help_text='Phone number 555-555-5555' + ) + ipphonepartition = models.ForeignKey( + to='ipphone.IPPhonePartition', + on_delete=models.PROTECT, + # related_name='', + blank=True, + null=True, + verbose_name='IP Phone Partition' + ) + status = models.PositiveSmallIntegerField( + choices=PHONE_STATUS_CHOICES, + default=PHONE_STATUS_ACTIVE, + verbose_name='Status', + help_text='The operational status of this Phone Number' + ) + interface = models.ForeignKey( + to='dcim.Interface', + on_delete=models.CASCADE, + related_name='phone', + blank=True, + null=True + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + objects = PhoneManager() + tags = TaggableManager(through=TaggedItem) + + csv_headers = [ + 'phone_number', 'ipphonepartition', 'status', 'device', 'interface_name', 'description', + ] + + class Meta: + ordering = ['id', 'phone_number', 'ipphonepartition'] + verbose_name = 'Phone Number' + verbose_name_plural = 'Phone Numbers' + + def __str__(self): + return str(self.phone_number) + + def get_absolute_url(self): + return reverse('ipphone:phone', args=[self.pk]) + + def get_duplicates(self): + return Phone.objects.filter(phone_number=self.phone_number).exclude(pk=self.pk) + + def clean(self): + + if self.phone_number: + + # Enforce unique Phone (if applicable) + if self.ipphonepartition and self.ipphonepartition.enforce_unique: + duplicate_pns = self.get_duplicates() + if duplicate_pns: + raise ValidationError({ + 'phone_number': "Duplicate Phone Number found in {}: {}".format( + "IP Phone Partition {}".format(self.ipphonepartition) if self.ipphonepartition else "global table", + duplicate_pns.first(), + ) + }) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + def to_objectchange(self, action): + # Annotate the assigned Interface (if any) + try: + parent_obj = self.interface + except ObjectDoesNotExist: + parent_obj = None + + return ObjectChange( + changed_object=self, + object_repr=str(self), + action=action, + related_object=parent_obj, + object_data=serialize_object(self) + ) + + def to_csv(self): + return ( + self.phone_number, + self.get_status_display(), + self.device.identifier if self.device else None, + self.interface.name if self.interface else None, + self.description, + self.ipphonepartition, + ) + + @property + def device(self): + if self.interface: + return self.interface.device + return None + + def get_status_class(self): + return STATUS_CHOICE_CLASSES[self.status] + + +class IPPhonePartitionManager(models.Manager): + + def search(self): + return IPPhonePartition.objects.all() + + +class IPPhonePartition(ChangeLoggedModel, CustomFieldModel): + name = models.CharField( + max_length=50 + ) + enforce_unique = models.BooleanField( + default=True, + verbose_name='Enforce unique space', + help_text='Prevent duplicate extensions within this IP Phone Partition' + ) + description = models.CharField( + max_length=100, + blank=True + ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) + + tags = TaggableManager(through=TaggedItem) + + csv_headers = ['name', 'enforce_unique', 'description'] + + class Meta: + ordering = ['name'] + verbose_name = 'IP Phone Partition' + verbose_name_plural = 'IP Phone Partitions' + + def __str__(self): + return self.display_name or super().__str__() + + def get_absolute_url(self): + return reverse('ipphone:ipphonepartitions', args=[self.pk]) + + def to_csv(self): + return ( + self.name, + self.enforce_unique, + self.description, + ) + + @property + def display_name(self): + return self.name diff --git a/netbox/ipphone/tables.py b/netbox/ipphone/tables.py new file mode 100644 index 000000000..c4da9b162 --- /dev/null +++ b/netbox/ipphone/tables.py @@ -0,0 +1,108 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from dcim.models import Interface +from tenancy.tables import COL_TENANT +from utilities.tables import BaseTable, BooleanColumn, ToggleColumn +from .models import Phone, IPPhonePartition + +IPPHONEPARTITION_LINK = """ +{% if record.ipphonepartition %} + {{ record.ipphonepartition }} +{% else %} + Global +{% endif %} +""" + +PHONE_LINK = """ +{% if record.pk %} + {{ record.phone_number }} +{% elif perms.ipphone.add_phone %} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available +{% else %} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} PN{{ record.0|pluralize }} available +{% endif %} +""" + +PHONE_ASSIGN_LINK = """ +{{ record }} +""" + +PHONE_PARENT = """ +{% if record.interface %} + {{ record.interface.parent }} +{% else %} + — +{% endif %} +""" + +STATUS_LABEL = """ +{% if record.pk %} + {{ record.get_status_display }} +{% else %} + Available +{% endif %} +""" + +# +# Phones +# + +class PhoneTable(BaseTable): + pk = ToggleColumn() + phone_number = tables.TemplateColumn(PHONE_LINK, verbose_name='Phone Number') + ipphonepartition = tables.TemplateColumn(IPPHONEPARTITION_LINK, verbose_name='IP Phone Partition') + status = tables.TemplateColumn(STATUS_LABEL) + parent = tables.TemplateColumn(PHONE_PARENT, orderable=False) + interface = tables.Column(orderable=False) + + class Meta(BaseTable.Meta): + model = Phone + fields = ( + 'pk', 'phone_number', 'ipphonepartition', 'status', 'parent', 'interface', 'description', + ) + + +class PhoneDetailTable(PhoneTable): + class Meta(PhoneTable.Meta): + fields = ( + 'pk', 'phone_number', 'ipphonepartition', 'status', 'parent', 'interface', 'description', + ) + + +class PhoneAssignTable(BaseTable): + phone_number = tables.TemplateColumn(PHONE_ASSIGN_LINK, verbose_name='Phone Number') + status = tables.TemplateColumn(STATUS_LABEL) + parent = tables.TemplateColumn(PHONE_PARENT, orderable=False) + interface = tables.Column(orderable=False) + + class Meta(BaseTable.Meta): + model = Phone + fields = ('phone_number', 'ipphonepartition', 'status', 'parent', 'interface', 'description') + orderable = False + + +class InterfacePhoneTable(BaseTable): + """ + List Phone Number assigned to a specific Interface. + """ + phone_number = tables.TemplateColumn(PHONE_ASSIGN_LINK, verbose_name='Phone Number') + status = tables.TemplateColumn(STATUS_LABEL) + + class Meta(BaseTable.Meta): + model = Phone + fields = ('phone_number', 'ipphonepartition', 'status', 'description') + + +# +# IPPhonePartitions +# + +class IPPhonePartitionTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn() + + class Meta(BaseTable.Meta): + model = IPPhonePartition + fields = ('pk', 'name', 'description', 'enforce_unique') + diff --git a/netbox/ipphone/tests.py b/netbox/ipphone/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/netbox/ipphone/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/ipphone/urls.py b/netbox/ipphone/urls.py new file mode 100644 index 000000000..d84732572 --- /dev/null +++ b/netbox/ipphone/urls.py @@ -0,0 +1,34 @@ +from django.urls import path + +from extras.views import ObjectChangeLogView +from . import views +from .models import Phone, IPPhonePartition + +app_name = 'ipphone' +urlpatterns = [ + + # Phones + path(r'phones/', views.PhoneListView.as_view(), name='phone_list'), + path(r'phones/add/', views.PhoneCreateView.as_view(), name='phone_add'), + path(r'phones/bulk-add/', views.PhoneBulkCreateView.as_view(), name='phone_bulk_add'), + path(r'phones/import/', views.PhoneBulkImportView.as_view(), name='phone_import'), + path(r'phones/edit/', views.PhoneBulkEditView.as_view(), name='phone_bulk_edit'), + path(r'phones/delete/', views.PhoneBulkDeleteView.as_view(), name='phone_bulk_delete'), + path(r'phones//changelog/', ObjectChangeLogView.as_view(), name='phone_changelog', kwargs={'model': Phone}), + path(r'phones/assign/', views.PhoneAssignView.as_view(), name='phone_assign'), + path(r'phones//', views.PhoneView.as_view(), name='phone'), + path(r'phones//edit/', views.PhoneEditView.as_view(), name='phone_edit'), + path(r'phones//delete/', views.PhoneDeleteView.as_view(), name='phone_delete'), + + # IPPhonePartitions + + path(r'ipphonepartitions/', views.IPPhonePartitionListView.as_view(), name='ipphonepartition_list'), + path(r'ipphonepartitions/add/', views.IPPhonePartitionCreateView.as_view(), name='ipphonepartition_add'), + path(r'ipphonepartitions/import/', views.IPPhonePartitionBulkImportView.as_view(), name='ipphonepartition_import'), + path(r'ipphonepartitions/edit/', views.IPPhonePartitionBulkEditView.as_view(), name='ipphonepartition_bulk_edit'), + path(r'ipphonepartitions/delete/', views.IPPhonePartitionBulkDeleteView.as_view(), name='ipphonepartition_bulk_delete'), + path(r'ipphonepartitions//', views.IPPhonePartitionView.as_view(), name='ipphonepartitions'), + path(r'ipphonepartitions//edit/', views.IPPhonePartitionEditView.as_view(), name='ipphonepartition_edit'), + path(r'ipphonepartitions//delete/', views.IPPhonePartitionDeleteView.as_view(), name='ipphonepartition_delete'), + path(r'ipphonepartitions//changelog/', ObjectChangeLogView.as_view(), name='ipphonepartition_changelog', kwargs={'model': IPPhonePartition}), +] diff --git a/netbox/ipphone/views.py b/netbox/ipphone/views.py new file mode 100644 index 000000000..aba1778f0 --- /dev/null +++ b/netbox/ipphone/views.py @@ -0,0 +1,244 @@ +from django.conf import settings +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db.models import Count, Q +from django.shortcuts import get_object_or_404, redirect, render +from django.views.generic import View +from django_tables2 import RequestConfig + +from dcim.models import Device, Interface +from utilities.paginator import EnhancedPaginator +from utilities.views import ( + BulkCreateView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, +) +from virtualization.models import VirtualMachine +from . import filters, forms, tables +from .constants import * +from .models import Phone, IPPhonePartition + + +# +# IPPhonePartitions +# + +class IPPhonePartitionListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipphone.view_ipphonepartition' + queryset = IPPhonePartition.objects.all() # prefetch_related('tenant') + filter = filters.IPPhonePartitionFilter + filter_form = forms.IPPhonePartitionFilterForm + table = tables.IPPhonePartitionTable + template_name = 'ipphone/ipphonepartition_list.html' + + +class IPPhonePartitionView(PermissionRequiredMixin, View): + permission_required = 'ipphone.view_ipphonepartition' + + def get(self, request, pk): + + ipphonepartition = get_object_or_404(IPPhonePartition.objects.all(), pk=pk) + + return render(request, 'ipphone/ipphonepartition.html', { + 'ipphonepartition': ipphonepartition + }) + + +class IPPhonePartitionCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipphone.add_ipphonepartition' + model = IPPhonePartition + model_form = forms.IPPhonePartitionForm + template_name = 'ipphone/ipphonepartition_edit.html' + default_return_url = 'ipphone:ipphonepartition_list' + + +class IPPhonePartitionEditView(IPPhonePartitionCreateView): + permission_required = 'ipphone.change_ipphonepartition' + + +class IPPhonePartitionDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'ipphone.delete_ipphonepartition' + model = IPPhonePartition + default_return_url = 'ipphone:ipphonepartition_list' + + +class IPPhonePartitionBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipphone.add_ipphonepartition' + model_form = forms.IPPhonePartitionCSVForm + table = tables.IPPhonePartitionTable + default_return_url = 'ipphone:ipphonepartition_list' + + +class IPPhonePartitionBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipphone.change_ipphonepartition' + filter = filters.IPPhonePartitionFilter + table = tables.IPPhonePartitionTable + form = forms.IPPhonePartitionBulkEditForm + default_return_url = 'ipphone:ipphonepartition_list' + + +class IPPhonePartitionBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipphone.delete_ipphonepartition' + queryset = IPPhonePartition.objects.all() # prefetch_related('') + filter = filters.IPPhonePartitionFilter + table = tables.IPPhonePartitionTable + default_return_url = 'ipphone:ipphonepartition_list' + + +# +# Phone Numbers +# + +class PhoneListView(PermissionRequiredMixin, ObjectListView): + permission_required = 'ipphone.view_phone_number' + queryset = Phone.objects.prefetch_related( + 'interface__device' + ) + filter = filters.PhoneFilter + filter_form = forms.PhoneForm + table = tables.PhoneDetailTable + template_name = 'ipphone/phone_list.html' + + +class PhoneView(PermissionRequiredMixin, View): + permission_required = 'ipphone.view_phone' + + def get(self, request, pk): + + phone = get_object_or_404(Phone.objects.prefetch_related('interface__device'), pk=pk) + + # # TBD + + # # Duplicate Phone Numbers table + # # duplicate_pns = Phone.objects.filter( + # # phone_number=str(phone.phone_number) + # # ).exclude( + # # pk=phone.pk + # # ).prefetch_related( + # # 'interface__device' + # # ) + # duplicate_pns = [] + + # duplicate_pns_table = tables.PhoneTable(list(duplicate_pns), orderable=False) + + # related_pns = [] + + # # related_pns = Phone.objects.prefetch_related( + # # 'interface__device' + # # ).exclude( + # # phone_number=str(phone.phone_number) + # # ) + + # related_pns_table = tables.PhoneTable(list(related_pns), orderable=False) + + return render(request, 'ipphone/phone.html', { + 'phone': phone, + # 'duplicate_pns_table': duplicate_pns_table, + # 'related_pns_table': related_pns_table, + }) + + +class PhoneCreateView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipphone.add_phone' + model = Phone + model_form = forms.PhoneForm + template_name = 'ipphone/phone_edit.html' + default_return_url = 'ipphone:phone_list' + + def alter_obj(self, obj, request, url_args, url_kwargs): + + interface_id = request.GET.get('interface') + if interface_id: + try: + obj.interface = Interface.objects.get(pk=interface_id) + except (ValueError, Interface.DoesNotExist): + pass + + return obj + + +class PhoneEditView(PhoneCreateView): + permission_required = 'ipphone.change_phone' + + +class PhoneAssignView(PermissionRequiredMixin, View): + """ + Search for Phone Numbers to be assigned to an Interface. + """ + permission_required = 'ipphone.change_phone' + + def dispatch(self, request, *args, **kwargs): + + # Redirect user if an interface has not been provided + if 'interface' not in request.GET: + return redirect('ipphone:phone_add') + + return super().dispatch(request, *args, **kwargs) + + def get(self, request): + + form = forms.PhoneAssignForm() + + return render(request, 'ipphone/phone_assign.html', { + 'form': form, + 'return_url': request.GET.get('return_url', ''), + }) + + def post(self, request): + + form = forms.PhoneAssignForm(request.POST) + table = None + + if form.is_valid(): + + queryset = Phone.objects.prefetch_related( + 'interface__device' + ).filter( + phone_number__istartswith=form.cleaned_data['phone_number'], + )[:100] # Limit to 100 results + table = tables.PhoneAssignTable(queryset) + + return render(request, 'ipphone/phone_assign.html', { + 'form': form, + 'table': table, + 'return_url': request.GET.get('return_url', ''), + }) + + +class PhoneDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'ipphone.delete_phone' + model = Phone + default_return_url = 'ipphone:phone_list' + + +class PhoneBulkCreateView(PermissionRequiredMixin, BulkCreateView): + permission_required = 'ipphone.add_phone' + form = forms.PhoneBulkCreateForm + model_form = forms.PhoneBulkAddForm + pattern_target = 'phone_number' + template_name = 'ipphone/phone_bulk_add.html' + default_return_url = 'ipphone:phone_list' + + +class PhoneBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipphone.add_phone' + # queryset = Phone.objects.all() + model_form = forms.PhoneCSVForm + table = tables.PhoneTable + default_return_url = 'ipphone:phone_list' + + +class PhoneBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipphone.change_phone' + queryset = Phone.objects.prefetch_related('interface__device') + filter = filters.PhoneFilter + table = tables.PhoneTable + form = forms.PhoneBulkEditForm + default_return_url = 'ipphone:phone_list' + + +class PhoneBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipphone.delete_phone' + queryset = Phone.objects.prefetch_related('interface__device') + filter = filters.PhoneFilter + table = tables.PhoneTable + default_return_url = 'ipphone:phone_list' + + diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 0175d89f2..fd5df6372 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -177,6 +177,7 @@ INSTALLED_APPS = [ 'circuits', 'dcim', 'ipam', + 'ipphone', 'extras', 'secrets', 'tenancy', @@ -366,6 +367,7 @@ CACHEOPS = { 'auth.permission': {'ops': 'all'}, 'dcim.*': {'ops': 'all'}, 'ipam.*': {'ops': 'all'}, + 'ipphone.*': {'ops': 'all'}, 'extras.*': {'ops': 'all'}, 'secrets.*': {'ops': 'all'}, 'users.*': {'ops': 'all'}, diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index f39040baf..3119d5aa3 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -36,6 +36,7 @@ _patterns = [ path(r'dcim/', include('dcim.urls')), path(r'extras/', include('extras.urls')), path(r'ipam/', include('ipam.urls')), + path(r'ipphone/', include('ipphone.urls')), path(r'secrets/', include('secrets.urls')), path(r'tenancy/', include('tenancy.urls')), path(r'user/', include('users.urls')), @@ -47,6 +48,7 @@ _patterns = [ path(r'api/dcim/', include('dcim.api.urls')), path(r'api/extras/', include('extras.api.urls')), path(r'api/ipam/', include('ipam.api.urls')), + path(r'api/ipphone/', include('ipphone.api.urls')), path(r'api/secrets/', include('secrets.api.urls')), path(r'api/tenancy/', include('tenancy.api.urls')), path(r'api/virtualization/', include('virtualization.api.urls')), diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py index b26d45db5..5305acaea 100644 --- a/netbox/netbox/views.py +++ b/netbox/netbox/views.py @@ -25,6 +25,9 @@ from extras.models import ObjectChange, ReportResult, TopologyMap from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.tables import AggregateTable, IPAddressTable, PrefixTable, VLANTable, VRFTable +from ipphone.filters import PhoneFilter, IPPhonePartitionFilter +from ipphone.models import Phone, IPPhonePartition +from ipphone.tables import PhoneTable, IPPhonePartitionTable from secrets.filters import SecretFilter from secrets.models import Secret from secrets.tables import SecretTable @@ -204,6 +207,8 @@ class HomeView(View): 'aggregate_count': Aggregate.objects.count(), 'prefix_count': Prefix.objects.count(), 'ipaddress_count': IPAddress.objects.count(), + 'phone_number_count': Phone.objects.count(), + 'ipphonepartition_count': IPPhonePartition.objects.count(), 'vlan_count': VLAN.objects.count(), # Circuits diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 57e2b03b8..3b874707d 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -553,7 +553,7 @@ Interfaces
@@ -905,8 +905,10 @@ $('button.toggle-ips').click(function() { var selected = $(this).attr('selected'); if (selected) { $('#interfaces_table tr.ipaddresses').hide(); + $('#interfaces_table tr.phones').hide(); } else { $('#interfaces_table tr.ipaddresses').show(); + $('#interfaces_table tr.phones').show(); } $(this).attr('selected', !selected); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); diff --git a/netbox/templates/dcim/inc/interface.html b/netbox/templates/dcim/inc/interface.html index 424f487a8..aaa9de937 100644 --- a/netbox/templates/dcim/inc/interface.html +++ b/netbox/templates/dcim/inc/interface.html @@ -147,6 +147,11 @@ {% endif %} + {% if perms.ipphone.add_phone %} + + + + {% endif %} {% if perms.dcim.change_interface %} {% if iface.cable %} {% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %} @@ -259,3 +264,69 @@ {% endif %} {% endwith %} + +{% with phones=iface.phone.all %} + {% if phones %} + + {# Placeholder #} + {% if perms.dcim.change_interface or perms.dcim.delete_interface %} + + {% endif %} + + {# Phone Numbers table #} + + + + + + + + + + + + {% for ph in iface.phone.all %} + + + {# Phone Number #} + + + {# status #} + + + + + {# Description #} + + + {# Buttons #} + + + + {% endfor %} +
PhoneStatusDescription
+ {{ ph }} + + {{ ph.get_status_display }} + + {% if ph.description %} + {{ ph.description }} + {% else %} + + {% endif %} + + {% if perms.ipphone.change_phone %} + + + + {% endif %} + {% if perms.ipphone.delete_phone %} + + + + {% endif %} +
+ + + {% endif %} +{% endwith %} \ No newline at end of file diff --git a/netbox/templates/home.html b/netbox/templates/home.html index 8d483568f..4c119f229 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -203,6 +203,33 @@ +
+
+ IP Phone +
+
+
+ {% if perms.ipphone.view_phones %} + {{ stats.phone_number_count }} +

Phone Numbers

+ {% else %} + +

Phone Numbers

+ {% endif %} +

Individual Phone Numbers

+
+
+ {% if perms.ipphone.view_ipphonepartition %} + {{ stats.ipphonepartition_count }} +

IP Phone Partitions

+ {% else %} + +

IP Phone Partitions

+ {% endif %} +

IP Phone Partitions

+
+
+
Circuits diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 3379c058c..761dca43b 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -292,6 +292,31 @@ +