ip phone and partition initial

This commit is contained in:
patrickpritchett 2019-10-07 13:59:16 -06:00
parent 63ad93afd0
commit ee6bf3c2d6
39 changed files with 1833 additions and 3 deletions

View File

@ -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'],
]
],
]

View File

3
netbox/ipphone/admin.py Normal file
View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -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']

View File

@ -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',
]

View File

@ -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

View File

@ -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

5
netbox/ipphone/apps.py Normal file
View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class IpphoneConfig(AppConfig):
name = 'ipphone'

View File

@ -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',
}

96
netbox/ipphone/filters.py Normal file
View File

@ -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']

314
netbox/ipphone/forms.py Normal file
View File

@ -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()
)

View File

@ -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'],
},
),
]

View File

@ -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'],
},
),
]

View File

@ -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'),
),
]

View File

185
netbox/ipphone/models.py Normal file
View File

@ -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

108
netbox/ipphone/tables.py Normal file
View File

@ -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 %}
<a href="{{ record.ipphonepartition.get_absolute_url }}">{{ record.ipphonepartition }}</a>
{% else %}
Global
{% endif %}
"""
PHONE_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.phone_number }}</a>
{% elif perms.ipphone.add_phone %}
<a href="{% url 'ipphone:phone_add' %}?phone_number={{ record.1 }}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} IP{{ record.0|pluralize }} available</a>
{% else %}
{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Many{% endif %} PN{{ record.0|pluralize }} available
{% endif %}
"""
PHONE_ASSIGN_LINK = """
<a href="{% url 'ipphone:phone_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
"""
PHONE_PARENT = """
{% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
{% else %}
&mdash;
{% endif %}
"""
STATUS_LABEL = """
{% if record.pk %}
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
{% else %}
<span class="label label-success">Available</span>
{% 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')

3
netbox/ipphone/tests.py Normal file
View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

34
netbox/ipphone/urls.py Normal file
View File

@ -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/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='phone_changelog', kwargs={'model': Phone}),
path(r'phones/assign/', views.PhoneAssignView.as_view(), name='phone_assign'),
path(r'phones/<int:pk>/', views.PhoneView.as_view(), name='phone'),
path(r'phones/<int:pk>/edit/', views.PhoneEditView.as_view(), name='phone_edit'),
path(r'phones/<int:pk>/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/<int:pk>/', views.IPPhonePartitionView.as_view(), name='ipphonepartitions'),
path(r'ipphonepartitions/<int:pk>/edit/', views.IPPhonePartitionEditView.as_view(), name='ipphonepartition_edit'),
path(r'ipphonepartitions/<int:pk>/delete/', views.IPPhonePartitionDeleteView.as_view(), name='ipphonepartition_delete'),
path(r'ipphonepartitions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='ipphonepartition_changelog', kwargs={'model': IPPhonePartition}),
]

244
netbox/ipphone/views.py Normal file
View File

@ -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'

View File

@ -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'},

View File

@ -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')),

View File

@ -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

View File

@ -553,7 +553,7 @@
<strong>Interfaces</strong>
<div class="pull-right noprint">
<button class="btn btn-default btn-xs toggle-ips" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show IPs / Phone #s
</button>
</div>
</div>
@ -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');

View File

@ -147,6 +147,11 @@
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.ipphone.add_phone %}
<a href="{% url 'ipphone:phone_add' %}?interface={{ iface.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-success" title="Add Phone Number">
<i class="glyphicon glyphicon-earphone" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.dcim.change_interface %}
{% if iface.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=iface.cable %}
@ -259,3 +264,69 @@
</tr>
{% endif %}
{% endwith %}
{% with phones=iface.phone.all %}
{% if phones %}
<tr class="phones">
{# Placeholder #}
{% if perms.dcim.change_interface or perms.dcim.delete_interface %}
<td></td>
{% endif %}
{# Phone Numbers table #}
<td colspan="9" style="padding: 0">
<table class="table table-condensed interface-ips">
<thead>
<tr class="text-muted">
<th class="col-md-3">Phone</th>
<th class="col-md-2">Status</th>
<th class="col-md-3"></th>
<th class="col-md-3">Description</th>
<th class="col-md-1"></th>
</tr>
</thead>
{% for ph in iface.phone.all %}
<tr>
{# Phone Number #}
<td>
<a href="{% url 'ipphone:phone' pk=ph.pk %}">{{ ph }}</a>
</td>
{# status #}
<td>
<span class="label label-{{ ph.get_status_class }}">{{ ph.get_status_display }}</span>
</td>
<td></td>
{# Description #}
<td>
{% if ph.description %}
{{ ph.description }}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
{# Buttons #}
<td class="text-right text-nowrap noprint">
{% if perms.ipphone.change_phone %}
<a href="{% url 'ipphone:phone_edit' pk=ph.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-info btn-xs">
<i class="glyphicon glyphicon-pencil" aria-hidden="true" title="Edit Phone Number"></i>
</a>
{% endif %}
{% if perms.ipphone.delete_phone %}
<a href="{% url 'ipphone:phone_delete' pk=ph.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<i class="glyphicon glyphicon-trash" aria-hidden="true" title="Delete Phone Number"></i>
</a>
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</td>
</tr>
{% endif %}
{% endwith %}

View File

@ -203,6 +203,33 @@
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>IP Phone</strong>
</div>
<div class="list-group">
<div class="list-group-item">
{% if perms.ipphone.view_phones %}
<span class="badge pull-right">{{ stats.phone_number_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'ipphone:phone_list' %}">Phone Numbers</a></h4>
{% else %}
<span class="badge pull-right"><i class="fa fa-lock"></i></span>
<h4 class="list-group-item-heading">Phone Numbers</h4>
{% endif %}
<p class="list-group-item-text text-muted">Individual Phone Numbers</p>
</div>
<div class="list-group-item">
{% if perms.ipphone.view_ipphonepartition %}
<span class="badge pull-right">{{ stats.ipphonepartition_count }}</span>
<h4 class="list-group-item-heading"><a href="{% url 'ipphone:ipphonepartition_list' %}">IP Phone Partitions</a></h4>
{% else %}
<span class="badge pull-right"><i class="fa fa-lock"></i></span>
<h4 class="list-group-item-heading">IP Phone Partitions</h4>
{% endif %}
<p class="list-group-item-text text-muted">IP Phone Partitions</p>
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Circuits</strong>

View File

@ -292,6 +292,31 @@
</li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/ipphone/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">IP Phone <span class="caret"></span></a>
<ul class="dropdown-menu">
<li class="dropdown-header">IP Phone</li>
<li{% if not perms.ipphone.view_phone %} class="disabled"{% endif %}>
{% if perms.ipphone.add_phone %}
<div class="buttons pull-right">
<a href="{% url 'ipphone:phone_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'ipphone:phone_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'ipphone:phone_list' %}">Phone Numbers</a>
</li>
<li{% if not perms.ipphone.view_ipphonepartition %} class="disabled"{% endif %}>
{% if perms.ipphone.add_ipphonepartition %}
<div class="buttons pull-right">
<a href="{% url 'ipphone:ipphonepartition_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
<a href="{% url 'ipphone:ipphonepartition_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
</div>
{% endif %}
<a href="{% url 'ipphone:ipphonepartition_list' %}">IP Phone Partitions</a>
</li>
<li class="divider"></li>
</ul>
</li>
<li class="dropdown{% if request.path|contains:'/virtualization/' %} active{% endif %}">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Virtualization <span class="caret"></span></a>
<ul class="dropdown-menu">

View File

@ -9,7 +9,7 @@
<form action="." method="get" class="form">
{% for field in filter_form %}
<div class="form-group">
{% if field.name == "q" %}
{% if field.name == "q" or field.name == "phone_number" %}
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search" {% if request.GET.q %}value="{{ request.GET.q }}" {% endif %}/>
<span class="input-group-btn">

View File

@ -0,0 +1,16 @@
{% load helpers %}
<ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
<a href="{% url 'ipphone:phone_add' %}{% querystring request %}">New Number</a>
</li>
{% if 'interface' in request.GET %}
<li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
<a href="{% url 'ipphone:phone_assign' %}{% querystring request %}">Assign Number</a>
</li>
{% else %}
<li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}>
<a href="{% url 'ipphone:phone_bulk_add' %}{% querystring request %}">Bulk Create</a>
</li>
{% endif %}
</ul>

View File

@ -0,0 +1,87 @@
{% extends '_base.html' %}
{% load custom_links %}
{% load helpers %}
{% block header %}
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipphone:ipphonepartition_list' %}">IP Phone Partitions</a></li>
<li>{{ ipphonepartition }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipphone:ipphonepartition_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search IP Phone Partitions" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right noprint">
{% if perms.ipphone.change_ipphonepartition %}
<a href="{% url 'ipphone:ipphonepartition_edit' pk=ipphonepartition.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this IP Phone Partition
</a>
{% endif %}
{% if perms.ipphone.delete_ipphonepartition %}
<a href="{% url 'ipphone:ipphonepartition_delete' pk=ipphonepartition.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this IP Phone Partition
</a>
{% endif %}
</div>
<h1>{% block title %}IP Phone Partition {{ ipphonepartition }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=ipphonepartition %}
<div class="pull-right noprint">
{% custom_links ipphonepartition %}
</div>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ ipphonepartition.get_absolute_url }}">IP Phone Partition</a>
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipphone:ipphonepartition_changelog' pk=ipphonepartition.pk %}">Changelog</a>
</li>
{% endif %}
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-6">
<div class="panel panel-default">
<div class="panel-heading">
<strong>IP Phone Partition</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Unique Extension Space</td>
<td>
{% if ipphonepartition.enforce_unique %}
<i class="glyphicon glyphicon-ok text-success" title="Yes"></i>
{% else %}
<i class="glyphicon glyphicon-remove text-danger" title="No"></i>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ ipphonepartition.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'extras/inc/tags_panel.html' with tags=ipphonepartition.tags.all url='ipphone:ipphonepartition_list' %}
</div>
<div class="col-md-6">
{% include 'inc/custom_fields_panel.html' with obj=ipphonepartition %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,27 @@
{% extends 'utilities/obj_edit.html' %}
{% load form_helpers %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>IP Partition</strong></div>
<div class="panel-body">
{% render_field form.name %}
{% render_field form.enforce_unique %}
{% render_field form.description %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipphone.add_ipphonepartition %}
{% add_button 'ipphone:ipphonepartition_add' %}
{% import_button 'ipphone:ipphonepartition_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Ip Phone Partitions{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipphone:ipphonepartition_bulk_edit' bulk_delete_url='ipphone:ipphonepartition_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,101 @@
{% extends '_base.html' %}
{% load custom_links %}
{% load helpers %}
{% block header %}
<div class="row noprint">
<div class="col-sm-8 col-md-9">
<ol class="breadcrumb">
<li><a href="{% url 'ipphone:phone_list' %}">Phone Numbers</a></li>
<li>{{ phone }}</li>
</ol>
</div>
<div class="col-sm-4 col-md-3">
<form action="{% url 'ipphone:phone_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" class="form-control" placeholder="Search Numbers" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
</button>
</span>
</div>
</form>
</div>
</div>
<div class="pull-right noprint">
{% if perms.ipphone.change_phone %}
<a href="{% url 'ipphone:phone_edit' pk=phone.pk %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span>
Edit this Number
</a>
{% endif %}
{% if perms.ipphone.delete_phone %}
<a href="{% url 'ipphone:phone_delete' pk=phone.pk %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span>
Delete this Number
</a>
{% endif %}
</div>
<h1>{% block title %}{{ phone }}{% endblock %}</h1>
{% include 'inc/created_updated.html' with obj=phone %}
<div class="pull-right noprint">
{% custom_links phone %}
</div>
<ul class="nav nav-tabs">
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{{ phone.get_absolute_url }}">Phone Number</a>
</li>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
<a href="{% url 'ipphone:phone_changelog' pk=phone.pk %}">Changelog</a>
</li>
{% endif %}
</ul>
{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-4">
<div class="panel panel-default">
<div class="panel-heading">
<strong>Phone Number</strong>
</div>
<table class="table table-hover panel-body attr-table">
<tr>
<td>Status</td>
<td>
<span class="label label-{{ phone.get_status_class }}">{{ phone.get_status_display }}</span>
</td>
</tr>
<tr>
<td>Partition</td>
<td>
{% if phone.ipphonepartition %}
<span><a href="{{ phone.ipphonepartition.parent.get_absolute_url }}">{{ phone.ipphonepartition.parent }}</a> ({{ phone.ipphonepartition }})</span>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr>
<td>Description</td>
<td>{{ phone.description|placeholder }}</td>
</tr>
<tr>
<td>Assignment</td>
<td>
{% if phone.interface %}
<span><a href="{{ phone.interface.parent.get_absolute_url }}">{{ phone.interface.parent }}</a> ({{ phone.interface }})</span>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/custom_fields_panel.html' with obj=phone %}
{% include 'extras/inc/tags_panel.html' with tags=phone.tags.all url='ipphone:phone_list' %}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,47 @@
{% extends 'utilities/obj_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% block content %}
<form action="{% querystring request %}" method="post" class="form form-horizontal">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h3>Assign a Phone Number</h3>
{% include 'ipphone/inc/phone_edit_header.html' with active_tab='assign' %}
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>
<div class="panel-body">
{{ form.non_field_errors }}
</div>
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Select Phone Number</strong></div>
<div class="panel-body">
{% render_field form.phone_number %}
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 col-md-offset-3 text-right">
<button type="submit" class="btn btn-primary">Search</button>
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
</div>
</div>
</form>
{% if table %}
<div class="row">
<div class="col-md-12" style="margin-top: 20px">
<h3>Search Results</h3>
{% include 'utilities/obj_table.html' with table_template='panel_table.html' %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,36 @@
{% extends 'utilities/obj_edit.html' %}
{% load static %}
{% load form_helpers %}
{% block title %}Bulk Add Phone Numbers{% endblock %}
{% block tabs %}
{% include 'ipphone/inc/phone_edit_header.html' with active_tab='bulk_add' %}
{% endblock %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Phone Numbers</strong></div>
<div class="panel-body">
{% render_field form.pattern %}
{% render_field model_form.status %}
{% render_field model_form.ipphonepartition %}
{% render_field model_form.description %}
</div>
</div>
<!-- <div class="panel panel-default">
<div class="panel-heading"><strong>Tenancy</strong></div>
<div class="panel-body">
{% render_field model_form.tenant_group %}
{% render_field model_form.tenant %}
</div>
</div> -->
{% if model_form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields model_form %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,64 @@
{% extends 'utilities/obj_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load helpers %}
{% block tabs %}
{% if not obj.pk %}
{% include 'ipphone/inc/phone_edit_header.html' with active_tab='add' %}
{% endif %}
{% endblock %}
{% block form %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Phone Number</strong></div>
<div class="panel-body">
{% render_field form.phone_number %}
{% render_field form.ipphonepartition %}
{% render_field form.status %}
{% render_field form.description %}
</div>
</div>
<!-- <div class="panel panel-default">
<div class="panel-heading"><strong>Site</strong></div>
<div class="panel-body">
{% render_field form.site %}
</div>
</div> -->
<!-- {% if obj.interface %}
<div class="panel panel-default">
<div class="panel-heading">
<strong>Interface Assignment</strong>
</div>
<div class="panel-body">
<div class="form-group">
<label class="col-md-3 control-label">{{ obj.interface.parent|model_name|bettertitle }}</label>
<div class="col-md-9">
<p class="form-control-static">
<a href="{{ obj.interface.parent.get_absolute_url }}">{{ obj.interface.parent }}</a>
</p>
</div>
</div>
{% render_field form.interface %}
</div>
</div>
{% endif %} -->
<div class="panel panel-default">
<div class="panel-heading"><strong>Tags</strong></div>
<div class="panel-body">
{% render_field form.tags %}
</div>
</div>
{% if form.custom_fields %}
<div class="panel panel-default">
<div class="panel-heading"><strong>Custom Fields</strong></div>
<div class="panel-body">
{% render_custom_fields form %}
</div>
</div>
{% endif %}
{% endblock %}
{% block javascript %}
<script src="{% static 'js/livesearch.js' %}?v{{ settings.VERSION }}"></script>
{% endblock %}

View File

@ -0,0 +1,22 @@
{% extends '_base.html' %}
{% load buttons %}
{% block content %}
<div class="pull-right noprint">
{% if perms.ipphone.add_phone %}
{% add_button 'ipphone:phone_add' %}
{% import_button 'ipphone:phone_import' %}
{% endif %}
{% export_button content_type %}
</div>
<h1>{% block title %}Phone Numbers{% endblock %}</h1>
<div class="row">
<div class="col-md-9">
{% include 'utilities/obj_table.html' with bulk_edit_url='ipphone:phone_bulk_edit' bulk_delete_url='ipphone:phone_bulk_delete' %}
</div>
<div class="col-md-3 noprint">
{% include 'inc/search_panel.html' %}
{% include 'inc/tags_panel.html' %}
</div>
</div>
{% endblock %}

View File

@ -677,7 +677,7 @@ class ChainedFieldsMixin(forms.BaseForm):
field.queryset = field.queryset.filter(**filters_dict)
elif not self.is_bound and getattr(self, 'instance', None) and hasattr(self.instance, field_name):
obj = getattr(self.instance, field_name)
if obj is not None:
if obj is not None and obj != '':
field.queryset = field.queryset.filter(pk=obj.pk)
else:
field.queryset = field.queryset.none()

View File

@ -131,6 +131,7 @@ def example_choices(field, arg=3):
Returns a number (default: 3) of example choices for a ChoiceFiled (useful for CSV import forms).
"""
examples = []
print(field)
if hasattr(field, 'queryset'):
choices = [
(obj.pk, getattr(obj, field.to_field_name)) for obj in field.queryset[:arg + 1]