Merge branch 'develop' into develop-2.3

This commit is contained in:
Jeremy Stretch 2017-11-14 14:38:32 -05:00
commit 3df8c63d5c
18 changed files with 220 additions and 21 deletions

View File

@ -256,12 +256,19 @@ class DeviceViewSet(CustomFieldModelViewSet):
device.platform device.platform
)) ))
# Check that NAPALM is installed and verify the configured driver # Check that NAPALM is installed
try: try:
import napalm import napalm
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
except ImportError: except ImportError:
raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.") raise ServiceUnavailable("NAPALM is not installed. Please see the documentation for instructions.")
# TODO: Remove support for NAPALM < 2.0
try:
from napalm.base.exceptions import ConnectAuthError, ModuleImportError
except ImportError:
from napalm_base.exceptions import ConnectAuthError, ModuleImportError
# Validate the configured driver
try: try:
driver = napalm.get_network_driver(device.platform.napalm_driver) driver = napalm.get_network_driver(device.platform.napalm_driver)
except ModuleImportError: except ModuleImportError:

View File

@ -425,7 +425,8 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Device model (slug)', label='Device model (slug)',
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES choices=STATUS_CHOICES,
null_value=None
) )
is_full_depth = django_filters.BooleanFilter( is_full_depth = django_filters.BooleanFilter(
name='device_type__is_full_depth', name='device_type__is_full_depth',

View File

@ -1117,6 +1117,15 @@ class ConsoleServerPort(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def clean(self):
# Check that the parent device's DeviceType is a console server
device_type = self.device.device_type
if not device_type.is_console_server:
raise ValidationError("The {} {} device type not support assignment of console server ports.".format(
device_type.manufacturer, device_type
))
# #
# Power ports # Power ports
@ -1182,6 +1191,15 @@ class PowerOutlet(models.Model):
def __str__(self): def __str__(self):
return self.name return self.name
def clean(self):
# Check that the parent device's DeviceType is a PDU
device_type = self.device.device_type
if not device_type.is_pdu:
raise ValidationError("The {} {} device type not support assignment of power outlets.".format(
device_type.manufacturer, device_type
))
# #
# Interfaces # Interfaces
@ -1238,6 +1256,13 @@ class Interface(models.Model):
def clean(self): def clean(self):
# Check that the parent device's DeviceType is a network device
device_type = self.device.device_type
if not device_type.is_network_device:
raise ValidationError("The {} {} device type not support assignment of network interfaces.".format(
device_type.manufacturer, device_type
))
# An Interface must belong to a Device *or* to a VirtualMachine # An Interface must belong to a Device *or* to a VirtualMachine
if self.device and self.virtual_machine: if self.device and self.virtual_machine:
raise ValidationError("An interface cannot belong to both a device and a virtual machine.") raise ValidationError("An interface cannot belong to both a device and a virtual machine.")

View File

@ -1432,7 +1432,7 @@ class ConsoleServerPortTest(HttpStatusMixin, APITestCase):
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_console_server=True
) )
devicerole = DeviceRole.objects.create( devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000' name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
@ -1590,7 +1590,7 @@ class PowerOutletTest(HttpStatusMixin, APITestCase):
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_pdu=True
) )
devicerole = DeviceRole.objects.create( devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000' name='Test Device Role 1', slug='test-device-role-1', color='ff0000'
@ -1667,7 +1667,7 @@ class InterfaceTest(HttpStatusMixin, APITestCase):
site = Site.objects.create(name='Test Site 1', slug='test-site-1') site = Site.objects.create(name='Test Site 1', slug='test-site-1')
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
devicetype = DeviceType.objects.create( devicetype = DeviceType.objects.create(
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True
) )
devicerole = DeviceRole.objects.create( devicerole = DeviceRole.objects.create(
name='Test Device Role 1', slug='test-device-role-1', color='ff0000' name='Test Device Role 1', slug='test-device-role-1', color='ff0000'

View File

@ -7,7 +7,7 @@ from django.db import transaction
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from extras.constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_SELECT from extras.constants import CF_TYPE_BOOLEAN, CF_TYPE_DATE, CF_TYPE_INTEGER, CF_TYPE_SELECT
from extras.models import CustomField, CustomFieldChoice, CustomFieldValue from extras.models import CustomField, CustomFieldChoice, CustomFieldValue
from utilities.api import ValidatedModelSerializer from utilities.api import ValidatedModelSerializer
@ -38,6 +38,15 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
# Data validation # Data validation
if value not in [None, '']: if value not in [None, '']:
# Validate integer
if cf.type == CF_TYPE_INTEGER:
try:
int(value)
except ValueError:
raise ValidationError(
"Invalid value for integer field {}: {}".format(field_name, value)
)
# Validate boolean # Validate boolean
if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]: if cf.type == CF_TYPE_BOOLEAN and value not in [True, False, 1, 0]:
raise ValidationError( raise ValidationError(

View File

@ -2,6 +2,7 @@
# Generated by Django 1.11.4 on 2017-09-26 21:25 # Generated by Django 1.11.4 on 2017-09-26 21:25
from __future__ import unicode_literals from __future__ import unicode_literals
from distutils.version import StrictVersion from distutils.version import StrictVersion
import re
from django.conf import settings from django.conf import settings
import django.contrib.postgres.fields.jsonb import django.contrib.postgres.fields.jsonb
@ -18,7 +19,7 @@ def verify_postgresql_version(apps, schema_editor):
with connection.cursor() as cursor: with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()") cursor.execute("SELECT VERSION()")
row = cursor.fetchone() row = cursor.fetchone()
pg_version = row[0].split()[1] pg_version = re.match('^PostgreSQL (\d+\.\d+(\.\d+)?)', row[0]).group(1)
if StrictVersion(pg_version) < StrictVersion('9.4.0'): if StrictVersion(pg_version) < StrictVersion('9.4.0'):
raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version)) raise Exception("PostgreSQL 9.4.0 or higher is required ({} found). Upgrade PostgreSQL and then run migrations again.".format(pg_version))

View File

@ -160,7 +160,8 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Role (slug)', label='Role (slug)',
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=PREFIX_STATUS_CHOICES choices=PREFIX_STATUS_CHOICES,
null_value=None
) )
class Meta: class Meta:
@ -265,7 +266,8 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Interface (ID)', label='Interface (ID)',
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_STATUS_CHOICES choices=IPADDRESS_STATUS_CHOICES,
null_value=None
) )
role = django_filters.MultipleChoiceFilter( role = django_filters.MultipleChoiceFilter(
choices=IPADDRESS_ROLE_CHOICES choices=IPADDRESS_ROLE_CHOICES
@ -364,7 +366,8 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet):
label='Role (slug)', label='Role (slug)',
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=VLAN_STATUS_CHOICES choices=VLAN_STATUS_CHOICES,
null_value=None
) )
class Meta: class Meta:

View File

@ -688,6 +688,11 @@ class IPAddressBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
nullable_fields = ['vrf', 'role', 'tenant', 'description'] nullable_fields = ['vrf', 'role', 'tenant', 'description']
class IPAddressAssignForm(BootstrapMixin, forms.Form):
vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF')
address = forms.CharField(label='IP Address')
def ipaddress_status_choices(): def ipaddress_status_choices():
status_counts = {} status_counts = {}
for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'): for status in IPAddress.objects.values('status').annotate(count=Count('status')).order_by('status'):

View File

@ -76,6 +76,10 @@ IPADDRESS_LINK = """
{% endif %} {% endif %}
""" """
IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
"""
IPADDRESS_PARENT = """ IPADDRESS_PARENT = """
{% if record.interface %} {% if record.interface %}
<a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a> <a href="{{ record.interface.parent.get_absolute_url }}">{{ record.interface.parent }}</a>
@ -268,8 +272,8 @@ class PrefixDetailTable(PrefixTable):
class IPAddressTable(BaseTable): class IPAddressTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL)
vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF')
status = tables.TemplateColumn(STATUS_LABEL)
tenant = tables.TemplateColumn(TENANT_LINK) tenant = tables.TemplateColumn(TENANT_LINK)
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False) parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
interface = tables.Column(orderable=False) interface = tables.Column(orderable=False)
@ -293,6 +297,18 @@ class IPAddressDetailTable(IPAddressTable):
) )
class IPAddressAssignTable(BaseTable):
address = tables.TemplateColumn(IPADDRESS_ASSIGN_LINK, verbose_name='IP Address')
status = tables.TemplateColumn(STATUS_LABEL)
parent = tables.TemplateColumn(IPADDRESS_PARENT, orderable=False)
interface = tables.Column(orderable=False)
class Meta(BaseTable.Meta):
model = IPAddress
fields = ('address', 'vrf', 'status', 'role', 'tenant', 'parent', 'interface')
orderable = False
# #
# VLAN groups # VLAN groups
# #

View File

@ -60,6 +60,7 @@ urlpatterns = [
url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'),
url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'),
url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'),
url(r'^ip-addresses/assign/$', views.IPAddressAssignView.as_view(), name='ipaddress_assign'),
url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'), url(r'^ip-addresses/(?P<pk>\d+)/$', views.IPAddressView.as_view(), name='ipaddress'),
url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'), url(r'^ip-addresses/(?P<pk>\d+)/edit/$', views.IPAddressEditView.as_view(), name='ipaddress_edit'),
url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'), url(r'^ip-addresses/(?P<pk>\d+)/delete/$', views.IPAddressDeleteView.as_view(), name='ipaddress_delete'),

View File

@ -4,7 +4,7 @@ import netaddr
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count, Q from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.views.generic import View from django.views.generic import View
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
@ -550,7 +550,7 @@ class PrefixIPAddressesView(View):
'prefix': prefix, 'prefix': prefix,
'ip_table': ip_table, 'ip_table': ip_table,
'permissions': permissions, 'permissions': permissions,
'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf or '0', prefix.prefix), 'bulk_querystring': 'vrf_id={}&parent={}'.format(prefix.vrf.pk if prefix.vrf else '0', prefix.prefix),
}) })
@ -686,6 +686,51 @@ class IPAddressEditView(IPAddressCreateView):
permission_required = 'ipam.change_ipaddress' permission_required = 'ipam.change_ipaddress'
class IPAddressAssignView(PermissionRequiredMixin, View):
"""
Search for IPAddresses to be assigned to an Interface.
"""
permission_required = 'ipam.change_ipaddress'
def dispatch(self, request, *args, **kwargs):
# Redirect user if an interface has not been provided
if 'interface' not in request.GET:
return redirect('ipam:ipaddress_add')
return super(IPAddressAssignView, self).dispatch(request, *args, **kwargs)
def get(self, request):
form = forms.IPAddressAssignForm()
return render(request, 'ipam/ipaddress_assign.html', {
'form': form,
'return_url': request.GET.get('return_url', ''),
})
def post(self, request):
form = forms.IPAddressAssignForm(request.POST)
table = None
if form.is_valid():
queryset = IPAddress.objects.select_related(
'vrf', 'tenant', 'interface__device', 'interface__virtual_machine'
).filter(
vrf=form.cleaned_data['vrf'],
address__net_host=form.cleaned_data['address'],
)
table = tables.IPAddressAssignTable(queryset)
return render(request, 'ipam/ipaddress_assign.html', {
'form': form,
'table': table,
'return_url': request.GET.get('return_url', ''),
})
class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView):
permission_required = 'ipam.delete_ipaddress' permission_required = 'ipam.delete_ipaddress'
model = IPAddress model = IPAddress

View File

@ -13,7 +13,7 @@ except ImportError:
) )
VERSION = '2.2.5-dev' VERSION = '2.2.6-dev'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@ -1,4 +1,16 @@
{% load helpers %}
<ul class="nav nav-tabs" style="margin-bottom: 20px"> <ul class="nav nav-tabs" style="margin-bottom: 20px">
<li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_add' %}">Individual</a></li> <li role="presentation"{% if active_tab == 'add' %} class="active"{% endif %}>
<li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}><a href="{% url 'ipam:ipaddress_bulk_add' %}">Bulk</a></li> <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}">New IP</a>
</li>
{% if 'interface' in request.GET %}
<li role="presentation"{% if active_tab == 'assign' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}">Assign IP</a>
</li>
{% else %}
<li role="presentation"{% if active_tab == 'bulk_add' %} class="active"{% endif %}>
<a href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}">Bulk Create</a>
</li>
{% endif %}
</ul> </ul>

View File

@ -0,0 +1,48 @@
{% extends 'utilities/obj_edit.html' %}
{% load static from staticfiles %}
{% 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 an IP Address</h3>
{% include 'ipam/inc/ipadress_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 IP Address</strong></div>
<div class="panel-body">
{% render_field form.vrf %}
{% render_field form.address %}
</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-10 col-md-offset-1" 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

@ -132,7 +132,7 @@ def querystring(request, **kwargs):
querydict[k] = v querydict[k] = v
elif k in querydict: elif k in querydict:
querydict.pop(k) querydict.pop(k)
querystring = querydict.urlencode() querystring = querydict.urlencode(safe='/')
if querystring: if querystring:
return '?' + querystring return '?' + querystring
else: else:

View File

@ -6,6 +6,7 @@ from dcim.api.serializers import NestedDeviceRoleSerializer, NestedPlatformSeria
from dcim.constants import IFACE_FF_VIRTUAL from dcim.constants import IFACE_FF_VIRTUAL
from dcim.models import Interface from dcim.models import Interface
from extras.api.customfields import CustomFieldModelSerializer from extras.api.customfields import CustomFieldModelSerializer
from ipam.models import IPAddress
from tenancy.api.serializers import NestedTenantSerializer from tenancy.api.serializers import NestedTenantSerializer
from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer from utilities.api import ChoiceFieldSerializer, ValidatedModelSerializer
from virtualization.constants import STATUS_CHOICES from virtualization.constants import STATUS_CHOICES
@ -83,18 +84,30 @@ class WritableClusterSerializer(CustomFieldModelSerializer):
# Virtual machines # Virtual machines
# #
# Cannot import ipam.api.NestedIPAddressSerializer due to circular dependency
class VirtualMachineIPAddressSerializer(serializers.ModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:ipaddress-detail')
class Meta:
model = IPAddress
fields = ['id', 'url', 'family', 'address']
class VirtualMachineSerializer(CustomFieldModelSerializer): class VirtualMachineSerializer(CustomFieldModelSerializer):
status = ChoiceFieldSerializer(choices=STATUS_CHOICES) status = ChoiceFieldSerializer(choices=STATUS_CHOICES)
cluster = NestedClusterSerializer() cluster = NestedClusterSerializer()
role = NestedDeviceRoleSerializer() role = NestedDeviceRoleSerializer()
tenant = NestedTenantSerializer() tenant = NestedTenantSerializer()
platform = NestedPlatformSerializer() platform = NestedPlatformSerializer()
primary_ip = VirtualMachineIPAddressSerializer()
primary_ip4 = VirtualMachineIPAddressSerializer()
primary_ip6 = VirtualMachineIPAddressSerializer()
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6', 'vcpus', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6',
'memory', 'disk', 'comments', 'custom_fields', 'vcpus', 'memory', 'disk', 'comments', 'custom_fields',
] ]

View File

@ -70,7 +70,8 @@ class VirtualMachineFilter(CustomFieldFilterSet):
label='Search', label='Search',
) )
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=STATUS_CHOICES choices=STATUS_CHOICES,
null_value=None
) )
cluster_group_id = django_filters.ModelMultipleChoiceFilter( cluster_group_id = django_filters.ModelMultipleChoiceFilter(
name='cluster__group', name='cluster__group',

View File

@ -1,5 +1,6 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from django.conf import settings
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
@ -255,3 +256,14 @@ class VirtualMachine(CreatedUpdatedModel, CustomFieldModel):
def get_status_class(self): def get_status_class(self):
return VM_STATUS_CLASSES[self.status] return VM_STATUS_CLASSES[self.status]
@property
def primary_ip(self):
if settings.PREFER_IPV4 and self.primary_ip4:
return self.primary_ip4
elif self.primary_ip6:
return self.primary_ip6
elif self.primary_ip4:
return self.primary_ip4
else:
return None